diff --git a/.claude/skills/agent-integration/SKILL.md b/.claude/skills/agent-integration/SKILL.md index d2b990c7b0..9b2c7b4632 100644 --- a/.claude/skills/agent-integration/SKILL.md +++ b/.claude/skills/agent-integration/SKILL.md @@ -19,7 +19,7 @@ Collect these before starting (ask the user if not provided): | Parameter | Description | How to derive | |-----------|-------------|---------------| -| `AGENT_NAME` | Human-readable name (e.g., "Gemini CLI") | User provides | +| `AGENT_NAME` | Human-readable name (e.g., "Copilot CLI") | User provides | | `AGENT_PACKAGE` | Go package dir name — **no hyphens** | Lowercase, remove hyphens/spaces | | `AGENT_KEY` | Registry key for `agent.Register()` and `entire enable` | Check existing patterns in `cmd/entire/cli/agent/registry.go` | | `AGENT_SLUG` | Filesystem/URL-safe slug (kebab-case) used in E2E runner filenames and script names | Kebab-case of agent name; align with existing entries in `e2e/agents/` | diff --git a/.claude/skills/agent-integration/test-writer.md b/.claude/skills/agent-integration/test-writer.md index 8f5d3557b9..d23d8a7874 100644 --- a/.claude/skills/agent-integration/test-writer.md +++ b/.claude/skills/agent-integration/test-writer.md @@ -49,7 +49,7 @@ Read `cmd/entire/cli/agent/$AGENT_PACKAGE/AGENT.md` (the one-pager from the rese Add a new `Agent` implementation in `e2e/agents/${agent_slug}.go`: -**Pattern to follow** (based on existing implementations like `claude.go`, `gemini.go`, `opencode.go`): +**Pattern to follow** (based on existing implementations like `claude.go`, `codex.go`, `opencode.go`): ```go package agents @@ -153,7 +153,7 @@ func (a *${AgentName}) StartSession(ctx context.Context, dir string) (Session, e Key implementation details: - Self-register in `init()` with `Register()`, gated by `E2E_AGENT` env var -- Use `RegisterGate("name", N)` if the agent's API has strict rate limits (e.g., Gemini uses gate of 1) +- Use `RegisterGate("name", N)` if the agent's API has strict rate limits (e.g., Factory AI Droid uses gate of 1) - `Bootstrap()` handles CI-specific one-time setup (auth config, API key injection) - `IsTransientError()` identifies retryable API failures — `RepoState.RunPrompt` retries once on transient errors - `RunPrompt()` uses `exec.CommandContext` with `Setpgid: true` and process-group kill for clean cancellation diff --git a/.entire/settings.json b/.entire/settings.json index 77a79ecc1f..8973cb62fd 100644 --- a/.entire/settings.json +++ b/.entire/settings.json @@ -4,8 +4,7 @@ "checkpoint_remote": { "provider": "github", "repo": "entireio/cli-checkpoints" - }, - "filtered_fetches": true + } }, "strategy": "manual-commit", "checkpoints": { diff --git a/.gemini/settings.json b/.gemini/settings.json index e6c377128b..8e9e2882f6 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -3,147 +3,5 @@ "fileName": [ "AGENTS.md" ] - }, - "hooks": { - "AfterAgent": [ - { - "hooks": [ - { - "name": "entire-after-agent", - "type": "command", - "command": "sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks gemini after-agent'" - } - ] - } - ], - "AfterModel": [ - { - "hooks": [ - { - "name": "entire-after-model", - "type": "command", - "command": "sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks gemini after-model'" - } - ] - } - ], - "AfterTool": [ - { - "matcher": "*", - "hooks": [ - { - "name": "entire-after-tool", - "type": "command", - "command": "sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks gemini after-tool'" - } - ] - } - ], - "BeforeAgent": [ - { - "hooks": [ - { - "name": "entire-before-agent", - "type": "command", - "command": "sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks gemini before-agent'" - } - ] - } - ], - "BeforeModel": [ - { - "hooks": [ - { - "name": "entire-before-model", - "type": "command", - "command": "sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks gemini before-model'" - } - ] - } - ], - "BeforeTool": [ - { - "matcher": "*", - "hooks": [ - { - "name": "entire-before-tool", - "type": "command", - "command": "sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks gemini before-tool'" - } - ] - } - ], - "BeforeToolSelection": [ - { - "hooks": [ - { - "name": "entire-before-tool-selection", - "type": "command", - "command": "sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks gemini before-tool-selection'" - } - ] - } - ], - "Notification": [ - { - "hooks": [ - { - "name": "entire-notification", - "type": "command", - "command": "sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks gemini notification'" - } - ] - } - ], - "PreCompress": [ - { - "hooks": [ - { - "name": "entire-pre-compress", - "type": "command", - "command": "sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks gemini pre-compress'" - } - ] - } - ], - "SessionEnd": [ - { - "matcher": "exit", - "hooks": [ - { - "name": "entire-session-end-exit", - "type": "command", - "command": "sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks gemini session-end'" - } - ] - }, - { - "matcher": "logout", - "hooks": [ - { - "name": "entire-session-end-logout", - "type": "command", - "command": "sh -c 'if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks gemini session-end'" - } - ] - } - ], - "SessionStart": [ - { - "hooks": [ - { - "name": "entire-session-start", - "type": "command", - "command": "sh -c 'if ! command -v entire >/dev/null 2>&1; then printf \"%s\\n\" \"{\\\"systemMessage\\\":\\\"Entire CLI is enabled but not installed or not on PATH. Installation guide: https://docs.entire.io/cli/installation#installation-methods\\\"}\"; exit 0; fi; exec entire hooks gemini session-start'" - } - ] - } - ] - }, - "hooksConfig": { - "enabled": true - }, - "tools": { - "enableHooks": true } } diff --git a/.gemini/test-hooks.sh b/.gemini/test-hooks.sh deleted file mode 100755 index c28736cea2..0000000000 --- a/.gemini/test-hooks.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/bin/bash -# Test script to verify Gemini CLI hooks work correctly -# Run from the cli directory: .gemini/test-hooks.sh - -set -e - -cd "$(dirname "$0")/.." - -echo "=== Testing Gemini CLI Hook Handlers ===" -echo "" - -# Create a temp directory for test transcript -TEMP_DIR=$(mktemp -d) -TRANSCRIPT_FILE="$TEMP_DIR/transcript.json" -echo '{"messages": [{"role": "user", "content": "test prompt"}]}' > "$TRANSCRIPT_FILE" - -# Test 1: Session Start Hook -echo "1. Testing session-start hook..." -SESSION_START_INPUT=$(cat <" + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "Commit: <${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}> by ${{ github.actor }}" + } + ] + } + ] + } + ] + } diff --git a/.github/workflows/e2e-windows.yml b/.github/workflows/e2e-windows.yml index 389a91fc6c..6d6921f88a 100644 --- a/.github/workflows/e2e-windows.yml +++ b/.github/workflows/e2e-windows.yml @@ -9,7 +9,7 @@ on: type: string default: "" agent: - description: "Agent to test (claude-code, factoryai-droid)" + description: "Agent to test (claude-code, factoryai-droid, antigravity)" required: false type: string default: claude-code @@ -27,6 +27,7 @@ on: options: - claude-code - factoryai-droid + - antigravity concurrency: group: e2e-windows-tests-${{ inputs.agent }} @@ -67,15 +68,28 @@ jobs: # droid.exe lands in %USERPROFILE%\bin, not .local\bin. "$env:USERPROFILE\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - name: Install antigravity + if: inputs.agent == 'antigravity' + run: | + # agy's official installer: it resolves the architecture, verifies a + # SHA-512 from the release manifest, and installs into + # %LOCALAPPDATA%\agy\bin. It always takes latest and has no version + # flag — right for a dispatch-only workflow, which wants whatever agy + # users actually have. e2e.yml pins instead; see its own comment. + irm https://antigravity.google/cli/install.ps1 | iex + "$env:LOCALAPPDATA\agy\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - name: Bootstrap agent shell: bash env: AGENT: ${{ inputs.agent }} # droid's runner writes ANTHROPIC_API_KEY into its BYOK settings, so # both agents bootstrap off the same key; FACTORY_API_KEY is droid's - # own auth. + # own auth. antigravity runs in agy's Gemini API-key mode off the + # shared GEMINI_API_KEY, as on Linux. ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} run: go run ./e2e/bootstrap "$AGENT" - name: Run isolated test @@ -85,6 +99,7 @@ jobs: AGENT: ${{ inputs.agent }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts E2E_PARALLEL: 1 run: | diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4110958eef..c0bd878d5a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -11,7 +11,7 @@ on: - '' - claude-code - opencode - - gemini-cli + - antigravity - factoryai-droid - cursor-cli - copilot-cli @@ -48,19 +48,19 @@ jobs: # shell metacharacters here otherwise. env: AGENT_INPUT: ${{ github.event.inputs.agent }} - # gemini-cli is deliberately absent from the all-agents list. Since - # Gemini CLI 0.57.0 the agent forces `core.hooksPath=''` on every shell - # command it runs, so no checkpoint is recorded for a commit it makes and - # the leg fails for a reason no change on our side can fix. It stays in - # the workflow_dispatch choices above so the leg can be run by hand - # against a newer Gemini release; put it back in this list (it is still - # in the RED "reliable" tier in notify-slack) once one restores hooks. run: | input="$AGENT_INPUT" if [ -n "$input" ]; then echo "agents=[\"$input\"]" >> "$GITHUB_OUTPUT" else - echo 'agents=["claude-code","opencode","factoryai-droid","cursor-cli","copilot-cli","roger-roger","codex"]' >> "$GITHUB_OUTPUT" + # antigravity runs in agy's Gemini API-key mode (the + # GEMINI_API_KEY repo secret, no account session). agy < 1.1.25 loaded + # .agents/hooks.json on that route but never executed the hooks + # (google-antigravity/antigravity-cli#893); the install step above + # always fetches the latest release, which has executed them since + # 1.1.25. With the optional ANTIGRAVITY_GOOGLE_APPLICATION_CREDENTIALS_JSON + # secret the leg runs on ADC instead. + echo 'agents=["claude-code","opencode","factoryai-droid","cursor-cli","copilot-cli","roger-roger","codex","antigravity"]' >> "$GITHUB_OUTPUT" fi e2e-tests: @@ -101,7 +101,32 @@ jobs: case "${{ matrix.agent }}" in claude-code) curl -fsSL https://claude.ai/install.sh | bash ;; opencode) curl -fsSL https://opencode.ai/install | bash ;; - gemini-cli) npm install -g @google/gemini-cli ;; + antigravity) + # Deliberately NOT pinned, because pinning agy does not work. + # agy self-updates IN PLACE — the updater replaces the executable + # at its own path, which in CI is the file installed here — and + # it spawns that updater on essentially every launch, ~0.4s in, + # with auth not gating it. Its only brake is a 15-minute check + # keyed on state a fresh CI home never has. So a pinned tarball + # governs only the FIRST agy spawn of the job, and the harness + # spawns agy once per prompt (e2e/agents/antigravity.go), so + # everything after prompt one runs whatever the updater fetched. + # Measured on Windows 11 ARM64: 1.2.7 installed, one headless + # prompt, 1.2.9 at the same path afterwards. agy offers no way + # out — no flag, no settings key, no env var. + # + # The consequence, stated rather than implied: this job's agy + # version floats, so a third-party agy regression CAN redden PRs + # that never touched Antigravity (see #893). That exposure was + # always real; the pin that used to sit here only read like + # protection. Closing it needs the updater actually blocked — + # an unwritable install path, or no egress for this step — and + # neither is tested yet. + # + # nightly-e2e.yml also tracks latest, but for its own reason: + # that job exists to report agy drift. + curl -fsSL https://antigravity.google/cli/install.sh | bash + ;; codex) npm install -g @openai/codex ;; cursor-cli) curl https://cursor.com/install -fsS | bash ;; factoryai-droid) curl -fsSL https://app.factory.ai/cli | sh ;; @@ -118,11 +143,27 @@ jobs: command -v roger-roger command -v entire-agent-roger-roger + - name: Configure Antigravity ADC + if: matrix.agent == 'antigravity' + env: + ANTIGRAVITY_GOOGLE_APPLICATION_CREDENTIALS_JSON: ${{ secrets.ANTIGRAVITY_GOOGLE_APPLICATION_CREDENTIALS_JSON }} + run: | + set -euo pipefail + # ADC is optional since agy 1.1.13: without the secret the harness runs + # agy in Gemini API-key mode (the GEMINI_API_KEY repo secret). + if [ -z "${ANTIGRAVITY_GOOGLE_APPLICATION_CREDENTIALS_JSON:-}" ]; then + echo "No ANTIGRAVITY_GOOGLE_APPLICATION_CREDENTIALS_JSON secret; Antigravity E2E uses GEMINI_API_KEY (agy API-key mode)" + exit 0 + fi + creds="$RUNNER_TEMP/antigravity-google-credentials.json" + install -m 600 /dev/null "$creds" + printf '%s' "$ANTIGRAVITY_GOOGLE_APPLICATION_CREDENTIALS_JSON" > "$creds" + echo "GOOGLE_APPLICATION_CREDENTIALS=$creds" >> "$GITHUB_ENV" + - name: Bootstrap agent if: matrix.agent != 'roger-roger' env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} @@ -134,15 +175,13 @@ jobs: env: TEST_FILTER: ${{ inputs.test }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - E2E_CODEX_MODEL: ${{ matrix.agent == 'codex' && 'gpt-5.6-luna' || '' }} - E2E_GEMINI_MODEL: ${{ matrix.agent == 'gemini-cli' && 'gemini-3.1-flash-lite' || '' }} + E2E_CODEX_MODEL: ${{ matrix.agent == 'codex' && 'gpt-6-luna' || '' }} CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} # Only the copilot-cli leg gets the token; other agents must not receive it. COPILOT_GITHUB_TOKEN: ${{ matrix.agent == 'copilot-cli' && github.token || '' }} - E2E_CONCURRENT_TEST_LIMIT: ${{ matrix.agent == 'gemini-cli' && '6' || matrix.agent == 'factoryai-droid' && '1' || matrix.agent == 'cursor-cli' && '2' || '' }} + E2E_CONCURRENT_TEST_LIMIT: ${{ matrix.agent == 'factoryai-droid' && '1' || matrix.agent == 'cursor-cli' && '2' || '' }} # roger-roger is deterministic, so it runs through its dedicated task, # which does NOT enable --rerun-fails. Routing it through the default # task (`test:e2e`) would retry a real regression and mask it. @@ -174,9 +213,9 @@ jobs: retention-days: 7 e2e-windows: - # Windows covers the two agents e2e-windows.yml can install. An unset agent + # Windows covers the agents e2e-windows.yml can install. An unset agent # (the push path) means claude-code, as before. - if: inputs.agent == '' || inputs.agent == 'claude-code' || inputs.agent == 'factoryai-droid' + if: inputs.agent == '' || inputs.agent == 'claude-code' || inputs.agent == 'factoryai-droid' || inputs.agent == 'antigravity' uses: ./.github/workflows/e2e-windows.yml permissions: contents: read @@ -205,7 +244,7 @@ jobs: set -euo pipefail # Reliable tier (RED). The e2e-windows reusable job is also RED but # is matched separately below since it has no "(agent)" suffix. - reliable="claude-code opencode gemini-cli roger-roger" + reliable="claude-code opencode roger-roger" failed_names=$(gh api --paginate \ "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs" \ diff --git a/.github/workflows/nightly-e2e.yml b/.github/workflows/nightly-e2e.yml index 8e190db58c..ce8a5d3eca 100644 --- a/.github/workflows/nightly-e2e.yml +++ b/.github/workflows/nightly-e2e.yml @@ -24,13 +24,13 @@ concurrency: jobs: smoke: runs-on: ${{ matrix.os }} - # Above the sum of the agents' own retry ceilings (~85m: 9 + 18 + 13.5 + - # 13.5 + 18 + 13.5, see the step caps below) plus setup, so the per-step cap - # is always the one that binds — a step timeout leaves an attributable row - # in the table, a job timeout kills Summarize and the artifact upload with - # it. Legs measure 6m30s-8m40s; this only binds on a hang in one of the - # uncapped setup steps. - timeout-minutes: 90 + # Above the sum of the agents' own retry ceilings (~108m: 9 + 18 + 13.5 + + # 13.5 + 18 + 13.5 + 22.5, see the step caps below) plus setup, so the + # per-step cap is always the one that binds — a step timeout leaves an + # attributable row in the table, a job timeout kills Summarize and the + # artifact upload with it. Legs measure 6m30s-8m40s; this only binds on a + # hang in one of the uncapped setup steps. + timeout-minutes: 115 permissions: contents: read actions: read @@ -121,6 +121,33 @@ jobs: "NIGHTLY_TAG=$tag" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "E2E_ENTIRE_BIN=$((Get-Command entire).Source)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + # Windows reads the version from the binary's PE resource section, not + # from the ldflags stamp `entire version` prints (#2218). Compared against + # the tag this nightly was built from, so resources generated for the + # wrong version fail here too, not only missing ones. + - name: Verify Windows PE version metadata + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + # Stop is what makes the Write-Error calls below terminating. + $ErrorActionPreference = "Stop" + # goreleaser passes {{.Version}}, which is the tag without its "v". + $expected = $env:NIGHTLY_TAG -replace '^v', '' + # The numeric tuple carries major.minor.patch only; the prerelease + # suffix lives in the string fields. + $expectedNumeric = [version]"$($expected -replace '-.*', '').0" + foreach ($name in "entire", "git-remote-entire") { + $command = Get-Command $name + $product = (Get-Item $command.Source).VersionInfo.ProductVersion + Write-Host "$name $($command.Version) $product" + if ($command.Version -ne $expectedNumeric) { + Write-Error "$name PE version is $($command.Version), expected $expectedNumeric from $env:NIGHTLY_TAG" + } + if ($product -ne $expected) { + Write-Error "$name PE product version is '$product', expected '$expected'" + } + } + - name: Checkout the nightly tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -141,9 +168,8 @@ jobs: if: matrix.os == 'macos-latest' run: brew install tmux - # Excluded everywhere: gemini-cli (forces core.hooksPath='' since 0.57.0, - # so nothing is checkpointed), pi (no CI installer), vogon and - # roger-roger (not shipped agents). + # Excluded everywhere: pi (no CI installer), vogon and roger-roger (not + # shipped agents). - name: Install agent CLIs (Unix) if: matrix.os != 'windows-latest' run: | @@ -155,32 +181,50 @@ jobs: # unauthenticated api.github.com call it gives no way to authenticate, # so it is rate-limited at random. npm install -g @openai/codex @github/copilot opencode-ai + # agy's official installer: it resolves OS and architecture itself, + # verifies a SHA-512 from the release manifest, and installs latest + # into ~/.local/bin, which the line below puts on PATH. + curl -fsSL https://antigravity.google/cli/install.sh | bash echo "$HOME/.local/bin" >> "$GITHUB_PATH" - echo "AGENTS=claude-code opencode codex cursor-cli factoryai-droid copilot-cli" >> "$GITHUB_ENV" + echo "AGENTS=claude-code opencode codex cursor-cli factoryai-droid copilot-cli antigravity" >> "$GITHUB_ENV" - # cursor-cli is absent: its E2E driver sends every prompt through tmux - # (e2e/agents/cursor_cli.go), because Cursor's headless -p mode fires no - # hooks — and there is no tmux on Windows. + # cursor-cli is the only absence here: its E2E driver sends every prompt + # through tmux (e2e/agents/cursor_cli.go), because Cursor's headless -p + # mode fires no hooks — and there is no tmux on Windows. + # + # antigravity matters most on this leg: it is the one agent whose Windows + # hook path differs from its Unix one (agy hands hook commands to + # cmd.exe, not sh), and a hook that fails there is silent — agy still + # reports success. Nothing else exercises that path automatically. - name: Install agent CLIs (Windows) if: matrix.os == 'windows-latest' shell: pwsh run: | irm https://claude.ai/install.ps1 | iex irm https://app.factory.ai/cli/windows | iex + irm https://antigravity.google/cli/install.ps1 | iex npm install -g @openai/codex @github/copilot opencode-ai # Stop does not cover native commands ($PSNativeCommandUseError- # ActionPreference defaults to $false), so npm's failure needs this. if ($LASTEXITCODE -ne 0) { throw "npm install failed (exit $LASTEXITCODE)" } - # Neither iex line needs such a guard: both installers throw or exit - # on every failure path, and that ends this step. Verified 2026-09-09 - # for claude, 2026-09-10 for droid, whose installer shadows - # Write-Error with one that exits 1. + # None of the three iex lines needs such a guard: each installer + # throws or exits on every failure path, and that ends this step. + # Verified 2026-09-09 for claude, 2026-09-10 for droid, whose + # installer shadows Write-Error with one that exits 1, and + # 2026-09-23 for agy, which throws rather than exits when it is run + # sourced, as `| iex` runs it. "$HOME\.local\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append # droid's Windows installer puts droid.exe in %USERPROFILE%\bin, not # .local\bin, and updates only the stored user PATH, which later # steps do not re-read. "$HOME\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - "AGENTS=claude-code codex copilot-cli opencode factoryai-droid" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + # agy lands in %LOCALAPPDATA%\agy\bin, and its installer updates the + # stored user PATH the same way. It picks the architecture itself and + # always serves latest, so there is nothing to keep in sync here; + # e2e.yml downloads a release tarball instead only because it pins an + # exact version, which this installer has no flag for. + "$env:LOCALAPPDATA\agy\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + "AGENTS=claude-code codex copilot-cli opencode factoryai-droid antigravity" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append # One step per agent: a step timeout is the only per-agent bound that # works on all three runners (macOS has no timeout(1)), and one hung agent @@ -207,7 +251,8 @@ jobs: # E2E_ENTIRE_BIN (job env, set above) is what makes this the *installed* # nightly: the mise task skips its build when it is set. # - # The six bodies are byte-identical; only AGENT and the key differ. + # The seven bodies are byte-identical; only AGENT, the key and (for + # antigravity, whose TimeoutMultiplier is 2.5x) the step cap differ. - name: Smoke test claude-code if: ${{ !cancelled() && contains(format(' {0} ', env.AGENTS), ' claude-code ') }} timeout-minutes: 20 @@ -253,7 +298,7 @@ jobs: env: AGENT: codex OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - E2E_CODEX_MODEL: gpt-5.6-luna + E2E_CODEX_MODEL: gpt-6-luna run: | set +e set -uo pipefail @@ -325,6 +370,28 @@ jobs: echo "$AGENT $rc" >> e2e/artifacts/results.txt exit "$rc" + # 25 minutes: 3 attempts x 3 min x the 2.5x multiplier is 22.5 min. + # GEMINI_API_KEY selects agy's API-key mode (no account session); the + # harness writes modelProvider=gemini into an isolated HOME itself. + - name: Smoke test antigravity + if: ${{ !cancelled() && contains(format(' {0} ', env.AGENTS), ' antigravity ') }} + timeout-minutes: 25 + shell: bash + env: + AGENT: antigravity + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: | + set +e + set -uo pipefail + mkdir -p e2e/artifacts + go run ./e2e/bootstrap "$AGENT" && + E2E_ARTIFACT_DIR="$ARTIFACT_ROOT/$AGENT" \ + mise run test:e2e --agent "$AGENT" "$TEST_FILTER" + rc=$? + grep -q '^Total: 0 ' "$ARTIFACT_ROOT/$AGENT/report.txt" 2>/dev/null && rc=1 + echo "$AGENT $rc" >> e2e/artifacts/results.txt + exit "$rc" + - name: Summarize if: always() shell: bash diff --git a/.gitignore b/.gitignore index 896bb738c2..4f2e6bc345 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,12 @@ *.so *.dylib +# Generated Windows PE resource objects (mise run winversion). Go links a .syso +# only from the package directory it compiles, so these two paths are the only +# places they can be written. +cmd/entire/*.syso +cmd/git-remote-entire/*.syso + # Test binary, built with `go test -c` *.test @@ -55,7 +61,6 @@ e2e/artifacts/ .worktrees/ .entire/worktrees/ .claude/worktrees/ -test-gemini.txt entire-main entire-test test_claude.txt diff --git a/.goreleaser.yaml b/.goreleaser.yaml index f8a40950b4..25efdafeaf 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -6,6 +6,7 @@ before: hooks: - go mod tidy - mise run completions + - mise run winversion {{.Version}} builds: - id: entire diff --git a/CHANGELOG.md b/CHANGELOG.md index 3512f6d1d6..20e47f301a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,114 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Breaking changes and migrations + +- Gemini CLI support is removed: `entire configure --agent gemini`, Gemini hook capture, the Gemini review runner and summary provider, session import and resume, and Gemini skill setup are gone. Entire hooks already installed in `.gemini/settings.json` now exit silently without recording anything; `entire doctor` and `entire disable --uninstall` remove them. Checkpoints recorded from earlier Gemini CLI sessions remain readable by `entire explain` and summaries + +## [0.11.2] - 2026-09-23 + +### Changed + +- Trail, review, thread and watch requests follow the RFD-026 cell contract (snake_case payloads, bracket filters, cursor pagination, dotted watch event names). `--json` output keeps its existing keys. `entire trail finding list` replaces `--offset` with `--cursor` and returns `next_cursor` alongside `has_more` when another page exists ([#2507](https://github.com/entireio/cli/pull/2507)) + +### Fixed + +- A trailing `.git` in a native repository reference is now part of the repo name instead of being stripped. Previously `entire repo delete /et//foo.git` could delete a sibling repo named `foo`, and repo-scoped commands inside a clone of `foo.git` targeted `foo`. Confirmation lines now show the repository the server resolved, `repo create` accepts `.git` names as the API does, and dot-only owner or repo names are rejected ([#2557](https://github.com/entireio/cli/pull/2557)) +- `ENTIRE_CHECKPOINT_TOKEN` is only attached when the checkpoint remote uses a direct git transport. `entire://` and `file://` remotes are used as configured and are never rewritten into credentialed HTTPS URLs ([#2558](https://github.com/entireio/cli/pull/2558)) +- Session IDs containing surrounding whitespace, trailing periods, ASCII control characters or Windows reserved device names are rejected on every OS. Unsafe checkpoint metadata is refused before session files are resolved or restored, and external-agent session references are checked against the repository session store before `write-session` runs ([#2405](https://github.com/entireio/cli/pull/2405)) + +### Housekeeping + +- Removed `filtered_fetches` from this repository's `.entire/settings.json` ([#2560](https://github.com/entireio/cli/pull/2560)) + +### Thanks + +Thanks to @wernerkasselman-au for responsibly reporting the session ID path validation issue! + +## [0.11.0] - 2026-09-22 + +### Breaking changes and migrations + +- Control-plane commands are renamed without compatibility aliases. `auth use` is now `auth switch`, `repo get` is `repo view`, `repo visibility set` is `repo edit --visibility`, `repo list ` takes `--project`, `repo mirror create|use` become `repo mirror add` and `repo remote use`, and `repo mirror collaborators list` becomes `repo access list`; positional cluster arguments become `--cluster`. `entire grant ` becomes `entire grant `. Update scripts using the old spellings ([#2502](https://github.com/entireio/cli/pull/2502), [#2490](https://github.com/entireio/cli/pull/2490)) +- `entire logout` processes every saved login and revokes CLI sessions on other machines too. Older servers fall back to revoking only the current session. Browser and web sessions remain active unless `--everywhere` is passed. `--all-contexts` is removed, explicit `--context` is rejected, and Ctrl-C stops the sweep ([#2510](https://github.com/entireio/cli/pull/2510), [#2539](https://github.com/entireio/cli/pull/2539)) +- `entire enable` initializes repositories locally without creating or pushing a GitHub remote. `--repo-name`, `--repo-owner`, `--repo-visibility`, `--no-github` and `--push` are removed; publish separately. Git initialization, identity setup and the optional initial commit are unchanged ([#2485](https://github.com/entireio/cli/pull/2485)) +- Mirror creation always uses the asynchronous workflow. Remove the retired `async_mirror_requests` key from settings files; strict decoding rejects it ([#2267](https://github.com/entireio/cli/pull/2267)) +- `entire investigate` moves to the [entire-investigate](https://github.com/entireio/entire-investigate) plugin. Install with `entire plugin install investigate` or accept the install prompt; existing flags are unchanged. Copy the old `investigate` settings object into `.entire/investigate.local.json`; legacy settings blocks are ignored ([#2515](https://github.com/entireio/cli/pull/2515)) + +### Added + +- Native repository references (`/et//`) are supported by `repo view`, `delete`, `visibility`, `protection`, `grant` and `checkpoint explain --repo`. Ambiguous bare `owner/repo` references are rejected with forge-qualified suggestions. `entire dispatch --repos` also accepts native repos and sends forge-qualified slugs ([#2387](https://github.com/entireio/cli/pull/2387), [#2247](https://github.com/entireio/cli/pull/2247), [#2396](https://github.com/entireio/cli/pull/2396), [#2553](https://github.com/entireio/cli/pull/2553)) +- `entire repo mirror` supports native repositories. `repo clone` and `repo remote url` offer an interactive choice of the home cluster and ready native mirrors. Non-interactive runs use the primary cluster unless `--cluster ` is supplied; GitHub mirrors use the same selection flow ([#2516](https://github.com/entireio/cli/pull/2516), [#2529](https://github.com/entireio/cli/pull/2529), [#2546](https://github.com/entireio/cli/pull/2546)) +- `entire cluster list` shows regions, cluster slugs and validated hosts for use with commands such as `org create --region` and `repo clone --cluster` ([#2480](https://github.com/entireio/cli/pull/2480)) +- `entire repo remote url ` prints a repository's git remote URL and nothing else, so an existing local checkout can be connected with `git remote add entire "$(entire repo remote url /et/project/repo)"`. It works outside a git checkout and keeps placement prompts on stderr ([#2492](https://github.com/entireio/cli/pull/2492)) +- `entire repo protection list|add|remove` manages branch protection rules. Empty results distinguish unprotected native repositories from GitHub mirrors whose rules are managed upstream ([#2344](https://github.com/entireio/cli/pull/2344)) +- `entire auth switch` without an argument offers a saved-context picker. It and `auth contexts` consistently mark the active context ([#2326](https://github.com/entireio/cli/pull/2326), [#2347](https://github.com/entireio/cli/pull/2347)) +- Subagent tracking for Codex and Copilot CLI. Codex reports child token totals only with exact coverage. Copilot supports modern agent IDs and unambiguous name matching on 1.0.63; task records explain when child transcripts are unavailable ([#2209](https://github.com/entireio/cli/pull/2209), [#2341](https://github.com/entireio/cli/pull/2341)) +- `entire enable` asks which git remote should receive checkpoints, and `--checkpoint-push-remote ` selects one non-interactively. The override is written to `.entire/settings.local.json` only, leaving automatic election in place when you keep the current destination ([#2345](https://github.com/entireio/cli/pull/2345)) +- `entire configure --checkpoint-remote` accepts `gitlab:/`. Mirror push-through, the trails API, issue linking and entire.io checkpoint discovery remain GitHub-only ([#2528](https://github.com/entireio/cli/pull/2528)) +- OpenCode can generate summaries and tailor runners: `entire configure --summarize-provider opencode` works for `checkpoint explain --generate` and `runner setup`. The adapter runs in a temporary directory with a dedicated tool-denying agent and sharing disabled ([#2361](https://github.com/entireio/cli/pull/2361)) +- `entire graph` offers to install the Graph plugin on demand, with confirmation and progress on stderr. Non-interactive runs print an `entire plugin install graph` hint; stdout remains reserved for plugin output ([#2324](https://github.com/entireio/cli/pull/2324)) +- `entire repo create` waits up to 10 minutes for provisioning by default. `--no-wait` skips waiting; `--wait-timeout` changes the limit. If readiness cannot be confirmed, the command prints the created repository and recovery instructions and exits nonzero. `repo view` reads registry details by default; pass `--authoritative` to check provisioning status ([#2483](https://github.com/entireio/cli/pull/2483)) +- `entire enable` fills missing repo-local git identity fields from the verified Entire profile without overwriting configured values. It signs in when needed, or provides `ENTIRE_TOKEN` guidance in detected automation ([#2415](https://github.com/entireio/cli/pull/2415)) +- `entire login` automatically uses device-code authentication on Linux and BSD without a graphical display, including headless sessions not detected as SSH. WSL and an explicit `BROWSER` are excluded ([#2504](https://github.com/entireio/cli/pull/2504)) +- Nightly smoke tests install published artifacts on Linux, macOS and Windows and verify agent checkpointing. A stable-install job also tests the documented Homebrew command ([#2159](https://github.com/entireio/cli/pull/2159), [#2409](https://github.com/entireio/cli/pull/2409)) +- A control-plane E2E suite covers device-flow login and organization, project and repository lifecycle operations without a coding agent, using isolated credentials and automatic resource cleanup ([#2411](https://github.com/entireio/cli/pull/2411)) + +### Changed + +- Data-API commands and links follow `ENTIRE_TOKEN` or the selected login's environment rather than defaulting to production. `entire api --to cell` and `-j ` use that login's cluster catalog. Commands acting as a saved login announce the context on stderr when multiple logins exist ([#2509](https://github.com/entireio/cli/pull/2509), [#2414](https://github.com/entireio/cli/pull/2414), [#2538](https://github.com/entireio/cli/pull/2538)) +- `entire --context X` propagates to child processes through `ENTIRE_CONTEXT`, fixing inconsistent login selection in `repo clone`, `resume`, `explain` and `trail create`. Unknown context names are rejected before spawning children ([#2413](https://github.com/entireio/cli/pull/2413)) +- `entire status --json` reports one entry per session, with `session_id`, `worktree_path` and `branch`, instead of collapsing sessions for the same agent ([#2363](https://github.com/entireio/cli/pull/2363)) +- The hidden `entire checkpoint policy` command and its enforcement are removed. Its policy ref used a server-reserved namespace and could block repository mirroring ([#2508](https://github.com/entireio/cli/pull/2508)) +- Redaction and transcript reads use less memory and avoid repeated searches for duplicate findings. Redaction output and fingerprints are unchanged ([#2291](https://github.com/entireio/cli/pull/2291)) +- Agents no longer display a Preview suffix during setup. `Agent.IsPreview` and the external-agent `info` field `is_preview` are removed; external agents still emitting the field remain compatible ([#2554](https://github.com/entireio/cli/pull/2554)) +- Removed an unreachable wrong-cluster hint from `git-remote-entire` and outdated semantic-search rollout wording ([#2384](https://github.com/entireio/cli/pull/2384), [#2366](https://github.com/entireio/cli/pull/2366)) + +### Fixed + +- Imported checkpoints, including onboarding imports, carry a validated anchor commit to prevent hydration failures. The anchor must be a full hex commit ID; validation also applies to `--dry-run`. Existing imports are not repaired ([#2491](https://github.com/entireio/cli/pull/2491)) +- Fixed missing `factoryai-droid` checkpoints on Windows by installing native `cmd.exe` hook wrappers, including on hosts with Git Bash ([#2349](https://github.com/entireio/cli/pull/2349)) +- Managed plugin executables work on Windows without symlinks. Fixed the hardlink fallback on all platforms ([#2371](https://github.com/entireio/cli/pull/2371)) +- Interactive prompts use native Windows console handles. Plugin confirmations no longer require an extra keypress to close the prompt ([#2333](https://github.com/entireio/cli/pull/2333), [#2481](https://github.com/entireio/cli/pull/2481)) +- Commands run through Cursor's or OpenCode's shell tools no longer show unattended interactive prompts ([#2317](https://github.com/entireio/cli/pull/2317)) +- Already-committed files are no longer checkpointed again at turn-end. Comparisons against HEAD respect `autocrlf`, `.gitattributes` and Git LFS without refreshing the index ([#2482](https://github.com/entireio/cli/pull/2482), [#2336](https://github.com/entireio/cli/pull/2336), [#2372](https://github.com/entireio/cli/pull/2372)) +- `entire doctor` respects a valid checkpoint push remote, and `status` correctly reports disabled session pushing. Checkpoint fetches retain the selected read remote, fixing missing metadata and transcripts in clones with inherited checkpoint-remote settings ([#2329](https://github.com/entireio/cli/pull/2329), [#2337](https://github.com/entireio/cli/pull/2337), [#2342](https://github.com/entireio/cli/pull/2342)) +- Rejected checkpoint pushes preserve the remote's explanation, including secret-scanning and repository-rule failures. Confirmed policy and hook rejections no longer trigger speculative fetch/replay or get mislabeled as divergence ([#2488](https://github.com/entireio/cli/pull/2488), [#2514](https://github.com/entireio/cli/pull/2514)) +- `entire session current` uses agent session IDs and process ancestry instead of selecting the most recently active session, avoiding misidentification across worktrees and nested agents. Ambiguous matches are reported explicitly. `session adopt` does not yet enforce caller identification ([#2322](https://github.com/entireio/cli/pull/2322)) +- `entire status` no longer deletes stale records or finalizes exited sessions while reading them. Cleanup remains with `doctor` and the session sweeper ([#2363](https://github.com/entireio/cli/pull/2363)) +- Mid-turn commits record subagent tokens even when no shadow branch is resolved. Totals remain checkpoint-scoped rather than repeating cumulative usage ([#2334](https://github.com/entireio/cli/pull/2334)) +- Checkpoints remain visible in `checkpoint list` and `explain` when a case-insensitive filesystem changes the casing of a shard directory ([#2403](https://github.com/entireio/cli/pull/2403)) +- Native repositories shared by a direct pull grant resolve by `/et/` path without requiring project-level access. This fixes cloning and name resolution for repository management and cell-routing commands; each operation still requires its own permissions ([#2543](https://github.com/entireio/cli/pull/2543)) +- Unsupported summary providers produce an actionable error listing supported agents. `entire doctor` also detects invalid provider settings ([#2359](https://github.com/entireio/cli/pull/2359)) +- `entire logout` and `auth status` no longer count `null` or nameless entries in `contexts.json` as saved logins ([#2511](https://github.com/entireio/cli/pull/2511)) +- Shell completion installation and E2E transcript writes report file-close errors instead of silently accepting potentially incomplete writes ([#2357](https://github.com/entireio/cli/pull/2357)) +- Optimized the duplicate-key scan added in [#2321](https://github.com/entireio/cli/pull/2321) for large JSON objects while preserving unconditional detection ([#2348](https://github.com/entireio/cli/pull/2348)) + +### Security + +- Restored the cross-host redirect guard on cross-jurisdiction token exchanges after a regression between [#2224](https://github.com/entireio/cli/pull/2224) and [#2235](https://github.com/entireio/cli/pull/2235). This prevents redirects from forwarding login tokens to another host or accepting substituted tokens. Exchange URL validation remains in place ([#2261](https://github.com/entireio/cli/pull/2261)) +- Git hook installation uses confined filesystem operations, rejects symlinked hook directories and preserves foreign symlinked hooks. `doctor` reports unsafe hook directories. External summary-provider discovery requires the `external_agents` grant, and branch-name validation is strengthened ([#2315](https://github.com/entireio/cli/pull/2315)) +- JSONL redaction verifies replacements even when strings use alternate JSON encodings, falling back to structural rewriting or failing closed. Custom investigate and review prompts also require trusted settings before being passed to agents with approvals disabled ([#2321](https://github.com/entireio/cli/pull/2321)) +- GitHub Actions jobs declare explicit least-privilege token permissions. CodeQL scanning is limited to languages used in this repository ([#2354](https://github.com/entireio/cli/pull/2354), [#2355](https://github.com/entireio/cli/pull/2355)) + +### Housekeeping + +- Repository instructions are split into a compact `CLAUDE.md` and task-specific development references, with tests enforcing the root-file size budget and local documentation links. `AGENTS.md` remains a symlink ([#2506](https://github.com/entireio/cli/pull/2506)) +- Homebrew installation uses a single fully qualified cask command compatible with Homebrew 6's tap trust. The copyable command no longer includes a comment that fails in default interactive zsh. Requires Homebrew 6.0.10+ ([#2409](https://github.com/entireio/cli/pull/2409), [#2505](https://github.com/entireio/cli/pull/2505)) +- Updated Go from 1.26.6 to 1.27.1 and golangci-lint from 2.11.3 to 2.13.2, resolving findings from the updated linters ([#2356](https://github.com/entireio/cli/pull/2356)) +- Dependency bumps: `gofrs/flock`, `mattn/go-runewidth`, `posthog/posthog-go`, `golang.org/x/crypto`, `golang.org/x/mod`, `golang.org/x/sync`, `golang.org/x/sys`, and `aws-actions/configure-aws-credentials` ([#2314](https://github.com/entireio/cli/pull/2314), [#2325](https://github.com/entireio/cli/pull/2325), [#2530](https://github.com/entireio/cli/pull/2530)) +- All E2E agent runners honor `E2E_TIMEOUT` and per-test prompt timeouts. Malformed timeout values produce an error ([#2113](https://github.com/entireio/cli/pull/2113)) +- Consolidated E2E workflows with agent and test-regex selection. Nightly smoke failures now mark the individual agent's step as failed ([#2353](https://github.com/entireio/cli/pull/2353), [#2377](https://github.com/entireio/cli/pull/2377)) +- Codex E2E tests no longer use the retired `gpt-5.4-mini`. Multi-session prompts allow hook-added checkpoint trailers ([#2525](https://github.com/entireio/cli/pull/2525), [#2527](https://github.com/entireio/cli/pull/2527)) +- Fixed a terminal-test race, macOS path comparisons and `ENTIRE_TOKEN` test isolation. Redaction fixtures no longer embed a complete AWS-key-shaped value that triggers push protection ([#2526](https://github.com/entireio/cli/pull/2526), [#2339](https://github.com/entireio/cli/pull/2339), [#2489](https://github.com/entireio/cli/pull/2489), [#2544](https://github.com/entireio/cli/pull/2544), [#2542](https://github.com/entireio/cli/pull/2542), [#2389](https://github.com/entireio/cli/pull/2389)) +- Removed `.opencode/package-lock.json` from version control and tracked `.opencode/.gitignore` so fresh clones inherit its rules. Removed the repo-local `trail-summary` runner to prevent self-triggered trail-body updates ([#1980](https://github.com/entireio/cli/pull/1980), [#2407](https://github.com/entireio/cli/pull/2407)) + +### Thanks + +Thanks to @KC1706 for fixing checkpoints silently disappearing from `list` and `explain` when a case-insensitive filesystem folds a shard directory's case ([#2403](https://github.com/entireio/cli/pull/2403))! + ## [0.10.6] - 2026-09-07 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3186932a66..d404dad50d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -213,7 +213,7 @@ These are markdown files that define specialized behaviors for Claude Code (e.g. ### 2. Coding Agent Integrations (Go) -These are Go implementations that integrate Entire with different AI coding tools (Claude Code, Gemini CLI, OpenCode, Cursor, Factory AI Droid, Copilot CLI, etc.) using the Agent abstraction layer. +These are Go implementations that integrate Entire with different AI coding tools (Claude Code, Codex, Antigravity, OpenCode, Cursor, Factory AI Droid, Copilot CLI, etc.) using the Agent abstraction layer. - **Location:** `cmd/entire/cli/agent/` - **Steps:** @@ -272,7 +272,7 @@ Addressing Copilot feedback upfront is the fastest path to maintainer review. Entire exists to help you work with AI coding agents, so it would be odd if you weren't using one to contribute. There's no need to tell us you did. Our general thinking: use whatever agent and methodology you like, but until the robot revolution comes, you are responsible for the final code. Before submitting a PR for review, make sure you have reviewed it yourself. We'll close PRs that obviously skipped this step. -Entire supports Claude Code, Gemini CLI, OpenCode, Cursor, Factory AI Droid, Copilot CLI, and Pi, so feel free to use whichever one you're most comfortable with. +Entire supports Claude Code, Codex, Antigravity, OpenCode, Cursor, Factory AI Droid, Copilot CLI, and Pi, so feel free to use whichever one you're most comfortable with. One thing to watch out for is LLM eagerness. Agents like to please and they're in a hurry. A few common failure modes to push back on: diff --git a/README.md b/README.md index 99812d74b0..9609e2ab9d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ With Entire, you can: - **Understand why code changed, not just what** — Transcripts, prompts, files touched, token usage, tool calls, and more are captured alongside every commit. - **Resume from any checkpoint** — Go back to any previous agent session and pick up exactly where you or a coworker left off. - **Full context preserved and searchable** — A versioned record of every AI interaction tied to your git history, with nothing lost. -- **Zero context switching** — Git-native, two-step setup, works with Claude Code, Codex, Gemini, Pi, and more. +- **Zero context switching** — Git-native, two-step setup, works with Claude Code, Codex, Cursor, Antigravity, Pi, and more. ## Table of Contents @@ -377,7 +377,7 @@ These are visible in developer and nightly builds and hidden in stable releases, | Flag | Description | | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `--agent ` | Agent to set up hooks for: `claude-code`, `codex`, `copilot-cli`, `cursor`, `factoryai-droid`, `gemini`, `opencode`, `pi` (external agents on `$PATH` also work). Enables non-interactive mode | +| `--agent ` | Agent to set up hooks for: `antigravity`, `claude-code`, `codex`, `copilot-cli`, `cursor`, `factoryai-droid`, `opencode`, `pi` (external agents on `$PATH` also work). Enables non-interactive mode | | `--yes`, `-y` | Accept all defaults without prompting | | `--force`, `-f` | Force reinstall hooks (removes existing Entire hooks first) | | `--checkpoint-remote ` | Push checkpoint data to a separate repo; providers `github`, `gitlab` (e.g., `github:org/checkpoints-repo`) | @@ -515,7 +515,7 @@ Personal overrides, gitignored by default: | `strategy_options.checkpoint_push_remote` | remote name, e.g. `"upstream"` | Pin which single remote carries checkpoint data (see below) | | `strategy_options.filtered_fetches` | `true`, `false` | Use `--filter=blob:none` on checkpoint fetches | | `strategy_options.summarize.enabled` | `true`, `false` | Auto-generate AI summaries at commit time | -| `summary_generation.provider` | e.g. `claude-code`, `codex`, `gemini` | Which agent generates summaries (defaults to Claude) | +| `summary_generation.provider` | e.g. `claude-code`, `codex`, `pi` | Which agent generates summaries (defaults to Claude) | | `summary_generation.model` | provider-specific model hint | Model hint for summary generation (requires `provider`) | | `summary_timeout_seconds` | seconds | Hard deadline for `entire checkpoint explain --generate`. Unset or `0` means **no deadline** | | `redaction.*` | nested object | PII redaction, custom secret patterns, scanner engines, and the OpenAI Privacy Filter — documented in [docs/security-and-privacy.md](docs/security-and-privacy.md) | @@ -526,12 +526,12 @@ Each agent stores its hook configuration in its own directory. When you run `ent | Agent | Hook Location | Format | | ---------------- | ----------------------------- | ----------------- | +| Antigravity | `.agents/hooks.json` | JSON hooks config | | Claude Code | `.claude/settings.json` | JSON hooks config | | Codex | `.codex/hooks.json` | JSON hooks config | | Copilot CLI | `.github/hooks/entire.json` | JSON hooks config | | Cursor | `.cursor/hooks.json` | JSON hooks config | | Factory AI Droid | `.factory/settings.json` | JSON hooks config | -| Gemini CLI | `.gemini/settings.json` | JSON hooks config | | OpenCode | `.opencode/plugins/entire.ts` | TypeScript plugin | | Pi | `.pi/extensions/entire/index.ts` | TypeScript extension | @@ -617,7 +617,7 @@ When enabled, Entire automatically generates AI summaries for checkpoints at com Summaries are also generated on demand, with or without this setting, by `entire checkpoint explain --generate`. -**Which agent writes them.** By default Claude Code (`claude` on your `PATH`, model `sonnet`). Set a different one with `summary_generation.provider` — `claude-code`, `codex`, `copilot-cli`, `cursor`, `gemini`, `opencode`, or `pi`, plus an optional `summary_generation.model` hint: +**Which agent writes them.** By default Claude Code (`claude` on your `PATH`, model `sonnet`). Set a different one with `summary_generation.provider` — `antigravity`, `claude-code`, `codex`, `copilot-cli`, `cursor`, `opencode`, or `pi`, plus an optional `summary_generation.model` hint: ```bash entire configure --summarize-provider codex diff --git a/WINDOWS.md b/WINDOWS.md index 68697fdba5..68cec01be6 100644 --- a/WINDOWS.md +++ b/WINDOWS.md @@ -35,7 +35,7 @@ Git hooks use `#!/bin/sh` shebangs with POSIX shell syntax. Git for Windows exec ### Agent Hooks -Agent-specific hooks (Claude Code, Cursor, Gemini, OpenCode) are JSON configuration — the agents themselves handle execution. The hooks call `entire.exe` directly via `exec.Command`, not through a shell. +Agent-specific hooks (Claude Code, Cursor, OpenCode) are JSON configuration — the agents themselves handle execution. The hooks call `entire.exe` directly via `exec.Command`, not through a shell. ## Testing @@ -54,7 +54,7 @@ E2E tests require the agent binary (e.g., `claude`) to be installed and availabl ```bash # Set required env vars set E2E_ENTIRE_BIN=entire.exe -set E2E_AGENT=claude-code # or gemini-cli, opencode +set E2E_AGENT=claude-code # or opencode # Run all E2E tests go test -tags=e2e -count=1 -timeout=30m ./e2e/tests/... diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index 7cb793862f..687a06a4f9 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -5,6 +5,7 @@ package agent import ( "context" + "encoding/json" "io" "os/exec" "time" @@ -23,10 +24,10 @@ import ( type Agent interface { // --- Identity --- - // Name returns the agent registry key (e.g., "claude-code", "gemini") + // Name returns the agent registry key (e.g., "claude-code", "codex") Name() types.AgentName - // Type returns the agent type identifier (e.g., "Claude Code", "Gemini CLI") + // Type returns the agent type identifier (e.g., "Claude Code", "Codex") // This is stored in metadata and trailers. Type() types.AgentType @@ -38,7 +39,7 @@ type Agent interface { // ProtectedDirs returns repo-root-relative directories that Entire must never // record as session changes or capture into a checkpoint. - // Examples: [".claude"] for Claude, [".gemini"] for Gemini. + // Examples: [".claude"] for Claude, [".codex"] for Codex. ProtectedDirs() []string // --- Transcript Storage --- @@ -70,9 +71,9 @@ type Agent interface { // it verbatim when absolute. Callers that source agentSessionID from // untrusted data (e.g. checkpoint metadata on the shared // entire/checkpoints/v1 branch, hook input) MUST validate it with - // validation.ValidateSessionID first. The resume/log-restore paths do - // this at their choke points (transcript.resolveTranscriptPath and - // strategy.RestoreLogsOnly); do not call this with unvalidated input. + // validation.ValidateSessionID or resolve it through SessionStore.SessionFile, + // which applies that validation centrally. Do not call this method directly + // with unvalidated input. ResolveSessionFile(sessionDir, agentSessionID string) string // ReadSession reads session data from agent's storage. @@ -206,18 +207,18 @@ type TranscriptAnalyzer interface { // GetTranscriptPosition returns the current position (length) of a transcript. // For JSONL formats (Claude Code), this is the line count. - // For JSON formats (Gemini CLI), this is the message count. + // For JSON formats (OpenCode), this is the message count. // Returns 0 if the file doesn't exist or is empty. GetTranscriptPosition(path string) (int, error) // ExtractModifiedFilesFromOffset extracts files modified since a given offset. // For JSONL formats (Claude Code), offset is the starting line number. - // For JSON formats (Gemini CLI), offset is the starting message index. + // For JSON formats (OpenCode), offset is the starting message index. // Returns: // - files: list of file paths modified by the agent (from Write/Edit tools) // - currentPosition: the current position (line count or message count) // - error: any error encountered during reading - ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) + ExtractModifiedFilesFromOffset(ctx context.Context, path string, startOffset int) (files []string, currentPosition int, err error) } // PromptExtractor extracts user prompts from a transcript file. @@ -230,6 +231,21 @@ type PromptExtractor interface { ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) } +// TranscriptPromptExtractor extracts user prompts from transcript CONTENT the +// caller already holds. Condensation reads the transcript once — from the live +// path, or from the shadow-branch copy when the live path cannot be read — and +// stores those bytes in the checkpoint; the prompts it records must come from +// the same bytes, not from a second read of the path that can see a different +// (missing, shorter, or later) file. Optional: agents that only implement +// PromptExtractor keep the path-based fallback. +type TranscriptPromptExtractor interface { + Agent + + // ExtractPromptsFromTranscript returns user prompts from content starting + // at the given offset, using the same offset metric as ExtractPrompts. + ExtractPromptsFromTranscript(content []byte, fromOffset int) ([]string, error) +} + // TranscriptPreparer is called before ReadTranscript to handle agent-specific // flush/sync requirements (e.g., Claude Code's async transcript writing). // The framework calls PrepareTranscript before ReadTranscript if implemented. @@ -241,6 +257,35 @@ type TranscriptPreparer interface { PrepareTranscript(ctx context.Context, sessionRef string) error } +// LateTranscriptWriter marks agents whose transcript file is written only +// AFTER the Stop hook rather than streamed during the turn (e.g. Antigravity). +// Implementing this interface is the trait signal the strategy layer keys off +// instead of hardcoding agent types: mid-turn, such an agent's on-disk +// transcript can only contain previous turns' content, so an empty live +// transcript at condensation is a legitimate state (degrade, don't error) and +// transcript positions recorded at Stop may lag when the flush loses the race. +type LateTranscriptWriter interface { + Agent + + // CountTranscriptPosition returns the checkpoint-offset position for raw + // transcript content, using the same counting rule as the agent's readers + // (GetTranscriptPosition, ExtractPrompts). The value is stored in + // CheckpointTranscriptStart and later fed back to those readers as an + // offset — writer and readers must agree on the metric or an interior + // format quirk (e.g. a blank line) silently shifts extraction for the + // next checkpoint. + CountTranscriptPosition(content []byte) int + + // SliceTranscriptFromPosition returns content scoped to the lines after + // startOffset, counted by the same rule CountTranscriptPosition uses. + // Consumers that scope a transcript to one checkpoint range must go + // through this rather than a generic line slicer: startOffset was + // produced by the agent's metric, and re-deriving it under another rule + // reintroduces exactly the drift CountTranscriptPosition exists to stop. + // Returns nil when nothing follows startOffset. + SliceTranscriptFromPosition(content []byte, startOffset int) []byte +} + // TranscriptFetcher is implemented by agents that can materialize a session // transcript on demand (e.g. OpenCode via `opencode export`), including for // sessions Entire never tracked — where no hook-cached transcript file exists @@ -301,6 +346,31 @@ type TokenCalculator interface { CalculateTokenUsage(transcriptData []byte, fromOffset int) (*TokenUsage, error) } +// OutOfBandTokenSource provides token usage from a source other than the +// transcript. Antigravity is the only agent that needs this: agy never writes +// token data into its transcript or hook payloads — its title/statusline pipe +// is the only surface, captured to disk by `entire hooks antigravity +// title-tee` (see agent/antigravity/statusline.go). +// +// Flow: the lifecycle calls SnapshotTokenBaseline at TurnStart and stores the +// opaque baseline in PrePromptState; at TurnEnd (when transcript-based +// calculation yields nothing) it calls CalculateTokenUsageSince to get the +// checkpoint-scoped delta — the same cumulative-totals-minus-baseline pattern +// Codex uses, sourced out-of-band. +type OutOfBandTokenSource interface { + Agent + + // SnapshotTokenBaseline returns an opaque, agent-defined marker of the + // current cumulative token position for the session. A nil baseline with + // nil error means "no usage observed yet" (delta will count from zero). + SnapshotTokenBaseline(ctx context.Context, sessionID string) (json.RawMessage, error) + + // CalculateTokenUsageSince computes usage between the baseline and now. + // A nil result with nil error means no data is available (degrade to no + // token counts, never to an error). + CalculateTokenUsageSince(ctx context.Context, sessionID string, baseline json.RawMessage) (*TokenUsage, error) +} + // SubagentReference is the authoritative record of one spawned agent supplied // by the session ledger. Transcript paths are hints only: implementations must // verify that a path's native metadata identifies this exact AgentID. @@ -541,7 +611,7 @@ type SessionBaseDirProvider interface { Agent // GetSessionBaseDir returns the base directory containing per-project - // session subdirectories (e.g., ~/.claude/projects, ~/.gemini/tmp). + // session subdirectories (e.g., ~/.claude/projects, ~/.cursor/projects). GetSessionBaseDir() (string, error) } diff --git a/cmd/entire/cli/agent/antigravity/AGENT.md b/cmd/entire/cli/agent/antigravity/AGENT.md new file mode 100644 index 0000000000..6bd6f94dcd --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/AGENT.md @@ -0,0 +1,226 @@ +# Antigravity CLI (`agy`) — Integration One-Pager + +## Verdict: COMPATIBLE (Preview) + +The `agy` binary (Antigravity 2.0, Google's Gemini CLI successor) supports +workspace-scoped hooks via `.agents/hooks.json` and writes JSONL transcripts to +a predictable per-conversation location. The integration is **Preview** +status: documented here and in `docs/architecture/agent-guide.md`, with the +known limitations listed below (the CLI no longer renders a preview label +anywhere — `agent.IsPreview` was removed in #2554). Wire format captured on +agy **1.0.14/1.0.15** (real captured stdin, not docs) and re-verified +unchanged against agy **1.1.1** (2026-07-13); agy is fast-moving. + +**Key differences from other agents:** hooks fire per *model invocation*, not +per user prompt; the transcript is written **after** the Stop hook; token usage +appears **only** in the title/statusline JSON feed (never in transcripts or +hook payloads); tool args can arrive double-encoded. + +## Binary + +- Install: `curl`-based installer / GitHub releases (`google-antigravity/antigravity-cli`). +- Headless: `agy -p "" --add-dir ` (without + `--add-dir`, agy runs in `~/.gemini/antigravity-cli/scratch/`, not the cwd). + The path MUST be absolute: a relative one (`.`) is rejected but does not fail + the run — agy logs `failed to resolve --add-dir path` and `loaded 0 named + hooks`, then runs the turn with no hooks and exit 0, which looks exactly like + a hook-loading bug. +- Resume: `agy --conversation ` (used by `FormatResumeCommand`). +- Auth: consumer OAuth, ADC + `--project`, or (agy ≥ 1.1.13) **Gemini API-key + mode**: `{"modelProvider":"gemini"}` in `~/.gemini/antigravity-cli/settings.json` + plus `GEMINI_API_KEY` in the environment — no account session, keyring or + browser flow; startup fails fast if the key is unset. Optional + `GOOGLE_GEMINI_BASE_URL`. agy prefers `GOOGLE_API_KEY` when both keys are set. + The default (`cloudcode-pa.googleapis.com`) backend remains entitlement-gated; + see `e2e/README.md`. +- Version notes (1.1.1 → 1.2.7): hook surface unchanged (5 hook types); + 1.1.25 started executing hooks in Gemini API-key mode (see Known + limitations); 1.2.x dropped the Gemini 3.5 models from `agy models`; + 1.1.10 fixed hook ordering so `Stop`/`PostInvocation` fire reliably; + 1.1.12 answers `-p "/hooks"` (and `/help`, `/changelog`) locally without a + model turn on the platforms where that was checked — but agy 1.2.7 on + Windows 11 ran a full model turn for it, so `entire doctor` runs the + `agy -p /hooks --add-dir --output-format json` probe only with + `ENTIRE_ANTIGRAVITY_DOCTOR_PROBE=1`; by default it checks, at zero cost, + that the installed hook command is the shape this host needs; + 1.1.8/1.1.20 added `--output-format json|stream-json` and made headless exit + codes reflect only run-level failures; 1.1.9 expands `/skill` in `-p`. + +## Hook Mechanism + +Hooks live in the workspace at `.agents/hooks.json` under a named key (ours is +`"entire"`). Tool events (`PreToolUse`/`PostToolUse`) use `matcher` + `hooks` +lists; `PreInvocation`/`PostInvocation`/`Stop` use flat handler lists. Install +replaces the whole `"entire"` entry (idempotent by compact-JSON comparison), so +stale installs self-heal on the next `entire enable`. + +### Hook Names and Event Mapping + +Only the three hooks with lifecycle meaning are installed. agy's +`PostToolUse`/`PostInvocation` are deliberately **not** installed — no +lifecycle mapping, and each would spawn a no-op `entire` subprocess per tool +call / model invocation. Unknown verbs parse to a nil event, so a stale +hooks.json can never fail an agy turn. + +| agy hook | Fires | Entire event | +|----------|-------|--------------| +| `PreInvocation` | before **every model invocation** (`invocationNum` is **0-indexed**) | `TurnStart` when `invocationNum == 0`; for `> 0`, a `TurnStart` with `Event.SuppressIfSessionActive` — the dispatcher drops it only when a turn is genuinely mid-flight (`Phase == ACTIVE` and not stuck), so resumes (`agy --conversation`) are tracked while follow-up invocations don't clobber the pre-prompt baseline | +| `PreToolUse` (matcher `*`) | before each tool call | `ToolUse` for mutating tools (`write_to_file`, `replace_file_content`, `multi_replace_file_content`) → `FilesTouched` | +| `Stop` | at agent stop; payload carries `fullyIdle` | `TurnEnd` when `fullyIdle == true`; nil otherwise (background tasks still running) | + +There is **no SessionStart surface** — no way to print a "tracked by entire" +banner inside agy (silent tracking, same as Cursor/OpenCode/Copilot/Pi). + +### Hook Input Payloads (Captured) + +Common fields consumed: `conversationId`, `transcriptPath`. agy also sends +`workspacePaths`, `artifactDirectoryPath`, `stepIdx`, `initialNumSteps`, +`executionNum`, `terminationReason`, `error` — tolerated but not decoded (add +fields only when something reads them). Captured fixtures in `testdata/`. + +- `PreInvocation`: `invocationNum` (0-indexed; the docs now state this + explicitly). `initialNumSteps` is unusable as a "first?" signal — agy inserts + the user prompt as step 0, so it is already 1 on the first invocation. +- `PreToolUse`: `toolCall.name`, `toolCall.args`. **Args can be + double-encoded** (`"TargetFile":"\"foo.txt\""`, `"Overwrite":"true"`); + `decodeAgyString`/`decodeAgyBool` accept both the documented and the wire + shape. Paths are symlink-resolved (`resolveAgySymlinks`) so macOS + `/tmp → /private/tmp` doesn't defeat repo-relative filtering. +- `Stop`: `fullyIdle` (required). + +## Transcript + +Written to +`~/.gemini/antigravity-cli/brain//.system_generated/logs/transcript_full.jsonl` +(the hook payload's `transcriptPath` points there; agy also writes a truncated +`transcript.jsonl` alongside). Schema: one step object per line — +`step_index`, `source` (`USER_EXPLICIT`/`MODEL`/`SYSTEM`), `type` +(`USER_INPUT`/`PLANNER_RESPONSE`/`CODE_ACTION`/...), `content`, `tool_calls`. + +**agy writes the transcript AFTER the Stop hook** (sometimes seconds later). +Consequences, all handled: + +- `PrepareTranscript` (TranscriptPreparer) briefly waits, then materialises an + empty placeholder so the framework's fileExists check doesn't abort the turn. +- Prompts are re-extracted at condensation via the late-flush fallback + (`resolvePromptsFromLateFlushedTranscript`) when `prompt.txt` is empty. +- A first-turn mid-turn commit condenses against the empty placeholder as a + **files/prompt-only checkpoint** (degrade is agy-scoped; other agents keep + the error/retry invariant). + +### TranscriptAnalyzer + +`ExtractPrompts` (USER_INPUT steps, `` unwrap), +`GetTranscriptPosition`, and `ExtractModifiedFilesFromOffset` share one +offset metric: non-blank JSONL lines, blank lines skipped **before** counting +(`forEachNonBlankLine` is the single owner — the position one method stores is +consumed by the others). Validated against 385 real captured transcripts +(0 errors, 0 offset mismatches). + +## Token Usage (out-of-band) + +agy never writes token data into transcripts or hook payloads. The **only** +surface is the state JSON piped to the global `statusLine`/`title` command +slots (`context_window`: `total_input_tokens`, `total_output_tokens`, +`current_usage.*`). The integration: + +- claims the lower-stakes **`title` slot** in agy's global + `~/.gemini/antigravity-cli/settings.json` with + `entire hooks antigravity title-tee` (wrapping and preserving any + pre-existing user title command via `--wrap ''` where agy runs + the slot through sh, or `--wrap-b64 ` on Windows, where agy + runs it through cmd.exe and POSIX quoting would be torn apart; the tee + re-runs the original through the same shell agy would have used); +- the tee appends deduped snapshots to a per-conversation JSONL cache; +- `OutOfBandTokenSource`: `SnapshotTokenBaseline` at TurnStart (streaming + last-line read), `CalculateTokenUsageSince` at TurnEnd (cumulative totals + minus baseline; cache fields from snapshot lines strictly after the + baseline timestamp); +- the delta is recorded even when a turn ends with everything already + committed (agy's normal flow), so no tokens are lost; +- checkpoint metadata takes the checkpoint-scoped accumulator + (`state.CheckpointTokenUsage`), never the session-cumulative total. + +`entire doctor` warns when hooks are installed but the title slot doesn't +route through the tee; `entire agent remove antigravity` restores/removes the +slot (matching the bare tee **by shape**, so uninstalling from a different +worktree than the install still cleans up, including the legacy local-dev +`go run …` form older versions wrote). + +## Config Preservation + +- `.agents/hooks.json`: foreign keys preserved; only the `"entire"` entry is + managed. `HookConfig` keeps the `PostToolUse`/`PostInvocation`/`enabled` + schema fields for round-trip fidelity and stale-install detection. +- Global `settings.json`: only the `title` key is touched; user title commands + are wrapped, restored on uninstall, and anything unrecognized is left alone. + +## Gaps & Limitations (Preview) + +- **Silent tracking** in the agy UI (no SessionStart surface); `entire status` + is the visibility surface. +- **Headless subagent runs leave a ghost parent session**: agy runs subagents + as separate conversations with their own hooks; in `-p` mode the parent + conversation only receives `fullyIdle=false` Stops before the process exits, + so its session stays ACTIVE (visible in `entire status` until the stale + threshold). The subagent's work condenses normally, and ghost sessions with + no tracked files no longer pin shadow branches. +- **Mid-turn commit token scoping is coarse**: such checkpoints record zero + tokens; the turn's delta lands on the next condensation (session totals stay + correct). +- **First-turn mid-turn commits** may produce checkpoints without transcript + content (see Transcript above). +- **Token capture depends on the title slot** staying routed through the tee + (doctor-checked, setup-repaired). +- **Gemini API-key mode needs agy ≥ 1.1.25 for hooks.** Through 1.1.24 agy + loaded `.agents/hooks.json` on that auth route but never executed the hooks + (on OAuth/ADC the same hooks fired; reported as + google-antigravity/antigravity-cli#893, still open). Bisecting the release + binaries with a probe hook shows 1.1.25 and every later release execute them, + so the e2e harness's API-key mode (CI installs the latest agy) produces + checkpoints and the leg runs in the default matrix. The default + `cloudcode-pa` backend stays entitlement-gated (AUTH_PERMISSION_DENIED, + subject 110002 without a Gemini Code Assist subscription); the harness fails + fast on those walls and on `GEMINI_API_KEY … not set` / `API_KEY_INVALID`. + Details: `e2e/README.md`. +- **Transcript-derived file lists can be incomplete**: agy sometimes persists a + `PLANNER_RESPONSE` step with `truncated_fields`, dropping `TargetFile` from a + mutating tool call (~0.8% of `replace_file_content` calls in one corpus). + `ExtractModifiedFilesFromOffset` logs a WARN per dropped call instead of + silently skipping; live PreToolUse stdin is never truncated, so normal turns + are unaffected — only the late-flush / first-turn mid-turn fallbacks are. +- **Headless prompt goes in argv**: agy ≥ 1.2.x ignores stdin in print mode + (`-p " "` → "Error: empty prompt"; `-p -` is answered as the literal message + "-"), so `GenerateText` (summaries, `dispatch --local`) passes the prompt as + the `-p` value. The summarizer condenses agy step JSONL through + `antigravity.CondenseTranscript` (USER_INPUT, PLANNER_RESPONSE text and tool + calls; GENERIC/SYSTEM_MESSAGE skipped) — without it agy transcripts fell + through to the Claude parser and summarized to nothing. +- **First-run onboarding in a fresh HOME**: interactive `agy` shows a + color-scheme chooser and a Terms of Service & Data Use consent before the + prompt (Enter on the consent only toggles its checkbox; Down, Right, Enter + reaches [Done]). Headless `-p` runs skip both. State lives in + `~/.gemini/antigravity-cli/cache/onboarding.json` + (`{"onboardingComplete":true,"consumerOnboardingComplete":true,...}`), which + the e2e harness seeds into its isolated HOMEs. +- **Untrusted-workspace trap**: `agy -p` in a folder agy doesn't trust resolves + cwd to `~/.gemini/antigravity-cli/scratch/` — no hooks fire, no session, exit + 0. Workspace hooks (`.agents/hooks.json`) only load for a **trusted** + workspace (`trustedWorkspaces` array of absolute paths in agy's global + `settings.json`; interactive prompt "Do you trust the contents of this + project?"). Always pass `--add-dir ` (a relative + path is silently dropped, see Binary above) and, in a fresh HOME, pre-seed + `trustedWorkspaces` (the e2e harness does both). +- **Review**: agy is eligible in the `entire review` skill picker (skill + discovery across `~/.gemini/config/skills` (agy 1.1+ global), + `~/.gemini/antigravity-cli/skills`, `~/.gemini/skills`, + `/.agents/skills` (agy 1.1+ workspace), and legacy + `/.agent/skills`; `/name` invocation form) but is not a launchable + reviewer. + +## Captured Payloads + +`testdata/hook_stdin_pre_invocation.json`, `testdata/hook_stdin_pre_tool_use.json`, +`testdata/hook_stdin_stop.json` — real agy 1.0.x stdin captures (fixtures carry +the full documented payloads to pin unknown-field tolerance); +`testdata/transcript_sample.jsonl` — captured transcript fixture. diff --git a/cmd/entire/cli/agent/antigravity/antigravity.go b/cmd/entire/cli/agent/antigravity/antigravity.go new file mode 100644 index 0000000000..2a6e41dbbb --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/antigravity.go @@ -0,0 +1,104 @@ +// Package antigravity implements the Agent interface for Antigravity (Google's agentic coding CLI). +package antigravity + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" +) + +const antigravityTestBrainDirEnv = "ENTIRE_TEST_ANTIGRAVITY_BRAIN_DIR" + +//nolint:gochecknoinits // Agent self-registration is the intended pattern +func init() { + agent.Register(agent.AgentNameAntigravity, NewAntigravityAgent) +} + +// AntigravityAgent implements the Agent interface for Antigravity. +// +//nolint:revive // AntigravityAgent is clearer than Agent in this context +type AntigravityAgent struct { + CommandRunner agent.TextCommandRunner +} + +var _ agent.OutOfBandTokenSource = (*AntigravityAgent)(nil) + +// NewAntigravityAgent creates a new AntigravityAgent instance. +func NewAntigravityAgent() agent.Agent { + return &AntigravityAgent{} +} + +// --- Identity --- + +func (a *AntigravityAgent) Name() types.AgentName { return agent.AgentNameAntigravity } +func (a *AntigravityAgent) Type() types.AgentType { return agent.AgentTypeAntigravity } +func (a *AntigravityAgent) Description() string { + return "Antigravity CLI - Google's agentic coding CLI (Gemini CLI successor)" +} + +// DetectPresence reports whether Entire's Antigravity hooks are configured for +// this workspace. Antigravity 2.0 stores runtime data user-scope in +// ~/.gemini/antigravity-cli/, so the only meaningful workspace-level signal is +// whether our entry exists in .agents/hooks.json. +func (a *AntigravityAgent) DetectPresence(ctx context.Context) (bool, error) { + return a.AreHooksInstalled(ctx) +} + +func (a *AntigravityAgent) ProtectedDirs() []string { return []string{".agents", ".gemini"} } + +// --- Legacy methods --- + +func (a *AntigravityAgent) GetSessionID(input *agent.HookInput) string { return input.SessionID } +func (a *AntigravityAgent) GetSessionDir(_ string) (string, error) { + if override := os.Getenv(antigravityTestBrainDirEnv); override != "" { + return override, nil + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("antigravity: failed to get home directory: %w", err) + } + return filepath.Join(homeDir, ".gemini", "antigravity-cli", "brain"), nil +} + +func (a *AntigravityAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + return filepath.Join(sessionDir, agentSessionID, ".system_generated", "logs", "transcript_full.jsonl") +} + +func (a *AntigravityAgent) ReadSession(_ *agent.HookInput) (*agent.AgentSession, error) { + return nil, errors.New("antigravity: legacy ReadSession not supported; use transcriptPath from hook stdin") +} +func (a *AntigravityAgent) WriteSession(_ context.Context, session *agent.AgentSession) error { + if session == nil { + return errors.New("antigravity: session is nil") + } + if session.AgentName != "" && session.AgentName != a.Name() { + return fmt.Errorf("antigravity: session belongs to agent %q, not %q", session.AgentName, a.Name()) + } + if session.SessionRef == "" { + return errors.New("antigravity: session reference is required") + } + if len(session.NativeData) == 0 { + return errors.New("antigravity: session has no native data to write") + } + // Through the agent's session store, like every other agent: the write is + // contained to agy's brain directory and never follows a symlink. + if err := agent.WriteSessionFile(a, session, session.NativeData, 0o600); err != nil { + return fmt.Errorf("antigravity: write transcript: %w", err) + } + return nil +} + +// HookConfigRelPath implements agent.HookConfigLocator: agy loads workspace +// hooks from .agents/hooks.json at the worktree root. +func (a *AntigravityAgent) HookConfigRelPath() string { + return ".agents/" + AgentsHooksFileName +} + +func (a *AntigravityAgent) FormatResumeCommand(sessionID string) string { + return "agy --conversation " + sessionID +} diff --git a/cmd/entire/cli/agent/antigravity/antigravity_test.go b/cmd/entire/cli/agent/antigravity/antigravity_test.go new file mode 100644 index 0000000000..c4f97559c7 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/antigravity_test.go @@ -0,0 +1,163 @@ +package antigravity + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestAgent_ImplementsAgentAndHookSupport(t *testing.T) { + t.Parallel() + var _ agent.Agent = (*AntigravityAgent)(nil) + var _ agent.HookSupport = (*AntigravityAgent)(nil) +} + +func TestAgent_NameAndType(t *testing.T) { + t.Parallel() + a := &AntigravityAgent{} + if a.Name() != agent.AgentNameAntigravity { + t.Errorf("Name() = %q", a.Name()) + } + if a.Type() != agent.AgentTypeAntigravity { + t.Errorf("Type() = %q", a.Type()) + } +} + +func TestAgent_Registered(t *testing.T) { + t.Parallel() + _, err := agent.Get(agent.AgentNameAntigravity) + if err != nil { + t.Fatalf("agent not registered: %v", err) + } +} + +func TestDetectPresence(t *testing.T) { + t.Run("no hooks installed", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AntigravityAgent{} + present, err := ag.DetectPresence(context.Background()) + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if present { + t.Error("DetectPresence() = true, want false") + } + }) + + t.Run("hooks installed", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + t.Setenv(configDirEnv, t.TempDir()) + + ag := &AntigravityAgent{} + if _, err := ag.InstallHooks(context.Background(), false); err != nil { + t.Fatalf("InstallHooks: %v", err) + } + present, err := ag.DetectPresence(context.Background()) + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true after InstallHooks") + } + }) +} + +func TestGetSessionDir_HonorsTestOverride(t *testing.T) { + override := filepath.Join(t.TempDir(), "brain") + t.Setenv("ENTIRE_TEST_ANTIGRAVITY_BRAIN_DIR", override) + + got, err := (&AntigravityAgent{}).GetSessionDir("/repo") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + if got != override { + t.Fatalf("GetSessionDir() = %q, want %q", got, override) + } +} + +func TestResolveSessionFile_UsesAntigravityBrainTranscriptPath(t *testing.T) { + t.Parallel() + + got := (&AntigravityAgent{}).ResolveSessionFile("/home/me/.gemini/antigravity-cli/brain", "conv-123") + want := filepath.Join( + "/home/me/.gemini/antigravity-cli/brain", + "conv-123", + ".system_generated", + "logs", + "transcript_full.jsonl", + ) + if got != want { + t.Fatalf("ResolveSessionFile() = %q, want %q", got, want) + } +} + +func TestWriteSession_WritesTranscriptData(t *testing.T) { + t.Parallel() + + path := filepath.Join( + t.TempDir(), + "brain", + "conv-123", + ".system_generated", + "logs", + "transcript_full.jsonl", + ) + data := []byte(`{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE"}` + "\n") + err := (&AntigravityAgent{}).WriteSession(context.Background(), &agent.AgentSession{ + SessionID: "conv-123", + AgentName: agent.AgentNameAntigravity, + SessionRef: path, + NativeData: data, + }) + if err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read restored transcript: %v", err) + } + if string(got) != string(data) { + t.Fatalf("restored transcript = %q, want %q", got, data) + } +} + +func TestWriteSession_RejectsInvalidInput(t *testing.T) { + t.Parallel() + + ag := &AntigravityAgent{} + validPath := filepath.Join(t.TempDir(), "transcript_full.jsonl") + cases := []struct { + name string + session *agent.AgentSession + }{ + {name: "nil session", session: nil}, + {name: "wrong agent", session: &agent.AgentSession{ + AgentName: agent.AgentNameClaudeCode, + SessionRef: validPath, + NativeData: []byte("{}\n"), + }}, + {name: "empty ref", session: &agent.AgentSession{ + AgentName: agent.AgentNameAntigravity, + NativeData: []byte("{}\n"), + }}, + {name: "empty data", session: &agent.AgentSession{ + AgentName: agent.AgentNameAntigravity, + SessionRef: validPath, + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if err := ag.WriteSession(context.Background(), tc.session); err == nil { + t.Fatal("WriteSession() error = nil, want error") + } + }) + } +} diff --git a/cmd/entire/cli/agent/antigravity/discovery.go b/cmd/entire/cli/agent/antigravity/discovery.go new file mode 100644 index 0000000000..33cdf7535f --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/discovery.go @@ -0,0 +1,61 @@ +package antigravity + +import ( + "context" + "log/slog" + "os" + "path/filepath" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/skilldiscovery" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// DiscoverReviewSkills walks Antigravity's three user-skill scopes looking for +// review-adjacent skills. agy stores skills as //SKILL.md with +// YAML frontmatter (name + description); the model activates them by +// description, and they are referenced with a "/name" slash invocation. +// +// Scopes, in precedence order (global wins on name collision): +// - global (agy 1.1+): ~/.gemini/config/skills +// - global (pre-1.1 layouts): ~/.gemini/antigravity-cli/skills, ~/.gemini/skills +// - workspace (agy 1.1+): /.agents/skills +// - workspace (legacy, honored): /.agent/skills +// +// agy 1.1 moved the defaults ("Antigravity now defaults to .agents/skills, +// but still maintains backward support for .agent/skills"; global discovery +// under ~/.gemini/config/) — all roots are scanned so skills keep resolving +// across agy versions. +// +// Google's built-in skills under ~/.gemini/antigravity-cli/builtin/skills are +// deliberately NOT scanned — they are shipped guides (e.g. antigravity-guide), +// not user review tools. +// +// Best-effort per the SkillDiscoverer contract: unreadable HOME or missing +// directories yield (nil, nil); malformed SKILL.md files are skipped by the +// shared scanner with a Debug log. +// +//nolint:unparam // error return is part of the SkillDiscoverer contract +func (a *AntigravityAgent) DiscoverReviewSkills(ctx context.Context) ([]agent.DiscoveredSkill, error) { + home, err := os.UserHomeDir() + if err != nil { + logging.Debug(ctx, "antigravity discovery: UserHomeDir failed", slog.String("error", err.Error())) + return nil, nil + } + + var found []agent.DiscoveredSkill + found = append(found, skilldiscovery.ScanSkillsDir(ctx, filepath.Join(home, ".gemini", "config", "skills"), "", skilldiscovery.SlashForm)...) + found = append(found, skilldiscovery.ScanSkillsDir(ctx, filepath.Join(home, ".gemini", "antigravity-cli", "skills"), "", skilldiscovery.SlashForm)...) + found = append(found, skilldiscovery.ScanSkillsDir(ctx, filepath.Join(home, ".gemini", "skills"), "", skilldiscovery.SlashForm)...) + if root, rootErr := paths.WorktreeRoot(ctx); rootErr == nil { + found = append(found, skilldiscovery.ScanSkillsDir(ctx, filepath.Join(root, ".agents", "skills"), "", skilldiscovery.SlashForm)...) + found = append(found, skilldiscovery.ScanSkillsDir(ctx, filepath.Join(root, ".agent", "skills"), "", skilldiscovery.SlashForm)...) + } + + found = skilldiscovery.DedupeByInvocation(found) + if len(found) == 0 { + return nil, nil + } + return found, nil +} diff --git a/cmd/entire/cli/agent/antigravity/discovery_test.go b/cmd/entire/cli/agent/antigravity/discovery_test.go new file mode 100644 index 0000000000..d3419785a3 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/discovery_test.go @@ -0,0 +1,120 @@ +package antigravity + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/go-git/go-git/v6" +) + +func writeAgySkill(t *testing.T, scopeDir, name, description string) { + t.Helper() + dir := filepath.Join(scopeDir, name) + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + body := "---\nname: " + name + "\ndescription: " + description + "\n---\nbody\n" + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o600); err != nil { + t.Fatalf("write: %v", err) + } +} + +func TestDiscoverReviewSkills_ScansGlobalAndSharedScopes(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Chdir(t.TempDir()) // non-repo cwd → project scope skipped deterministically + + writeAgySkill(t, filepath.Join(home, ".gemini", "antigravity-cli", "skills"), "code-review", "Review PRs.") + writeAgySkill(t, filepath.Join(home, ".gemini", "skills"), "security-audit", "Audit deps.") + writeAgySkill(t, filepath.Join(home, ".gemini", "skills"), "formatter", "Format code.") + + got, err := (&AntigravityAgent{}).DiscoverReviewSkills(context.Background()) + if err != nil { + t.Fatalf("DiscoverReviewSkills: %v", err) + } + names := map[string]bool{} + for _, s := range got { + names[s.Name] = true + } + if !names["/code-review"] { + t.Errorf("missing global-scope /code-review: %+v", got) + } + if !names["/security-audit"] { + t.Errorf("missing shared-scope /security-audit: %+v", got) + } + if names["/formatter"] { + t.Errorf("non-review /formatter should be excluded: %+v", got) + } +} + +func TestDiscoverReviewSkills_ScansAgy11DefaultRoots(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + repo := t.TempDir() + // Bare git init is enough — DiscoverReviewSkills only needs WorktreeRoot + // to resolve; no commits or repo-local config are involved. (testutil is + // architecturally off-limits to agent packages; see architecture_test.go.) + if _, err := git.PlainInit(repo, false); err != nil { + t.Fatalf("git init: %v", err) + } + t.Chdir(repo) + paths.ClearWorktreeRootCache() + t.Cleanup(paths.ClearWorktreeRootCache) + + // agy 1.1.x moved the default scopes: global skills live under + // ~/.gemini/config/skills and workspace skills under .agents/skills + // (legacy .agent/skills is still honored for backward compatibility). + writeAgySkill(t, filepath.Join(home, ".gemini", "config", "skills"), "config-review", "Review configs.") + writeAgySkill(t, filepath.Join(repo, ".agents", "skills"), "workspace-review", "Review the workspace.") + writeAgySkill(t, filepath.Join(repo, ".agent", "skills"), "legacy-review", "Review legacy layouts.") + + got, err := (&AntigravityAgent{}).DiscoverReviewSkills(context.Background()) + if err != nil { + t.Fatalf("DiscoverReviewSkills: %v", err) + } + names := map[string]bool{} + for _, s := range got { + names[s.Name] = true + } + for _, want := range []string{"/config-review", "/workspace-review", "/legacy-review"} { + if !names[want] { + t.Errorf("missing %s: %+v", want, got) + } + } +} + +func TestDiscoverReviewSkills_DedupesAcrossScopes(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Chdir(t.TempDir()) + + writeAgySkill(t, filepath.Join(home, ".gemini", "antigravity-cli", "skills"), "code-review", "Global.") + writeAgySkill(t, filepath.Join(home, ".gemini", "skills"), "code-review", "Shared.") + + got, err := (&AntigravityAgent{}).DiscoverReviewSkills(context.Background()) + if err != nil { + t.Fatalf("DiscoverReviewSkills: %v", err) + } + count := 0 + for _, s := range got { + if s.Name == "/code-review" { + count++ + } + } + if count != 1 { + t.Fatalf("want 1 /code-review after dedupe, got %d: %+v", count, got) + } +} + +func TestDiscoverReviewSkills_NoSkillsReturnsNil(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Chdir(t.TempDir()) + + got, err := (&AntigravityAgent{}).DiscoverReviewSkills(context.Background()) + if err != nil || got != nil { + t.Fatalf("want (nil, nil) on empty install, got (%+v, %v)", got, err) + } +} diff --git a/cmd/entire/cli/agent/antigravity/generate.go b/cmd/entire/cli/agent/antigravity/generate.go new file mode 100644 index 0000000000..0fe979242f --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/generate.go @@ -0,0 +1,43 @@ +package antigravity + +import ( + "context" + "fmt" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// GenerateText submits a non-interactive prompt to the Antigravity CLI. The +// binary is `agy`; -p is the short alias for --print (single-prompt mode). +// +// The prompt travels in argv. Earlier releases accepted it on stdin behind a +// single-space -p placeholder (the Gemini CLI convention, verified on agy +// 1.0.16), but agy 1.2.7 ignores stdin in print mode: `-p " "` fails with +// "Error: empty prompt", and `-p -` is answered as the literal message "-" +// (both observed live, trail 444, 2026-09-22), which is how +// `entire dispatch --local --agent antigravity` came to hand agy an empty +// prompt. argv is the only documented route ("Usage: agy --print 'your +// prompt here'"). +// +// That makes prompt size this agent's problem in a way it is not for agents +// that use RunIsolatedTextGeneratorCLI's stdin. Linux caps a SINGLE argument +// at MAX_ARG_STRLEN (128 KiB) however large the total ARG_MAX is, so an +// unbounded prompt fails with E2BIG. summarize.maxCondensedTranscriptBytes is +// what keeps summary prompts inside it. Windows' ~32 KiB whole-command-line +// limit is tighter than any useful transcript budget and is not covered; a +// long enough prompt still fails there, loudly. +func (a *AntigravityAgent) GenerateText(ctx context.Context, prompt string, model string) (string, error) { + args := []string{"-p", prompt} + if model != "" { + args = append(args, "--model", model) + } + result, capturedStderr, stdoutBytes, err := agent.RunIsolatedTextGeneratorCLI(ctx, a.CommandRunner, "agy", "antigravity", args, "") + if err != nil { + return "", &agent.TextGenerationError{ + Err: fmt.Errorf("antigravity text generation failed: %w", err), + Stderr: capturedStderr, + StdoutBytes: stdoutBytes, + } + } + return result, nil +} diff --git a/cmd/entire/cli/agent/antigravity/generate_test.go b/cmd/entire/cli/agent/antigravity/generate_test.go new file mode 100644 index 0000000000..2ce7d41ad2 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/generate_test.go @@ -0,0 +1,41 @@ +package antigravity + +import ( + "context" + "os/exec" + "testing" +) + +// agy 1.2.7 ignores stdin in print mode (`-p " "` fails with "empty prompt", +// `-p -` is answered as the literal message "-"), so the prompt must travel in +// argv. This pins the shape `entire dispatch --local --agent antigravity` and +// `explain --generate` depend on. +func TestGenerateText_PassesPromptInArgv(t *testing.T) { + t.Parallel() + var gotBinary string + var gotArgs []string + a := &AntigravityAgent{CommandRunner: func(ctx context.Context, binary string, argv ...string) *exec.Cmd { + gotBinary, gotArgs = binary, argv + return exec.CommandContext(ctx, "echo", "PONG") + }} + + out, err := a.GenerateText(context.Background(), "Summarize this transcript.", "gemini-3.8-flash-low") + if err != nil { + t.Fatalf("GenerateText: %v", err) + } + if out != "PONG" { + t.Fatalf("GenerateText output = %q, want the CLI's stdout", out) + } + if gotBinary != "agy" { + t.Fatalf("binary = %q, want agy", gotBinary) + } + want := []string{"-p", "Summarize this transcript.", "--model", "gemini-3.8-flash-low"} + if len(gotArgs) != len(want) { + t.Fatalf("args = %q, want %q", gotArgs, want) + } + for i := range want { + if gotArgs[i] != want[i] { + t.Fatalf("args = %q, want %q", gotArgs, want) + } + } +} diff --git a/cmd/entire/cli/agent/antigravity/hooks.go b/cmd/entire/cli/agent/antigravity/hooks.go new file mode 100644 index 0000000000..d8073d646a --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/hooks.go @@ -0,0 +1,341 @@ +package antigravity + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// Ensure AntigravityAgent implements HookSupport and declares where its hook +// config lives (HookConfigRelPath in antigravity.go), so doctor's symlink scan +// and the vouchable-directory guard cover .agents/hooks.json like every other +// agent's config. +var ( + _ agent.HookSupport = (*AntigravityAgent)(nil) + _ agent.HookConfigLocator = (*AntigravityAgent)(nil) +) + +// AgentsHooksFileName is the hooks file used by Antigravity. +const AgentsHooksFileName = "hooks.json" + +// InstallHooks installs Antigravity hooks in .agents/hooks.json. +// If force is true, removes existing Entire hooks before installing. +// Returns the number of hooks installed. +func (a *AntigravityAgent) InstallHooks(ctx context.Context, force bool) (int, error) { + cfg, err := a.hookConfig(ctx) + if err != nil { + return 0, err + } + + // Read and parse existing hooks file, preserving unknown keys + rawFile := make(map[string]json.RawMessage) + existingData, readErr := cfg.Read() + if readErr == nil { + if err := json.Unmarshal(existingData, &rawFile); err != nil { + return 0, fmt.Errorf("failed to parse existing hooks.json: %w", err) + } + } else if !errors.Is(readErr, os.ErrNotExist) { + return 0, readErr //nolint:wrapcheck // agent.HookConfigFile already names the file in its error + } + + // Build the candidate Entire hook config. The whole "entire" entry is + // replaced on install, so a hook written by an older version cannot + // survive alongside the current one. + candidate := buildEntireHookConfig() + + // Title tee: agy's only token-usage surface (same payload as the + // statusline script). Run this BEFORE the idempotency early-return: the + // title slot lives in agy's GLOBAL settings.json, independent of this + // repo's .agents/hooks.json. If repo hooks already match but the global + // slot is missing or stale (upgrade from a pre-title-tee version, a failed + // first install, or `entire agent add` without --force), re-running setup + // must still repair it — otherwise the doctor's "re-run setup" hint is a + // no-op. InstallTitleTee is itself idempotent. Best-effort: a failure to + // claim the global slot must not fail repo-level hook setup. + // + // Gated on agy actually being on PATH. The slot lives in agy's global + // settings.json, so claiming it from a machine that has never run agy + // writes a shared user-level file on the strength of a repo-local + // command — `entire agent add antigravity` in a teammate's checkout, say. + // `entire doctor` gates its matching check the same way. The order above + // is unchanged: this still runs before the idempotency early-return, so + // stale-slot repair keeps working wherever agy is installed. + if _, lookErr := exec.LookPath(antigravityBinaryName); lookErr != nil { + logging.Debug(ctx, "skipping antigravity title tee: agy is not on PATH", + "error", lookErr.Error()) + } else if err := InstallTitleTee(); err != nil { + logging.Warn(ctx, "failed to install antigravity title tee", + "error", err.Error()) + } + + // Idempotency check: an entry the user disabled or one that already matches + // the candidate is left alone. --force remains the explicit override. + if !force { + if existing, ok := rawFile["entire"]; ok { + disabled, same := entireEntryMatches(existing, candidate) + if disabled || same { + return 0, nil + } + } + } + + // Marshal and insert the "entire" entry (replacing any prior value) + candidateBytes, err := jsonutil.MarshalWithNoHTMLEscape(candidate) + if err != nil { + return 0, fmt.Errorf("failed to marshal hook config: %w", err) + } + rawFile["entire"] = candidateBytes + + if err := writeHooksFile(rawFile, cfg); err != nil { + return 0, err + } + + // 3 hooks: pre-tool-use, pre-invocation, stop + return 3, nil +} + +// entireEntryMatches compares an installed "entire" entry against candidate by +// re-marshaling both to compact JSON. disabled reports a user-set +// "enabled": false — agy's documented per-entry disable knob, a deliberate +// choice that install must not rewrite and silently re-arm. +func entireEntryMatches(existing json.RawMessage, candidate HookConfig) (disabled, same bool) { + var existingCfg HookConfig + if err := json.Unmarshal(existing, &existingCfg); err != nil { + return false, false + } + if existingCfg.Enabled != nil && !*existingCfg.Enabled { + return true, false + } + existingBytes, err1 := jsonutil.MarshalWithNoHTMLEscape(existingCfg) + candidateBytes, err2 := jsonutil.MarshalWithNoHTMLEscape(candidate) + return false, err1 == nil && err2 == nil && bytes.Equal(existingBytes, candidateBytes) +} + +// HooksEntryMatchesHost reports whether the repo's installed "entire" entry is +// exactly what InstallHooks would write on THIS host. installed is false when +// there is no entry. A user-disabled entry counts as current. +// +// It exists for `entire doctor`: the hook command's SHAPE is host-specific +// (agy runs it through cmd.exe on Windows and sh elsewhere), and a hooks.json +// committed from a macOS checkout carries a sh wrapper that cmd.exe tears +// apart — the hook exits 1, the failure shows only in agy's log, and nothing +// is tracked. A file that merely exists proves nothing about that; comparing +// against the candidate does, at zero cost and without spawning agy. +func (a *AntigravityAgent) HooksEntryMatchesHost(ctx context.Context) (installed, current bool, err error) { + cfg, err := a.hookConfig(ctx) + if err != nil { + return false, false, err + } + existing, ok, err := readEntireEntry(cfg) + if err != nil || !ok { + return false, false, err + } + disabled, same := entireEntryMatches(existing, buildEntireHookConfig()) + return true, disabled || same, nil +} + +// readEntireEntry returns the raw "entire" entry from the repo's hooks.json. +// ok is false when the file or the entry is absent, which every caller reads +// as "no Entire hooks here" rather than as an error. +// +// Parsed per-entry, not as a whole file of HookConfigs: foreign entries are +// free-form user content and need not match our struct shapes, and a strict +// whole-file unmarshal would fail on them and permanently report our own +// entry as missing. +func readEntireEntry(cfg *agent.HookConfigFile) (json.RawMessage, bool, error) { + data, err := cfg.Read() + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + return nil, false, err //nolint:wrapcheck // agent.HookConfigFile already names the file in its error + } + var rawFile map[string]json.RawMessage + if err := json.Unmarshal(data, &rawFile); err != nil { + return nil, false, fmt.Errorf("parse hook config: %w", err) + } + entry, ok := rawFile["entire"] + return entry, ok, nil +} + +// HooksDisabled reports whether the repo's "entire" entry is present but +// explicitly switched off with "enabled": false. InstallHooks already treats +// that as a deliberate opt-out and leaves the entry alone. +// +// It exists so `entire doctor` can stay quiet about a configuration the user +// turned off. AreHooksInstalled and DetectPresence deliberately still report +// such an entry as present: they drive agent auto-detection and +// `entire agent list`, which describe what is on disk. +func (a *AntigravityAgent) HooksDisabled(ctx context.Context) (bool, error) { + cfg, err := a.hookConfig(ctx) + if err != nil { + return false, err + } + entry, ok, err := readEntireEntry(cfg) + if err != nil || !ok { + return false, err + } + var existingCfg HookConfig + if err := json.Unmarshal(entry, &existingCfg); err != nil { + return false, fmt.Errorf("parse entire hook entry: %w", err) + } + return existingCfg.Enabled != nil && !*existingCfg.Enabled, nil +} + +// hookConfig opens the repo's .agents/hooks.json through agent.HookConfigFile, +// which anchors on the worktree root and refuses a symlink at any component +// it creates directories under or writes through. +func (a *AntigravityAgent) hookConfig(ctx context.Context) (*agent.HookConfigFile, error) { + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + // Not a repository (tests, and `enable` before `git init`): the process + // directory is the only candidate, and it is a directory the caller + // chose rather than one derived from anything read off disk. The same + // fallback every other agent's hook config uses. + repoRoot = "." + } + return agent.OpenHookConfig(repoRoot, a.HookConfigRelPath()) //nolint:wrapcheck // agent.HookConfigFile already names the file in its error +} + +// UninstallHooks removes the Entire hook entry from .agents/hooks.json. +func (a *AntigravityAgent) UninstallHooks(ctx context.Context) error { + cfg, err := a.hookConfig(ctx) + if err != nil { + return err + } + data, err := cfg.Read() + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil // No hooks file means nothing to uninstall + } + return err //nolint:wrapcheck // agent.HookConfigFile already names the file in its error + } + + var rawFile map[string]json.RawMessage + if err := json.Unmarshal(data, &rawFile); err != nil { + return fmt.Errorf("failed to parse hooks.json: %w", err) + } + + // Nothing of ours in the file: leave it byte-for-byte alone. Rewriting it + // would re-indent and reorder a file that holds only the user's own + // entries, for no change of Entire's (the title-tee uninstall is careful + // about exactly this). + if _, ok := rawFile["entire"]; !ok { + return nil + } + delete(rawFile, "entire") + + return writeHooksFile(rawFile, cfg) +} + +// AreHooksInstalled checks if Entire hooks are installed. +func (a *AntigravityAgent) AreHooksInstalled(ctx context.Context) (bool, error) { + hookCfg, err := a.hookConfig(ctx) + if err != nil { + return false, err + } + entireRaw, ok, err := readEntireEntry(hookCfg) + if err != nil || !ok { + return false, err + } + var cfg HookConfig + if err := json.Unmarshal(entireRaw, &cfg); err != nil { + return false, fmt.Errorf("parse entire hook entry: %w", err) + } + + // Check at least one of our hook commands is present + return hasEntireHookInToolHandlers(cfg.PreToolUse) || + hasEntireHookInToolHandlers(cfg.PostToolUse) || + hasEntireHookInSimpleHandlers(cfg.PreInvocation) || + hasEntireHookInSimpleHandlers(cfg.PostInvocation) || + hasEntireHookInSimpleHandlers(cfg.Stop), nil +} + +// stopHookTimeoutSeconds is the explicit timeout installed on the Stop +// handler. Stop runs PrepareTranscript (short bounded wait) plus SaveStep — a +// shadow-branch checkpoint write that can exceed agy's 30s default timeout on +// large repos, in which case agy kills the hook mid-checkpoint with no trace. +const stopHookTimeoutSeconds = 300 + +// buildEntireHookConfig constructs the HookConfig for the "entire" entry for +// the host this binary runs on. +func buildEntireHookConfig() HookConfig { + return buildEntireHookConfigForHost(agent.HookHostIsWindows()) +} + +// buildEntireHookConfigForHost constructs the "entire" entry for a Windows or +// POSIX hook host. agy hands every hook command to cmd.exe /C on Windows +// whatever else is installed (a Git Bash sh on PATH changes nothing), so the +// gate is agent.HookHostIsWindows, not the UseWindowsProductionHooks sh probe, +// and the wrapper is the bare direct-shell form: the sh wrapper is cut apart by +// cmd.exe (`>/dev/null` becomes a redirect to a missing path) and the nested +// cmd.exe form becomes one unrecognised program name. Both fail the hook with +// exit 1, visible only in agy's own log while the turn reports SUCCESS — +// silent, total loss of tracking (trail 444, confirmed on Windows 11 ARM64 with +// agy 1.2.7). +func buildEntireHookConfigForHost(windowsHost bool) HookConfig { + const cmdPrefix = "entire hooks antigravity " + makeCmd := func(verb string) string { + if windowsHost { + return agent.WrapWindowsProductionSilentHookCommandDirect(cmdPrefix + verb) + } + return agent.WrapProductionSilentHookCommand(cmdPrefix + verb) + } + + // PostToolUse and PostInvocation are deliberately NOT installed: neither + // maps to a lifecycle event, and installing them spawns a no-op `entire` + // subprocess on every completed tool call / model invocation. The struct + // fields stay in HookConfig so the idempotency comparison detects (and + // replaces) stale installs that still carry them. + return HookConfig{ + PreToolUse: []ToolHandler{ + { + Matcher: "*", + Hooks: []HookCommand{{Type: hookTypeCommand, Command: makeCmd("pre-tool-use")}}, + }, + }, + PreInvocation: []SimpleHandler{{Type: hookTypeCommand, Command: makeCmd("pre-invocation")}}, + Stop: []SimpleHandler{{Type: hookTypeCommand, Command: makeCmd("stop"), Timeout: stopHookTimeoutSeconds}}, + } +} + +// writeHooksFile marshals rawFile and writes it through cfg, which creates the +// parent directories inside the worktree root and refuses symlinks. +func writeHooksFile(rawFile map[string]json.RawMessage, cfg *agent.HookConfigFile) error { + output, err := jsonutil.MarshalIndentWithNewline(rawFile, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal hooks.json: %w", err) + } + return cfg.Write(output, 0o600) //nolint:wrapcheck // agent.HookConfigFile already names the file in its error +} + +// hasEntireHookInToolHandlers checks if any ToolHandler entry is an Entire hook. +func hasEntireHookInToolHandlers(handlers []ToolHandler) bool { + for _, th := range handlers { + for _, hc := range th.Hooks { + if agent.IsManagedHookCommand(hc.Command) { + return true + } + } + } + return false +} + +// hasEntireHookInSimpleHandlers checks if any SimpleHandler entry is an Entire hook. +func hasEntireHookInSimpleHandlers(handlers []SimpleHandler) bool { + for _, sh := range handlers { + if agent.IsManagedHookCommand(sh.Command) { + return true + } + } + return false +} diff --git a/cmd/entire/cli/agent/antigravity/hooks_probe.go b/cmd/entire/cli/agent/antigravity/hooks_probe.go new file mode 100644 index 0000000000..e40f5a51d7 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/hooks_probe.go @@ -0,0 +1,207 @@ +package antigravity + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "path/filepath" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +// MinHooksProbeVersion is the first agy release whose print mode answers the +// read-only `/hooks` slash command locally — one tab-separated record per hook +// (or a structured payload under --output-format json) — "without starting an +// agent turn, spending quota or leaving a conversation behind" (agy 1.1.12 +// release notes). On older releases `-p "/hooks"` is sent to the model as +// literal prompt text, so the probe must never run there. +const MinHooksProbeVersion = "1.1.12" + +// antigravityBinaryName is the agy CLI binary looked up on PATH. +const antigravityBinaryName = "agy" + +// hooksProbeTimeout bounds the probe: agy still boots its language server for +// print mode, which takes a few seconds, but a hang (e.g. a stuck keyring +// prompt) must not stall `entire doctor`. +const hooksProbeTimeout = 30 * time.Second + +// HooksProbe is the outcome of asking agy which hooks it actually loads for a +// workspace. Loaded distinguishes "agy sees Entire's entry" from "the file is +// on disk but agy ignores it" — the latter is the untrusted-workspace trap +// (agy resolves an untrusted cwd to its scratch workspace, so no hooks fire). +type HooksProbe struct { + // Version is the agy version string reported by `agy --version`. + Version string + // Loaded is true when agy lists an Entire hook entry sourced from the + // workspace's .agents/hooks.json. + Loaded bool + // Sources are the hooks.json paths agy reported, for diagnostics. + Sources []string +} + +// ErrHooksProbeUnsupported is returned when the installed agy predates +// MinHooksProbeVersion; callers should report "cannot verify" rather than +// "not loaded". +var ErrHooksProbeUnsupported = errors.New("agy too old to answer /hooks in print mode") + +// ErrHooksProbeVersionUnknown is returned when `agy --version` printed +// something semver cannot parse (a build suffix, a banner line). It is +// deliberately distinct from ErrHooksProbeUnsupported: the agy may well be +// newer than the requirement, so "run `agy update`" would be wrong advice. +// The probe is skipped either way — a wrong guess is a real model turn. +var ErrHooksProbeVersionUnknown = errors.New("could not determine the agy version") + +// DoctorProbeEnv opts `entire doctor` into running ProbeLoadedHooks. Off by +// default: the probe's zero-quota argument rested on the version gate alone, +// and on Windows 11 with agy 1.2.7 `agy -p "/hooks"` was observed to run a +// full model turn (~12k input tokens) instead of answering locally. Until the +// local-answer behaviour is verified per platform rather than assumed from a +// version number, a default doctor run must not risk the user's quota. The +// probe also only proves agy PARSED hooks.json, not that the command in it can +// run; HooksEntryMatchesHost is the check that catches the failure that +// actually occurs. +const DoctorProbeEnv = "ENTIRE_ANTIGRAVITY_DOCTOR_PROBE" + +// ProbeLoadedHooks asks the agy binary on PATH which hooks it loads for +// repoRoot, via `agy -p /hooks --add-dir --output-format json`. +// --add-dir is what makes agy treat repoRoot as the workspace (the same flag +// the e2e harness relies on); without it the probe would answer for agy's +// scratch workspace and always report nothing loaded. +// +// Zero-quota by construction: the version gate guarantees agy answers /hooks +// locally. Returns ErrHooksProbeUnsupported for older agy, and any spawn or +// parse failure otherwise (an unauthenticated agy fails print mode with +// "authentication required", which surfaces here as an error). +func ProbeLoadedHooks(ctx context.Context, repoRoot string) (HooksProbe, error) { + probe := HooksProbe{} + // agy rejects a relative --add-dir but does not fail the run: it logs the + // rejection, loads zero hooks and answers for its scratch workspace, which + // would read here as "hooks not loaded". Refuse to ask the question wrong. + if !filepath.IsAbs(repoRoot) { + return probe, fmt.Errorf("agy --add-dir needs an absolute path, got %q", repoRoot) + } + agyPath, err := exec.LookPath(antigravityBinaryName) + if err != nil { + return probe, fmt.Errorf("agy not on PATH: %w", err) + } + + ctx, cancel := context.WithTimeout(ctx, hooksProbeTimeout) + defer cancel() + + versionOut, err := exec.CommandContext(ctx, agyPath, "--version").Output() + if err != nil { + return probe, fmt.Errorf("agy --version: %w", err) + } + probe.Version = strings.TrimSpace(string(versionOut)) + if err := classifyProbeVersion(probe.Version); err != nil { + return probe, err + } + + cmd := exec.CommandContext(ctx, agyPath, "-p", "/hooks", "--add-dir", repoRoot, "--output-format", "json") + cmd.Dir = repoRoot + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = strings.TrimSpace(stdout.String()) + } + return probe, fmt.Errorf("agy -p /hooks: %w: %s", err, firstLine(msg)) + } + + loaded, sources, err := parseHooksProbeOutput(stdout.Bytes(), filepath.Join(repoRoot, ".agents", AgentsHooksFileName)) + if err != nil { + return probe, err + } + probe.Loaded = loaded + probe.Sources = sources + return probe, nil +} + +// HooksProbeSupported reports whether an agy version string answers /hooks +// locally in print mode. Unparseable versions are treated as unsupported — +// the failure mode of a wrong guess is a real model turn on the user's quota. +func HooksProbeSupported(version string) bool { + return classifyProbeVersion(version) == nil +} + +// classifyProbeVersion returns nil for a version that answers /hooks locally, +// ErrHooksProbeVersionUnknown for one semver cannot parse, and +// ErrHooksProbeUnsupported for one that is too old. +func classifyProbeVersion(version string) error { + v := strings.TrimSpace(version) + if !strings.HasPrefix(v, "v") { + v = "v" + v + } + if !semver.IsValid(v) { + return fmt.Errorf("%w: agy --version printed %q", ErrHooksProbeVersionUnknown, strings.TrimSpace(version)) + } + if semver.Compare(v, "v"+MinHooksProbeVersion) < 0 { + return fmt.Errorf("%w: have %s, need >= %s", ErrHooksProbeUnsupported, strings.TrimSpace(version), MinHooksProbeVersion) + } + return nil +} + +// hooksProbeEnvelope is the subset of agy's --output-format json envelope the +// probe reads: command.data.hooks[] carries one entry per hooks.json "name" +// key with the file it came from. +// agyProbeStatusSuccess is the status agy reports for a completed command. +const agyProbeStatusSuccess = "SUCCESS" + +type hooksProbeEnvelope struct { + Status string `json:"status"` + Command struct { + Name string `json:"name"` + Data struct { + Hooks []struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Source string `json:"source"` + } `json:"hooks"` + } `json:"data"` + } `json:"command"` +} + +// parseHooksProbeOutput reports whether the envelope lists an enabled "entire" +// entry whose source is hooksPath (compared after symlink resolution on both +// sides, since agy reports the path it resolved). Sources of every listed +// entry are returned for diagnostics. +func parseHooksProbeOutput(out []byte, hooksPath string) (loaded bool, sources []string, err error) { + var env hooksProbeEnvelope + if err := json.Unmarshal(out, &env); err != nil { + return false, nil, fmt.Errorf("agy -p /hooks: unexpected output: %w", err) + } + // A non-success envelope carries no hooks, which is indistinguishable from + // a genuine empty list once the status is dropped — and the caller's + // remediation for an empty list is "your hooks are NOT LOADED", the wrong + // thing to tell someone whose probe failed. An absent status is left alone + // rather than treated as failure: only a status agy actually reported is + // evidence about the probe. + if env.Status != "" && !strings.EqualFold(env.Status, agyProbeStatusSuccess) { + return false, nil, fmt.Errorf("agy -p /hooks: reported status %q", env.Status) + } + wantPath := resolveAgySymlinks(hooksPath) + for _, h := range env.Command.Data.Hooks { + sources = append(sources, h.Source) + if h.Name != "entire" || !h.Enabled { + continue + } + if resolveAgySymlinks(h.Source) == wantPath { + loaded = true + } + } + return loaded, sources, nil +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/cmd/entire/cli/agent/antigravity/hooks_probe_test.go b/cmd/entire/cli/agent/antigravity/hooks_probe_test.go new file mode 100644 index 0000000000..e186c4a2a1 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/hooks_probe_test.go @@ -0,0 +1,88 @@ +package antigravity + +import ( + "path/filepath" + "testing" +) + +func TestHooksProbeSupported(t *testing.T) { + t.Parallel() + cases := map[string]bool{ + "1.1.22": true, + "1.1.12": true, + "v1.1.12": true, + "1.1.11": false, + "1.1.1": false, + "1.0.16": false, + "": false, + "dev": false, + } + for version, want := range cases { + if got := HooksProbeSupported(version); got != want { + t.Errorf("HooksProbeSupported(%q) = %v, want %v", version, got, want) + } + } +} + +func TestParseHooksProbeOutput(t *testing.T) { + t.Parallel() + dir := t.TempDir() + hooksPath := filepath.Join(dir, ".agents", "hooks.json") + + // Real 1.1.22 envelope shape (agy -p /hooks --output-format json). + out := []byte(`{"conversation_id":"","status":"SUCCESS","response":"entire\tenabled\tPreToolUse\t*\tcommand\tx\n","duration_seconds":0,"num_turns":0,"usage":{"input_tokens":0},"command":{"name":"hooks","data":{"hooks":[{"name":"entire","enabled":true,"source":"` + hooksPath + `","actions":[{"event":"PreToolUse","matcher":"*","type":"command","command":"x"}]}]}}}`) + loaded, sources, err := parseHooksProbeOutput(out, hooksPath) + if err != nil { + t.Fatal(err) + } + if !loaded { + t.Fatalf("want loaded=true for matching enabled entire entry, sources=%v", sources) + } + + // Disabled entry does not count. + disabled := []byte(`{"command":{"data":{"hooks":[{"name":"entire","enabled":false,"source":"` + hooksPath + `"}]}}}`) + loaded, _, err = parseHooksProbeOutput(disabled, hooksPath) + if err != nil || loaded { + t.Fatalf("disabled entire entry must not count as loaded: loaded=%v err=%v", loaded, err) + } + + // Another workspace's hooks.json does not count (untrusted-cwd trap). + other := []byte(`{"command":{"data":{"hooks":[{"name":"entire","enabled":true,"source":"/elsewhere/.agents/hooks.json"}]}}}`) + loaded, sources, err = parseHooksProbeOutput(other, hooksPath) + if err != nil || loaded { + t.Fatalf("other workspace source must not count: loaded=%v err=%v", loaded, err) + } + if len(sources) != 1 || sources[0] != "/elsewhere/.agents/hooks.json" { + t.Fatalf("sources = %v, want the reported path for diagnostics", sources) + } + + if _, _, err := parseHooksProbeOutput([]byte("not json"), hooksPath); err == nil { + t.Fatal("garbage output must error, not report loaded=false silently") + } +} + +// A failed probe and a genuine empty hook list both arrive with no hooks, so +// dropping the envelope's status made them indistinguishable — and doctor's +// remediation for an empty list tells the user their hooks are NOT LOADED, +// which is the wrong advice for someone whose probe never ran. +func TestParseHooksProbeOutput_RejectsNonSuccessStatus(t *testing.T) { + t.Parallel() + + hooksPath := filepath.Join(t.TempDir(), "hooks.json") + + loaded, _, err := parseHooksProbeOutput( + []byte(`{"status":"ERROR","command":{"name":"hooks","data":{"hooks":[]}}}`), hooksPath) + if err == nil { + t.Error("a non-success envelope must be reported as a failed probe, not as no hooks loaded") + } + if loaded { + t.Error("loaded must stay false for a failed probe") + } + + // An absent status is not evidence of failure: only a status agy actually + // reported says anything about the probe. + if _, _, err := parseHooksProbeOutput( + []byte(`{"command":{"name":"hooks","data":{"hooks":[]}}}`), hooksPath); err != nil { + t.Errorf("an envelope with no status must still parse: %v", err) + } +} diff --git a/cmd/entire/cli/agent/antigravity/hooks_test.go b/cmd/entire/cli/agent/antigravity/hooks_test.go new file mode 100644 index 0000000000..8996c00223 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/hooks_test.go @@ -0,0 +1,672 @@ +package antigravity + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + + "github.com/entireio/cli/cmd/entire/cli/osroot" +) + +func TestInstallHooks_FreshRepo(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + t.Setenv(configDirEnv, t.TempDir()) + + a := &AntigravityAgent{} + n, err := a.InstallHooks(context.Background(), false) + if err != nil { + t.Fatalf("InstallHooks: %v", err) + } + if n != 3 { + t.Errorf("installed %d hooks, want 3", n) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, ".agents", "hooks.json")) + if err != nil { + t.Fatal(err) + } + var f HooksFile + if err := json.Unmarshal(data, &f); err != nil { + t.Fatalf("parse hooks.json: %v", err) + } + cfg, ok := f["entire"] + if !ok { + t.Fatal("missing 'entire' hook entry") + } + if len(cfg.PreToolUse) != 1 || len(cfg.PreInvocation) != 1 || len(cfg.Stop) != 1 { + t.Errorf("event coverage incomplete: %+v", cfg) + } + // PostToolUse/PostInvocation are deliberately not installed: they have no + // lifecycle mapping and would spawn a no-op subprocess per tool call. + if len(cfg.PostToolUse) != 0 || len(cfg.PostInvocation) != 0 { + t.Errorf("no-op post hooks must not be installed: %+v", cfg) + } + if cfg.PreToolUse[0].Matcher != "*" { + t.Errorf("PreToolUse matcher = %q, want %q", cfg.PreToolUse[0].Matcher, "*") + } + // Stop runs PrepareTranscript + SaveStep (a shadow-branch checkpoint + // write); agy's default hook timeout is 30s, which a large repo can + // exceed — agy would kill the hook mid-checkpoint with no trace. The + // installed handler must carry an explicit generous timeout. + if cfg.Stop[0].Timeout != stopHookTimeoutSeconds { + t.Errorf("Stop timeout = %d, want %d", cfg.Stop[0].Timeout, stopHookTimeoutSeconds) + } +} + +func TestInstallHooks_Idempotent(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + t.Setenv(configDirEnv, t.TempDir()) + + a := &AntigravityAgent{} + + // First install + n, err := a.InstallHooks(context.Background(), false) + if err != nil { + t.Fatalf("first InstallHooks: %v", err) + } + if n != 3 { + t.Errorf("first install: installed %d hooks, want 3", n) + } + + // Second install — idempotent, should return 0 + n, err = a.InstallHooks(context.Background(), false) + if err != nil { + t.Fatalf("second InstallHooks: %v", err) + } + if n != 0 { + t.Errorf("second install: installed %d hooks, want 0 (idempotent)", n) + } +} + +func TestInstallHooks_PreservesForeignHooks(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + t.Setenv(configDirEnv, t.TempDir()) + + // Pre-seed .agents/hooks.json with a foreign entry + agentsDir := filepath.Join(tmpDir, ".agents") + if err := os.MkdirAll(agentsDir, 0o750); err != nil { + t.Fatal(err) + } + foreign := HooksFile{ + "safety-gate": { + PreToolUse: []ToolHandler{ + {Matcher: "*", Hooks: []HookCommand{{Type: "command", Command: "safety-gate check"}}}, + }, + }, + } + foreignBytes, err := json.Marshal(foreign) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentsDir, "hooks.json"), foreignBytes, 0o600); err != nil { + t.Fatal(err) + } + + a := &AntigravityAgent{} + n, err := a.InstallHooks(context.Background(), false) + if err != nil { + t.Fatalf("InstallHooks: %v", err) + } + if n != 3 { + t.Errorf("installed %d hooks, want 3", n) + } + + data, err := os.ReadFile(filepath.Join(agentsDir, "hooks.json")) + if err != nil { + t.Fatal(err) + } + var f HooksFile + if err := json.Unmarshal(data, &f); err != nil { + t.Fatalf("parse hooks.json: %v", err) + } + + // Foreign entry must survive + if _, ok := f["safety-gate"]; !ok { + t.Error("foreign 'safety-gate' hook entry was removed") + } + + // Entire entry must also exist + if _, ok := f["entire"]; !ok { + t.Error("missing 'entire' hook entry after install") + } +} + +func TestUninstallHooks_LeavesForeignHooks(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + t.Setenv(configDirEnv, t.TempDir()) + + a := &AntigravityAgent{} + + // Install entire hooks first + if _, err := a.InstallHooks(context.Background(), false); err != nil { + t.Fatalf("InstallHooks: %v", err) + } + + // Pre-seed a foreign entry alongside the entire one + agentsDir := filepath.Join(tmpDir, ".agents") + data, err := os.ReadFile(filepath.Join(agentsDir, "hooks.json")) + if err != nil { + t.Fatal(err) + } + var f HooksFile + if err := json.Unmarshal(data, &f); err != nil { + t.Fatal(err) + } + f["safety-gate"] = HookConfig{ + PreToolUse: []ToolHandler{ + {Matcher: "*", Hooks: []HookCommand{{Type: "command", Command: "safety-gate check"}}}, + }, + } + out, err := json.Marshal(f) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentsDir, "hooks.json"), out, 0o600); err != nil { + t.Fatal(err) + } + + // Uninstall + if err := a.UninstallHooks(context.Background()); err != nil { + t.Fatalf("UninstallHooks: %v", err) + } + + // Read back + data, err = os.ReadFile(filepath.Join(agentsDir, "hooks.json")) + if err != nil { + t.Fatal(err) + } + var after HooksFile + if err := json.Unmarshal(data, &after); err != nil { + t.Fatalf("parse hooks.json after uninstall: %v", err) + } + + // Foreign must survive + if _, ok := after["safety-gate"]; !ok { + t.Error("foreign 'safety-gate' entry was removed by UninstallHooks") + } + + // Entire entry must be gone + if _, ok := after["entire"]; ok { + t.Error("'entire' hook entry still present after UninstallHooks") + } +} + +func TestAreHooksInstalled(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + t.Setenv(configDirEnv, t.TempDir()) + + a := &AntigravityAgent{} + + if installed, err := a.AreHooksInstalled(context.Background()); err != nil || installed { + t.Errorf("AreHooksInstalled() = (%v, %v) before install, want (false, nil)", installed, err) + } + + if _, err := a.InstallHooks(context.Background(), false); err != nil { + t.Fatalf("InstallHooks: %v", err) + } + + if installed, err := a.AreHooksInstalled(context.Background()); err != nil || !installed { + t.Errorf("AreHooksInstalled() = (%v, %v) after install, want (true, nil)", installed, err) + } +} + +// TestAreHooksInstalled_ToleratesForeignEntryShapes pins detection tolerance: +// .agents/hooks.json is a shared, user-editable file, and foreign hook entries +// are free-form (agy only requires OUR entry to be well-shaped). A foreign +// entry whose fields don't match Entire's struct types (e.g. a string timeout) +// must not break detection of the entire entry — otherwise install succeeds +// while status/doctor permanently report "not installed". +func TestAreHooksInstalled_ToleratesForeignEntryShapes(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + t.Setenv(configDirEnv, t.TempDir()) + + a := &AntigravityAgent{} + if _, err := a.InstallHooks(context.Background(), false); err != nil { + t.Fatalf("InstallHooks: %v", err) + } + + // Splice in a foreign entry with shapes that don't unmarshal into + // Entire's handler structs: string timeout, numeric command. + hooksPath := filepath.Join(tmpDir, ".agents", AgentsHooksFileName) + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + raw["users-own-linter"] = json.RawMessage(`{"PreInvocation":[{"command":42,"timeout":"5s"}],"Stop":"not-a-list"}`) + merged, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(hooksPath, merged, 0o600); err != nil { + t.Fatal(err) + } + + if installed, err := a.AreHooksInstalled(context.Background()); err != nil || !installed { + t.Errorf("AreHooksInstalled() = (%v, %v) with a malformed foreign entry present, want (true, nil)", installed, err) + } +} + +// TestInstallHooks_RespectsUserDisabledEntry pins that a user-set +// "enabled": false on the entire hooks entry (agy's documented per-entry +// disable knob) is a deliberate choice: a non-force reinstall must leave the +// entry untouched rather than rewriting it and silently re-arming tracking. +func TestInstallHooks_RespectsUserDisabledEntry(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + t.Setenv(configDirEnv, t.TempDir()) + + a := &AntigravityAgent{} + if _, err := a.InstallHooks(context.Background(), false); err != nil { + t.Fatalf("InstallHooks: %v", err) + } + + // User disables the entry through agy's documented knob. + hooksPath := filepath.Join(tmpDir, ".agents", AgentsHooksFileName) + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := json.Unmarshal(raw["entire"], &cfg); err != nil { + t.Fatal(err) + } + cfg["enabled"] = false + entireBytes, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + raw["entire"] = entireBytes + merged, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(hooksPath, merged, 0o600); err != nil { + t.Fatal(err) + } + + n, err := a.InstallHooks(context.Background(), false) + if err != nil { + t.Fatalf("InstallHooks after disable: %v", err) + } + if n != 0 { + t.Errorf("InstallHooks rewrote a user-disabled entry (n=%d), want 0", n) + } + + after, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var afterRaw map[string]json.RawMessage + if err := json.Unmarshal(after, &afterRaw); err != nil { + t.Fatal(err) + } + var afterCfg HookConfig + if err := json.Unmarshal(afterRaw["entire"], &afterCfg); err != nil { + t.Fatal(err) + } + if afterCfg.Enabled == nil || *afterCfg.Enabled { + t.Error("user-set enabled:false was dropped — hooks silently re-armed") + } + + // --force is the explicit override: it may rewrite (and re-arm) the entry. + if _, err := a.InstallHooks(context.Background(), true); err != nil { + t.Fatalf("InstallHooks --force: %v", err) + } +} + +// TestInstallHooks_IdempotentStillRepairsTitleTee guards the regression where +// the repo-hooks idempotency early-return skipped the global title-tee install, +// leaving Antigravity checkpoints without token counts after an upgrade or a +// failed first title install (and making the doctor's "re-run setup" hint a +// no-op). Re-running InstallHooks must repair the missing global slot even when +// the repo's .agents/hooks.json already matches. +func TestInstallHooks_IdempotentStillRepairsTitleTee(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + t.Setenv(configDirEnv, t.TempDir()) + putFakeAgyOnPath(t) + + a := &AntigravityAgent{} + if _, err := a.InstallHooks(context.Background(), false); err != nil { + t.Fatalf("first InstallHooks: %v", err) + } + if !TitleTeeInstalled() { + t.Fatal("title tee should be installed after the first InstallHooks") + } + + // Simulate a missing/stale global slot while repo hooks remain correct. + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + if TitleTeeInstalled() { + t.Fatal("precondition: title tee should be gone before the idempotent re-install") + } + + // Second install hits the repo-hooks idempotency early-return, but must + // still re-install the missing global title tee. + if _, err := a.InstallHooks(context.Background(), false); err != nil { + t.Fatalf("second InstallHooks: %v", err) + } + if !TitleTeeInstalled() { + t.Error("idempotent InstallHooks must repair the missing title tee") + } +} + +// TestHooks_RefuseSymlinkedAgentsDir pins the three operations that used to +// resolve .agents through a checked-in symlink with bare os.ReadFile / +// os.MkdirAll / a joined-path write. A repository shipping +// `.agents -> /somewhere/else` got hooks.json created and rewritten outside the +// worktree from InstallHooks, deleted-from outside it from UninstallHooks, and +// AreHooksInstalled read the far end as its own answer. It is reachable +// without the user naming antigravity: DetectPresence is AreHooksInstalled. +func TestHooks_RefuseSymlinkedAgentsDir(t *testing.T) { + outside := t.TempDir() + worktree := t.TempDir() + if err := os.Symlink(outside, filepath.Join(worktree, ".agents")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + // The link's far end already holds an entire entry, so a followed read has + // something to report and a followed write has something to destroy. + planted := filepath.Join(outside, AgentsHooksFileName) + plantedBody := `{"entire":{"stop":[{"type":"command","command":"entire hooks antigravity stop"}]}}` + if err := os.WriteFile(planted, []byte(plantedBody), 0o600); err != nil { + t.Fatal(err) + } + t.Chdir(worktree) + t.Setenv(configDirEnv, t.TempDir()) + + a := &AntigravityAgent{} + + if _, err := a.InstallHooks(context.Background(), false); !errors.Is(err, osroot.ErrSymlinkedPath) { + t.Errorf("InstallHooks err = %v, want osroot.ErrSymlinkedPath", err) + } + + // An unreadable answer is not "no hooks": a caller deciding whether hooks + // can be left alone must not be told the far end's content is ours. + installed, err := a.AreHooksInstalled(context.Background()) + if !errors.Is(err, osroot.ErrSymlinkedPath) { + t.Errorf("AreHooksInstalled err = %v, want osroot.ErrSymlinkedPath", err) + } + if installed { + t.Error("AreHooksInstalled followed the link and claimed the planted entry") + } + + if err := a.UninstallHooks(context.Background()); !errors.Is(err, osroot.ErrSymlinkedPath) { + t.Errorf("UninstallHooks err = %v, want osroot.ErrSymlinkedPath", err) + } + + got, err := os.ReadFile(planted) + if err != nil { + t.Fatalf("the file at the link's far end must survive untouched: %v", err) + } + if string(got) != plantedBody { + t.Errorf("hooks.json outside the worktree was rewritten:\n%s", got) + } +} + +// TestHooks_RefuseSymlinkedHooksFile: the leaf is refused too. os.Root blocks a +// link that escapes the worktree but follows one pointing elsewhere inside it, +// and accepting the leaf would let `.agents/hooks.json -> ../victim.json` +// redirect both the merge read and the subsequent write. +func TestHooks_RefuseSymlinkedHooksFile(t *testing.T) { + worktree := t.TempDir() + if err := os.MkdirAll(filepath.Join(worktree, ".agents"), 0o750); err != nil { + t.Fatal(err) + } + victim := filepath.Join(worktree, "victim.json") + if err := os.WriteFile(victim, []byte(`{"mine":true}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("..", "victim.json"), filepath.Join(worktree, ".agents", AgentsHooksFileName)); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + t.Chdir(worktree) + t.Setenv(configDirEnv, t.TempDir()) + + a := &AntigravityAgent{} + if _, err := a.InstallHooks(context.Background(), false); !errors.Is(err, osroot.ErrSymlinkedPath) { + t.Errorf("InstallHooks err = %v, want osroot.ErrSymlinkedPath", err) + } + got, err := os.ReadFile(victim) + if err != nil { + t.Fatal(err) + } + if string(got) != `{"mine":true}` { + t.Errorf("the link's target was rewritten:\n%s", got) + } + info, err := os.Lstat(filepath.Join(worktree, ".agents", AgentsHooksFileName)) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Error("the user's symlink was replaced by a regular file") + } +} + +// agy runs hook commands through cmd.exe /C on Windows, so the installed +// command must be the bare direct-shell wrapper there: no `sh -c` (cmd.exe +// tears its redirects apart) and no nested `cmd.exe /d /s /c "…"` block (cmd.exe +// /C takes the quoted block as one program name). Confirmed on Windows 11 with +// agy 1.2.7 in trail 444. +func TestBuildEntireHookConfig_WindowsHostUsesDirectCmdWrapper(t *testing.T) { + t.Parallel() + + cfg := buildEntireHookConfigForHost(true) + commands := []string{ + cfg.PreToolUse[0].Hooks[0].Command, + cfg.PreInvocation[0].Command, + cfg.Stop[0].Command, + } + for _, cmd := range commands { + if strings.HasPrefix(cmd, "sh -c") { + t.Errorf("Windows host got the sh wrapper: %s", cmd) + } + if strings.HasPrefix(cmd, "cmd.exe") { + t.Errorf("Windows host got the nested cmd.exe wrapper, which agy's own cmd.exe /C rejects: %s", cmd) + } + if !strings.HasPrefix(cmd, "where.exe entire >nul 2>nul & if errorlevel 1 (ver>nul) else (entire hooks antigravity ") { + t.Errorf("unexpected Windows hook command shape: %s", cmd) + } + if !agent.IsManagedHookCommand(cmd) { + t.Errorf("uninstall and drift detection must still recognise the Windows command as Entire's: %s", cmd) + } + } + + posix := buildEntireHookConfigForHost(false) + if !strings.HasPrefix(posix.Stop[0].Command, "sh -c ") { + t.Errorf("POSIX host must keep the sh wrapper, got %s", posix.Stop[0].Command) + } +} + +// A hooks.json that never carried an Entire entry is the user's file: uninstall +// must not rewrite it (re-indented, keys reordered) for no change of ours. +func TestUninstallHooks_LeavesAForeignOnlyFileUntouched(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + t.Setenv(configDirEnv, t.TempDir()) + + hooksPath := filepath.Join(dir, ".agents", AgentsHooksFileName) + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + t.Fatal(err) + } + // Deliberately odd formatting: four-space indent, keys out of sorted order. + original := "{\n \"zeta\": {\"Stop\": [{\"type\": \"command\", \"command\": \"echo z\"}]},\n \"alpha\": {\"PreInvocation\": [{\"type\": \"command\", \"command\": \"echo a\"}]}\n}\n" + if err := os.WriteFile(hooksPath, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + + if err := (&AntigravityAgent{}).UninstallHooks(context.Background()); err != nil { + t.Fatalf("UninstallHooks: %v", err) + } + got, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + if string(got) != original { + t.Fatalf("foreign-only hooks.json was rewritten:\n%s", got) + } +} + +// HooksEntryMatchesHost is doctor's zero-cost replacement for the probe: it +// must call a fresh install current, a foreign-shaped entry stale, and a +// user-disabled entry current (install leaves it alone too). +func TestHooksEntryMatchesHost(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + t.Setenv(configDirEnv, t.TempDir()) + a := &AntigravityAgent{} + ctx := context.Background() + + installed, current, err := a.HooksEntryMatchesHost(ctx) + if err != nil || installed || current { + t.Fatalf("no file: installed=%v current=%v err=%v; want false,false,nil", installed, current, err) + } + + if _, err := a.InstallHooks(ctx, false); err != nil { + t.Fatal(err) + } + installed, current, err = a.HooksEntryMatchesHost(ctx) + if err != nil || !installed || !current { + t.Fatalf("fresh install: installed=%v current=%v err=%v; want true,true,nil", installed, current, err) + } + + hooksPath := filepath.Join(dir, ".agents", AgentsHooksFileName) + stale := `{"entire":{"PreInvocation":[{"type":"command","command":"sh -c 'exec entire hooks antigravity pre-invocation'"}]}}` + if err := os.WriteFile(hooksPath, []byte(stale), 0o600); err != nil { + t.Fatal(err) + } + installed, current, err = a.HooksEntryMatchesHost(ctx) + if err != nil || !installed || current { + t.Fatalf("foreign shape: installed=%v current=%v err=%v; want true,false,nil", installed, current, err) + } + + disabled := `{"entire":{"enabled":false,"PreInvocation":[{"type":"command","command":"echo off"}]}}` + if err := os.WriteFile(hooksPath, []byte(disabled), 0o600); err != nil { + t.Fatal(err) + } + installed, current, err = a.HooksEntryMatchesHost(ctx) + if err != nil || !installed || !current { + t.Fatalf("user-disabled: installed=%v current=%v err=%v; want true,true,nil", installed, current, err) + } +} + +// "enabled": false is a deliberate opt-out that InstallHooks already honours. +// It has to be readable on its own, separately from installed-ness: the entry +// stays genuinely present for agent detection and `entire agent list`, and only +// `entire doctor` wants to go quiet about it. +func TestHooksDisabled_SeparatesOptOutFromPresence(t *testing.T) { + for name, tc := range map[string]struct { + entry string + wantDisabled bool + }{ + "explicitly disabled": {`{"enabled":false,"Stop":[{"type":"command","command":"entire hooks antigravity stop"}]}`, true}, + "explicitly enabled": {`{"enabled":true,"Stop":[{"type":"command","command":"entire hooks antigravity stop"}]}`, false}, + "enabled unset": {`{"Stop":[{"type":"command","command":"entire hooks antigravity stop"}]}`, false}, + } { + t.Run(name, func(t *testing.T) { + // No t.Parallel — uses t.Chdir and t.Setenv + tmpDir := t.TempDir() + t.Chdir(tmpDir) + t.Setenv(configDirEnv, t.TempDir()) + + agentsDir := filepath.Join(tmpDir, ".agents") + if err := os.MkdirAll(agentsDir, 0o750); err != nil { + t.Fatal(err) + } + hooks := `{"entire":` + tc.entry + `}` + if err := os.WriteFile(filepath.Join(agentsDir, AgentsHooksFileName), []byte(hooks), 0o600); err != nil { + t.Fatal(err) + } + + a := &AntigravityAgent{} + disabled, err := a.HooksDisabled(context.Background()) + if err != nil { + t.Fatalf("HooksDisabled: %v", err) + } + if disabled != tc.wantDisabled { + t.Errorf("HooksDisabled() = %v, want %v", disabled, tc.wantDisabled) + } + + // Present either way: detection and `entire agent list` describe + // what is on disk, so the opt-out must not make the entry vanish. + installed, err := a.AreHooksInstalled(context.Background()) + if err != nil { + t.Fatalf("AreHooksInstalled: %v", err) + } + if !installed { + t.Error("AreHooksInstalled() = false; a disabled entry is still present") + } + }) + } +} + +// putFakeAgyOnPath makes InstallHooks see an installed agy: the global title +// slot is only claimed on machines where `agy` resolves on PATH, so a test that +// expects the tee to be installed has to stand one up. The file is never run. +func putFakeAgyOnPath(t *testing.T) { + t.Helper() + binDir := t.TempDir() + name := antigravityBinaryName + if runtime.GOOS == "windows" { + name += ".exe" + } + if err := os.WriteFile(filepath.Join(binDir, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write fake agy: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +// The title slot lives in agy's machine-global settings.json, so a repo-local +// `entire agent add antigravity` must not claim it on a machine that has never +// run agy. `entire doctor` gates its matching check on the same lookup. +func TestInstallHooks_SkipsTitleTeeWhenAgyIsAbsent(t *testing.T) { + // No t.Parallel — uses t.Chdir and t.Setenv + tmpDir := t.TempDir() + t.Chdir(tmpDir) + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + // An empty PATH is the "agy was never installed here" machine. + t.Setenv("PATH", t.TempDir()) + + a := &AntigravityAgent{} + if _, err := a.InstallHooks(context.Background(), false); err != nil { + t.Fatalf("InstallHooks: %v", err) + } + + // Repo hooks still installed: the global slot is a separate concern. + installed, err := a.AreHooksInstalled(context.Background()) + if err != nil { + t.Fatalf("AreHooksInstalled: %v", err) + } + if !installed { + t.Error("repo hooks must install even when agy is absent") + } + + if TitleTeeInstalled() { + t.Error("the machine-global title slot was claimed on a machine with no agy") + } + if _, err := os.Stat(filepath.Join(cfgDir, agySettingsFileName)); err == nil { + t.Error("agy's global settings.json was created on a machine that never ran agy") + } +} diff --git a/cmd/entire/cli/agent/antigravity/lifecycle.go b/cmd/entire/cli/agent/antigravity/lifecycle.go new file mode 100644 index 0000000000..31574f777a --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/lifecycle.go @@ -0,0 +1,297 @@ +package antigravity + +import ( + "context" + "encoding/json" + "io" + "path/filepath" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// Antigravity hook name constants — these become subcommands under `entire hooks antigravity`. +// +// Only the hooks with lifecycle significance are installed. agy also offers +// PostToolUse and PostInvocation, but neither maps to an Entire lifecycle +// event (PostInvocation fires before the transcript is written; PostToolUse +// duplicates what PreToolUse already captures) — installing them would spawn +// a no-op `entire` subprocess on every completed tool call and model +// invocation. +const ( + HookNamePreToolUse = "pre-tool-use" + HookNamePreInvocation = "pre-invocation" + HookNameStop = "stop" +) + +// HookNames returns the hook verbs Antigravity supports. +// These become subcommands: entire hooks antigravity +func (a *AntigravityAgent) HookNames() []string { + return []string{ + HookNamePreToolUse, + HookNamePreInvocation, + HookNameStop, + } +} + +// ParseHookEvent translates an Antigravity hook into a normalized lifecycle Event. +// Returns nil if the hook has no lifecycle significance. +func (a *AntigravityAgent) ParseHookEvent(_ context.Context, hookName string, stdin io.Reader) (*agent.Event, error) { + switch hookName { + case HookNamePreInvocation: + return parsePreInvocation(stdin) + case HookNameStop: + return parseStop(stdin) + case HookNamePreToolUse: + return parsePreToolUse(stdin) + default: + return nil, nil //nolint:nilnil // Unknown hooks have no lifecycle action + } +} + +// parsePreInvocation handles the PreInvocation hook. +// +// Emits TurnStart ONLY on the first model invocation of a conversation +// (invocationNum == 0). Subsequent PreInvocations within the same +// conversation return nil. +// +// Background: agy's PreInvocation fires per *model invocation*, but Entire's +// TurnStart event is designed for per-*user-prompt*. The framework's TurnStart +// handler re-captures pre-prompt state (preUntrackedFiles, attribution +// baseline) on every call. If we emit TurnStart on every PreInvocation, the +// baseline gets clobbered each time — by the time TurnEnd fires at Stop, the +// pre-state reflects the post-tool-use snapshot, and DetectFileChanges sees +// no new files compared to itself ("no files modified during session, +// skipping checkpoint"). Confirmed by agy traces showing two PreInvocations +// per single-prompt conversation. +// +// agy wire format: invocationNum is **0-indexed** (the docs now state this +// explicitly: "the first invocation is 0"). Real captured stdin from +// agy 1.0.0: +// +// PreInvocation #1: {"invocationNum":0,"initialNumSteps":1,...} ← turn start +// PreInvocation #2: {"invocationNum":1,"initialNumSteps":5,...} ← follow-up +// +// (initialNumSteps is not a usable "first?" signal — agy inserts the user +// prompt as a step before the first model call, so it's already 1.) +// +// Resumes (agy --continue / --conversation) start with invocationNum > 0, so +// invocationNum alone can't distinguish them from a mid-turn follow-up. We emit +// a TurnStart with SuppressIfSessionActive for every invocationNum > 0; the cli +// dispatcher fires it only when no active session state exists — so a resumed +// turn (state condensed/idle/absent) is tracked, while a genuine follow-up +// (state active mid-turn) is dropped without clobbering the baseline. +// +// Antigravity has no SessionStart hook surface, so there is no path to display +// a "tracked by entire" banner in the agy UI for v1. AntigravityAgent +// intentionally does not implement HookResponseWriter, matching the +// Cursor/OpenCode/Copilot/Pi pattern of silent session tracking. +func parsePreInvocation(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[InvocationPayload](stdin) + if err != nil { + return nil, err + } + // invocationNum>0 is ambiguous: a mid-turn follow-up model call (must NOT + // re-fire TurnStart, or the pre-prompt baseline is clobbered) OR the first + // call of a resumed conversation (agy --continue / --conversation), which + // starts at invocationNum>0 and MUST be tracked. We can't read session state + // here (agent packages must not import strategy), so emit a conditional + // TurnStart and let the dispatcher fire it only when no active session + // exists. + return &agent.Event{ + Type: agent.TurnStart, + SessionID: raw.ConversationID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + SuppressIfSessionActive: raw.InvocationNum != 0, + }, nil +} + +// parseStop handles the Stop hook. +// +// Returns TurnEnd when fullyIdle=true; returns nil when background tasks are +// still running (fullyIdle=false) so the session isn't finalized prematurely. +// +// We map fullyIdle=true to TurnEnd (not SessionEnd) because the framework's +// TurnEnd handler invokes SaveStep — which increments StepCount, writes a +// checkpoint to the shadow branch, and persists FilesTouched into the per- +// session metadata. Without that, the eventual `git commit` finds no shadow +// branch for the session and the cleanup pass at listAllSessionStates removes +// the state file before any checkpoint is condensed. Mapping to SessionEnd +// would mark the session ENDED but never run SaveStep, leaving files_touched +// in a state that never produces a checkpoint commit. +// +// Antigravity's lifecycle gives us exactly one definite "model loop finished" +// moment (Stop with fullyIdle=true), so it's the right anchor for TurnEnd. +// Multi-turn agy sessions get a single TurnEnd at exit, capturing the entire +// turn's work in one checkpoint — a deliberate trade-off vs the per-prompt +// granularity other agents (Gemini, Claude) achieve via separate BeforeAgent +// /AfterAgent or UserPromptSubmit/Stop hooks. See PrepareTranscript below for +// the asynchronous-transcript handling. +func parseStop(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[StopPayload](stdin) + if err != nil { + return nil, err + } + if !raw.FullyIdle { + return nil, nil //nolint:nilnil // Background tasks running — do not end session yet + } + return &agent.Event{ + Type: agent.TurnEnd, + SessionID: raw.ConversationID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} + +// parsePreToolUse handles the PreToolUse hook → ToolUse for mutating tools. +// Returns nil for non-mutating tools (no lifecycle action needed). +func parsePreToolUse(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[PreToolUsePayload](stdin) + if err != nil { + return nil, err + } + modifiedFiles, newFiles := extractFilesFromToolCall(&raw.ToolCall) + if modifiedFiles == nil && newFiles == nil { + return nil, nil //nolint:nilnil // Non-mutating tool — no lifecycle action + } + return &agent.Event{ + Type: agent.ToolUse, + SessionID: raw.ConversationID, + SessionRef: raw.TranscriptPath, + ModifiedFiles: modifiedFiles, + NewFiles: newFiles, + Timestamp: time.Now(), + }, nil +} + +// resolveAgySymlinks resolves symlinks for an absolute path agy sends so it +// matches the symlink-resolved worktree root the framework uses (e.g. macOS +// /tmp → /private/tmp). Without this, FilterAndNormalizePaths produces a +// "../" relative path and drops the file as "outside repo" — silently +// breaking files_touched capture. +// +// We can't EvalSymlinks the path itself because it may not exist yet +// (write_to_file is creating it). We also can't rely on EvalSymlinks of the +// immediate parent because agy can create files in *new* nested directories +// — EvalSymlinks returns an error for any missing component. So we walk up +// until we find an existing ancestor, resolve symlinks there, and reattach +// the missing tail. Returns the input unchanged if the path isn't absolute +// or no ancestor resolves. +func resolveAgySymlinks(p string) string { + if !filepath.IsAbs(p) { + return p + } + suffix := filepath.Base(p) + dir := filepath.Dir(p) + // Terminates without a depth cap: filepath.Dir is lexical and strictly + // shortens dir on every step until it reaches the root, where Dir(dir) == + // dir. Symlink cycles cannot affect that; EvalSymlinks handles them + // internally (it returns an error past its own hop limit), and the walk + // never follows what it resolved. + for { + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + return filepath.Join(resolved, suffix) + } + parent := filepath.Dir(dir) + if parent == dir { + return p // reached root without finding a resolvable ancestor + } + suffix = filepath.Join(filepath.Base(dir), suffix) + dir = parent + } +} + +// extractFilesFromToolCall inspects the tool call and returns the files it +// will modify or create. Both slices are nil for non-mutating tools. +// +// agy 1.0.0 wire-format quirk: every tool arg value is double-encoded as a +// JSON string containing the actual value. So instead of: +// +// {"TargetFile": "/path/to/file", "Overwrite": true} +// +// the hook actually receives: +// +// {"TargetFile": "\"/path/to/file\"", "Overwrite": "true"} +// +// This is undocumented but consistent. To stay robust against both the +// docs-shape format and the actual agy 1.0.0 format, we parse args into raw +// values and unquote/coerce on the way out. +func extractFilesFromToolCall(tc *ToolCall) (modifiedFiles, newFiles []string) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(tc.Args, &raw); err != nil { + return nil, nil + } + + switch tc.Name { + case "write_to_file": + targetFile := resolveAgySymlinks(decodeAgyString(raw["TargetFile"])) + if targetFile == "" { + return nil, nil + } + if decodeAgyBool(raw["Overwrite"]) { + return []string{targetFile}, nil + } + return nil, []string{targetFile} + + case "replace_file_content", "multi_replace_file_content": + targetFile := resolveAgySymlinks(decodeAgyString(raw["TargetFile"])) + if targetFile == "" { + return nil, nil + } + return []string{targetFile}, nil + + default: + return nil, nil + } +} + +// decodeAgyString handles agy's double-encoded string args. Tries the +// docs-shape format first (a plain JSON string), then falls back to the +// agy-actual format (a JSON string whose content is itself a JSON-encoded +// string). Returns "" when neither form decodes cleanly, which callers that +// only want a path or a name can treat as absent. +func decodeAgyString(raw json.RawMessage) string { + s, _ := decodeAgyStringOK(raw) + return s +} + +// decodeAgyStringOK is decodeAgyString with the decode result reported +// separately, for callers that must tell a legitimately empty string from a +// value that is not a string at all. Collapsing the two corrupts agy's +// double-encoded empty string: it decodes to "", and a caller that reads "" +// as failure falls back to a plain decode of the raw value, yielding the +// two-character literal `""`. +func decodeAgyStringOK(raw json.RawMessage) (string, bool) { + if len(raw) == 0 { + return "", false + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return "", false + } + // agy double-encodes — unwrap once more if the inner content is itself JSON-quoted. + var inner string + if err := json.Unmarshal([]byte(s), &inner); err == nil { + return inner, true + } + return s, true +} + +// decodeAgyBool handles agy's double-encoded bool args. Tries the docs-shape +// format (real JSON boolean) first, then the agy-actual format (string "true" +// or "false"). Returns false for any unrecognized shape. +func decodeAgyBool(raw json.RawMessage) bool { + if len(raw) == 0 { + return false + } + var b bool + if err := json.Unmarshal(raw, &b); err == nil { + return b + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s == "true" + } + return false +} diff --git a/cmd/entire/cli/agent/antigravity/lifecycle_test.go b/cmd/entire/cli/agent/antigravity/lifecycle_test.go new file mode 100644 index 0000000000..eabfb8d88f --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/lifecycle_test.go @@ -0,0 +1,441 @@ +package antigravity + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestHookNames(t *testing.T) { + t.Parallel() + a := &AntigravityAgent{} + names := a.HookNames() + want := []string{ + HookNamePreToolUse, + HookNamePreInvocation, + HookNameStop, + } + if len(names) != len(want) { + t.Fatalf("HookNames() returned %d names, want %d: %v", len(names), len(want), names) + } + for i, n := range want { + if names[i] != n { + t.Errorf("HookNames()[%d] = %q, want %q", i, names[i], n) + } + } +} + +func TestParseHookEvent_PreInvocation_FirstInvocationEmitsTurnStart(t *testing.T) { + t.Parallel() + // Payload values mirror real agy 1.0.0 wire format captured from the agy + // binary: the first PreInvocation of a fresh conversation has + // invocationNum=0 (yes, zero — agy is 0-indexed despite the docs reading + // like 1-based) and initialNumSteps=1 (agy inserts the user prompt as + // step 0 before the first model call fires). See the comment block on + // parsePreInvocation for the captured stdin samples. + payload := InvocationPayload{ + CommonPayload: CommonPayload{ + ConversationID: testConversationID, + TranscriptPath: testTranscriptPath, + }, + InvocationNum: 0, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNamePreInvocation, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev == nil { + t.Fatal("expected non-nil event for invocationNum=0 pre-invocation") + } + if ev.Type != agent.TurnStart { + t.Errorf("Type = %v, want TurnStart", ev.Type) + } + if ev.SessionID != testConversationID { + t.Errorf("SessionID = %q, want %q", ev.SessionID, testConversationID) + } + if ev.SessionRef != testTranscriptPath { + t.Errorf("SessionRef = %q, want %q", ev.SessionRef, testTranscriptPath) + } +} + +// TestParseHookEvent_PreInvocation_FollowUpEmitsConditionalTurnStart verifies +// that agy's per-model-call PreInvocations (invocationNum > 0) emit a TurnStart +// flagged SuppressIfSessionActive rather than nil. +// +// invocationNum>0 is ambiguous: it is EITHER a mid-turn follow-up model call +// (which must NOT re-fire TurnStart — re-capturing pre-prompt state clobbers +// the baseline TurnEnd diffs against, producing "no files modified, skipping +// checkpoint") OR the first call of a resumed conversation (agy --continue / +// --conversation), which starts at invocationNum>0 and MUST be tracked. The +// parser can't tell them apart without session state, so it defers the decision +// to the cli dispatcher via SuppressIfSessionActive: fire only when no active +// session exists. +// +// agy 1.0.0 ships invocationNum **0-indexed**; the fixture +// testdata/hook_stdin_pre_invocation.json carries invocationNum=1 (a follow-up). +func TestParseHookEvent_PreInvocation_FollowUpEmitsConditionalTurnStart(t *testing.T) { + t.Parallel() + data, err := os.ReadFile("testdata/hook_stdin_pre_invocation.json") + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNamePreInvocation, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev == nil || ev.Type != agent.TurnStart { + t.Fatalf("expected a conditional TurnStart for invocationNum>0, got %+v", ev) + } + if !ev.SuppressIfSessionActive { + t.Error("follow-up/resume TurnStart must set SuppressIfSessionActive so the dispatcher gates on session state") + } +} + +func TestParseHookEvent_Stop_FullyIdleTrueEmitsTurnEnd(t *testing.T) { + t.Parallel() + // Stop with fullyIdle=true must emit TurnEnd (not SessionEnd) so the + // framework's TurnEnd handler invokes SaveStep — which increments + // step_count, writes a checkpoint to the shadow branch, and lets the + // eventual `git commit` produce a real checkpoint on entire/checkpoints/v1. + // Emitting SessionEnd here would skip SaveStep entirely, leaving the + // session without a shadow branch and getting it garbage-collected at + // commit time. + data, err := os.ReadFile("testdata/hook_stdin_stop.json") + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNameStop, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev == nil { + t.Fatal("expected non-nil event for stop with fullyIdle=true") + } + if ev.Type != agent.TurnEnd { + t.Errorf("Type = %v, want TurnEnd", ev.Type) + } + if ev.SessionID != testConversationID { + t.Errorf("SessionID = %q, want %q", ev.SessionID, testConversationID) + } +} + +func TestParseHookEvent_Stop_FullyIdleFalseReturnsNil(t *testing.T) { + t.Parallel() + // Synthesize a stop payload with fullyIdle=false + payload := StopPayload{ + CommonPayload: CommonPayload{ + ConversationID: testConversationID, + TranscriptPath: testTranscriptPath, + }, + FullyIdle: false, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNameStop, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev != nil { + t.Errorf("expected nil event for stop with fullyIdle=false, got %+v", ev) + } +} + +func TestParseHookEvent_PreToolUse_WriteToFileExtractsModifiedFiles(t *testing.T) { + t.Parallel() + // Synthesize a PreToolUse payload with write_to_file (Overwrite=true → ModifiedFiles) + type writeArgs struct { + TargetFile string `json:"TargetFile"` + Overwrite bool `json:"Overwrite"` + } + argsJSON, err := json.Marshal(writeArgs{TargetFile: "src/main.go", Overwrite: true}) + if err != nil { + t.Fatal(err) + } + payload := PreToolUsePayload{ + CommonPayload: CommonPayload{ + ConversationID: testConversationID, + TranscriptPath: testTranscriptPath, + }, + ToolCall: ToolCall{ + Name: "write_to_file", + Args: json.RawMessage(argsJSON), + }, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNamePreToolUse, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev == nil { + t.Fatal("expected non-nil event for write_to_file tool") + } + if ev.Type != agent.ToolUse { + t.Errorf("Type = %v, want ToolUse", ev.Type) + } + if len(ev.ModifiedFiles) != 1 || ev.ModifiedFiles[0] != "src/main.go" { + t.Errorf("ModifiedFiles = %v, want [src/main.go]", ev.ModifiedFiles) + } + if len(ev.NewFiles) != 0 { + t.Errorf("NewFiles = %v, want empty (Overwrite=true → ModifiedFiles)", ev.NewFiles) + } +} + +func TestParseHookEvent_PreToolUse_WriteToFileNewFile(t *testing.T) { + t.Parallel() + // Overwrite=false → NewFiles + type writeArgs struct { + TargetFile string `json:"TargetFile"` + Overwrite bool `json:"Overwrite"` + } + argsJSON, err := json.Marshal(writeArgs{TargetFile: "src/new.go", Overwrite: false}) + if err != nil { + t.Fatal(err) + } + payload := PreToolUsePayload{ + CommonPayload: CommonPayload{ + ConversationID: testConversationID, + TranscriptPath: testTranscriptPath, + }, + ToolCall: ToolCall{ + Name: "write_to_file", + Args: json.RawMessage(argsJSON), + }, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNamePreToolUse, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev == nil { + t.Fatal("expected non-nil event for write_to_file (new file)") + } + if ev.Type != agent.ToolUse { + t.Errorf("Type = %v, want ToolUse", ev.Type) + } + if len(ev.NewFiles) != 1 || ev.NewFiles[0] != "src/new.go" { + t.Errorf("NewFiles = %v, want [src/new.go]", ev.NewFiles) + } + if len(ev.ModifiedFiles) != 0 { + t.Errorf("ModifiedFiles = %v, want empty (Overwrite=false → NewFiles)", ev.ModifiedFiles) + } +} + +func TestParseHookEvent_PreToolUse_ReplaceFileContent(t *testing.T) { + t.Parallel() + type replaceArgs struct { + TargetFile string `json:"TargetFile"` + } + argsJSON, err := json.Marshal(replaceArgs{TargetFile: "src/foo.go"}) + if err != nil { + t.Fatal(err) + } + payload := PreToolUsePayload{ + CommonPayload: CommonPayload{ + ConversationID: testConversationID, + TranscriptPath: testTranscriptPath, + }, + ToolCall: ToolCall{Name: "replace_file_content", Args: json.RawMessage(argsJSON)}, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNamePreToolUse, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev == nil { + t.Fatal("expected non-nil event for replace_file_content") + } + if ev.Type != agent.ToolUse { + t.Errorf("Type = %v, want ToolUse", ev.Type) + } + if len(ev.ModifiedFiles) != 1 || ev.ModifiedFiles[0] != "src/foo.go" { + t.Errorf("ModifiedFiles = %v, want [src/foo.go]", ev.ModifiedFiles) + } +} + +func TestParseHookEvent_PreToolUse_NonMutatingToolReturnsNil(t *testing.T) { + t.Parallel() + // Use the testdata fixture which uses run_command (non-mutating) + data, err := os.ReadFile("testdata/hook_stdin_pre_tool_use.json") + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNamePreToolUse, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev != nil { + t.Errorf("expected nil event for non-mutating tool run_command, got %+v", ev) + } +} + +// TestParseHookEvent_PreToolUse_AgyDoubleEncodedArgs verifies the file +// extraction tolerates agy 1.0.0's quirky wire format where every tool arg +// value is itself a JSON-encoded string. Without this resilience, the +// json.Unmarshal of the args struct fails silently on the Overwrite type +// mismatch (string "true" vs Go bool), TargetFile stays empty, and no +// ToolUse event is emitted — meaning no files_touched gets recorded and +// no checkpoint is created when the user commits. This is the bug that +// agy smoke testing surfaced. +// TestResolveAgySymlinks_ParentSymlink verifies the symlink-resolution helper +// handles macOS-style /tmp → /private/tmp parent-dir symlinks. This is the +// exact bug that broke files_touched capture on macOS: agy sends paths under +// /tmp/foo/bar.md, but paths.WorktreeRoot returns /private/tmp/foo, and the +// framework's filepath.Rel against the unresolved path yields ../../tmp/... +// which gets filtered as "outside repo". +func TestResolveAgySymlinks_ParentSymlink(t *testing.T) { + t.Parallel() + // Set up a directory and a symlink that points to it. + realDir := t.TempDir() + linkDir := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(realDir, linkDir); err != nil { + t.Fatalf("create symlink: %v", err) + } + // File doesn't need to exist (write_to_file is creating it). + through := filepath.Join(linkDir, "new.txt") + resolved := resolveAgySymlinks(through) + // On macOS, t.TempDir() returns /var/folders/... which itself is a symlink + // to /private/var/folders/.... EvalSymlinks will resolve both layers, so + // the want value must also be resolved for a fair comparison. + wantParent, err := filepath.EvalSymlinks(realDir) + if err != nil { + t.Fatalf("EvalSymlinks(realDir): %v", err) + } + want := filepath.Join(wantParent, "new.txt") + if resolved != want { + t.Errorf("resolveAgySymlinks(%q) = %q, want %q", through, resolved, want) + } +} + +func TestResolveAgySymlinks_RelativePathUnchanged(t *testing.T) { + t.Parallel() + if got := resolveAgySymlinks("foo/bar.txt"); got != "foo/bar.txt" { + t.Errorf("relative path should pass through unchanged, got %q", got) + } +} + +// TestResolveAgySymlinks_NewNestedDirectory verifies the symlink resolver +// walks up to the deepest existing ancestor when agy's write_to_file is +// creating both a new directory AND a file inside it. The original +// implementation only EvalSymlinks'd the immediate parent and failed +// (lstat: no such file or directory), silently returning the unresolved +// path — which would then be filtered as "outside repo" on macOS due to +// the /tmp → /private/tmp symlink. +func TestResolveAgySymlinks_NewNestedDirectory(t *testing.T) { + t.Parallel() + realDir := t.TempDir() + linkDir := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(realDir, linkDir); err != nil { + t.Fatalf("create symlink: %v", err) + } + // Path: symlinked-dir// + // Both newdir and file.txt do not exist; resolver must walk up to + // linkDir, resolve the symlink, then reattach newdir/file.txt. + through := filepath.Join(linkDir, "newdir", "file.txt") + resolved := resolveAgySymlinks(through) + + wantParent, err := filepath.EvalSymlinks(realDir) + if err != nil { + t.Fatalf("EvalSymlinks(realDir): %v", err) + } + want := filepath.Join(wantParent, "newdir", "file.txt") + if resolved != want { + t.Errorf("resolveAgySymlinks(%q) = %q, want %q", through, resolved, want) + } +} + +// TestResolveAgySymlinks_NoExistingAncestor verifies the resolver returns +// the input unchanged when no ancestor of the path exists at all (root is +// reached without finding a resolvable directory). This is the only path +// where the function gives up; the test pins that behavior so we don't +// accidentally return "" or a partially-resolved bogus path. +func TestResolveAgySymlinks_NoExistingAncestor(t *testing.T) { + t.Parallel() + // /// — extremely unlikely to exist. + p := "/nonexistent-prefix-" + filepath.Base(t.TempDir()) + "/a/b/c.txt" + if got := resolveAgySymlinks(p); got != p { + t.Errorf("expected unchanged input %q, got %q", p, got) + } +} + +func TestParseHookEvent_PreToolUse_AgyDoubleEncodedArgs(t *testing.T) { + t.Parallel() + // Reproduce agy's actual wire format exactly: + // "TargetFile": "\"/tmp/hello.txt\"", ← string-containing-JSON-string + // "Overwrite": "true", ← string instead of bool + argsRaw := []byte(`{"TargetFile":"\"hello.txt\"","Overwrite":"true","CodeContent":"\"hi\""}`) + payload := PreToolUsePayload{ + CommonPayload: CommonPayload{ + ConversationID: testConversationID, + TranscriptPath: testTranscriptPath, + }, + ToolCall: ToolCall{Name: "write_to_file", Args: json.RawMessage(argsRaw)}, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNamePreToolUse, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev == nil { + t.Fatal("expected non-nil event for write_to_file with agy-double-encoded args; got nil — file extraction silently failed") + } + if ev.Type != agent.ToolUse { + t.Errorf("Type = %v, want ToolUse", ev.Type) + } + // Overwrite=true (as string) → file goes into ModifiedFiles, not NewFiles + if len(ev.ModifiedFiles) != 1 || ev.ModifiedFiles[0] != "hello.txt" { + t.Errorf("ModifiedFiles = %v, want [hello.txt]", ev.ModifiedFiles) + } + if len(ev.NewFiles) != 0 { + t.Errorf("NewFiles = %v, want empty (Overwrite=true)", ev.NewFiles) + } +} + +// TestParseHookEvent_UnknownHookReturnsNil pins the default case: hook names +// we don't handle (including agy's PostToolUse/PostInvocation, which are no +// longer installed) parse to a nil event rather than an error, so a stale +// hooks.json entry can never fail an agy turn. +func TestParseHookEvent_UnknownHookReturnsNil(t *testing.T) { + t.Parallel() + a := &AntigravityAgent{} + for _, name := range []string{"post-tool-use", "post-invocation", "does-not-exist"} { + ev, err := a.ParseHookEvent(context.Background(), name, bytes.NewReader([]byte(`{}`))) + if err != nil { + t.Fatalf("ParseHookEvent(%q): %v", name, err) + } + if ev != nil { + t.Errorf("expected nil event for unhandled hook %q, got %+v", name, ev) + } + } +} diff --git a/cmd/entire/cli/agent/antigravity/statusline.go b/cmd/entire/cli/agent/antigravity/statusline.go new file mode 100644 index 0000000000..8b01516f9b --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/statusline.go @@ -0,0 +1,619 @@ +package antigravity + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/internal/flock" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/osroot" + "github.com/entireio/cli/internal/entireclient/userdirs" +) + +// agy's title/statusline hook pipes a state JSON to the configured command on +// every agent state change. The context_window object is the ONLY surface +// where agy exposes token usage — it never appears in transcripts or +// lifecycle hook payloads. AppendStatusSnapshot persists those snapshots so +// the lifecycle can compute per-checkpoint deltas later. +// +// Totals are cumulative per conversation; current_usage is the latest API call. + +// statusDirEnv overrides the snapshot cache directory (tests, ops). +const statusDirEnv = "ENTIRE_ANTIGRAVITY_STATUS_DIR" + +// statusRetention is how long snapshot files for other conversations are kept. +const statusRetention = 14 * 24 * time.Hour + +// statusLockSuffix is appended to a conversation's snapshot file name to form +// the advisory lock file AppendStatusSnapshot serialises on. +const statusLockSuffix = ".lock" + +// statusCurrentUsage mirrors context_window.current_usage in the payload. +type statusCurrentUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` +} + +// statusContextWindow mirrors context_window in the payload. +type statusContextWindow struct { + TotalInputTokens int `json:"total_input_tokens"` + TotalOutputTokens int `json:"total_output_tokens"` + ContextWindowSize int `json:"context_window_size,omitempty"` + CurrentUsage *statusCurrentUsage `json:"current_usage,omitempty"` +} + +// statusSnapshot is one persisted line in .jsonl. +type statusSnapshot struct { + Timestamp string `json:"ts"` + ConversationID string `json:"conversation_id"` + ContextWindow statusContextWindow `json:"context_window"` +} + +// statuslinePayload is the subset of agy's state JSON we consume. +type statuslinePayload struct { + ConversationID string `json:"conversation_id"` + ContextWindow *statusContextWindow `json:"context_window"` +} + +// statusStore is where snapshot files live: a shared *os.Root anchor plus the +// directory name inside it (docs/development/filesystem-safety.md, "The Root +// Anchors"). Every read, append, lock and prune below is a NAME inside this +// root, so a symlink planted at any component is refused instead of followed. +type statusStore struct { + root *os.Root + // dir is the directory inside root holding the JSONL files. "" means the + // root itself is the directory (the override case). + dir string +} + +// statusDefaultDir is the store's location inside the per-user cache root. +// Slash-separated on every platform: names inside an os.Root are slash paths +// by contract, and osroot splits them on "/" only — a filepath.Join here made +// "antigravity\status" a single component on Windows, so the store was never +// created and no snapshot was ever persisted there (trail 444). +const statusDefaultDir = "antigravity/status" + +// openStatusStore resolves the store. It honours the ENTIRE_ANTIGRAVITY_STATUS_DIR +// env override (tests, ops), otherwise anchors on userdirs.CacheRoot. userdirs is +// the mandated resolver: it honours $XDG_CACHE_HOME on every platform +// (os.UserCacheDir ignores it on darwin, defeating harness isolation), falls back +// to a throwaway per-process dir under `go test`, and refuses a relative +// override before anything is created. The override is held to the same rule +// (RequireAbsoluteOverride) and opened through the shared registry like every +// other anchor, never as filepath.Dir of the file about to be written. +func openStatusStore() (statusStore, error) { + if override := os.Getenv(statusDirEnv); override != "" { + if err := userdirs.RequireAbsoluteOverride(statusDirEnv, override); err != nil { + return statusStore{}, fmt.Errorf("antigravity status: %w", err) + } + if err := userdirs.EnsurePrivateDir(override); err != nil { + return statusStore{}, fmt.Errorf("antigravity status: %w", err) + } + root, err := osroot.Shared(override) + if err != nil { + return statusStore{}, fmt.Errorf("antigravity status: open %s: %w", statusDirEnv, err) + } + return statusStore{root: root}, nil + } + root, err := userdirs.CacheRoot() + if err != nil { + return statusStore{}, fmt.Errorf("antigravity status: resolve cache dir: %w", err) + } + return statusStore{root: root, dir: statusDefaultDir}, nil +} + +// dirName is the store directory as a name inside the root ("." for the root). +func (st statusStore) dirName() string { + if st.dir == "" { + return "." + } + return st.dir +} + +// fileName returns the slash-separated name inside the root of a +// conversation's JSONL file. filepath.Base guards against path traversal in +// the conversation ID (it strips either separator on Windows). +func (st statusStore) fileName(conversationID string) string { + return path.Join(st.dir, filepath.Base(conversationID)+".jsonl") +} + +// statusFilePath returns the absolute path of a conversation's snapshot file. +// Diagnostics and tests only: production I/O goes through the root by name. +func statusFilePath(conversationID string) (string, error) { + st, err := openStatusStore() + if err != nil { + return "", err + } + return filepath.Join(st.root.Name(), filepath.FromSlash(st.fileName(conversationID))), nil +} + +// AppendStatusSnapshot parses an agy state-JSON payload and appends a snapshot +// to the per-conversation JSONL file. The hot path never returns an error for +// malformed input — only for genuine I/O failures. +// +// agy fires the title command on every agent state change and does not +// serialize the invocations, so two tees can run at once. The dedup read and +// the append therefore happen under one advisory lock per conversation +// (.jsonl.lock, next to the file); without it a concurrent tee could append +// between the read and the write and the dedup would miss. +func AppendStatusSnapshot(payload []byte) error { + var p statuslinePayload + if err := json.Unmarshal(payload, &p); err != nil { + return nil + } + if p.ConversationID == "" || p.ContextWindow == nil { + return nil // missing required fields — silently skip + } + + // Dedup: compare compact JSON of the new context_window against the last + // persisted line's context_window. + newCWBytes, err := json.Marshal(p.ContextWindow) + if err != nil { + return nil + } + + st, err := openStatusStore() + if err != nil { + return err + } + if st.dir != "" { + if err := osroot.MkdirAllNoSymlink(st.root, st.dir, 0o750); err != nil { + return fmt.Errorf("antigravity status: mkdir: %w", err) + } + } + name := st.fileName(p.ConversationID) + + release, err := lockStatusFile(st, name) + if err != nil { + return err + } + defer release() + + f, err := osroot.OpenFileNoFollow(st.root, name, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0o600) + if err != nil { + return fmt.Errorf("antigravity status: open: %w", err) + } + defer func() { _ = f.Close() }() + + info, err := f.Stat() + if err != nil { + return fmt.Errorf("antigravity status: stat: %w", err) + } + isNew := info.Size() == 0 + if !isNew { + lastSnap, readErr := readLastSnapshotFrom(f) + if readErr == nil && lastSnap != nil { + lastCWBytes, marshalErr := json.Marshal(lastSnap.ContextWindow) + if marshalErr == nil && bytes.Equal(newCWBytes, lastCWBytes) { + return nil // duplicate — skip + } + } + } + + snap := statusSnapshot{ + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + ConversationID: p.ConversationID, + ContextWindow: *p.ContextWindow, + } + line, err := json.Marshal(snap) + if err != nil { + return nil + } + line = append(line, '\n') + if _, err := f.Write(line); err != nil { + return fmt.Errorf("antigravity status: write: %w", err) + } + + // Best-effort prune of stale files for other conversations when we first + // create the active file (avoids per-append overhead). + if isNew { + pruneStaleStatusFiles(st, p.ConversationID) + } + + return nil +} + +// SnapshotTokenBaseline returns the latest persisted snapshot for the +// conversation, or nil if none exists yet. A nil baseline is exact only for a +// genuinely fresh conversation; a resumed conversation whose title-tee shim +// hasn't written a snapshot before the first TurnStart will over-count the +// prior cumulative total on that first tracked turn. +func (a *AntigravityAgent) SnapshotTokenBaseline(ctx context.Context, sessionID string) (json.RawMessage, error) { + st, err := openStatusStore() + if err != nil { + return nil, nil //nolint:nilerr // ditto: an unusable status dir means no baseline + } + snap, err := readLastStatusSnapshot(ctx, st, st.fileName(sessionID)) + if err != nil || snap == nil { + return nil, nil //nolint:nilerr // ditto (missing file, no lines, malformed) + } + raw, err := json.Marshal(snap) + if err != nil { + return nil, nil //nolint:nilerr // ditto + } + return raw, nil +} + +// CalculateTokenUsageSince computes the delta between the baseline snapshot +// and the latest persisted snapshot. +// +// Exact: InputTokens/OutputTokens (cumulative totals minus baseline totals). +// Best-effort: cache fields and APICallCount, derived from the snapshot lines +// appended after the baseline timestamp (the dedup writer appends ~one line +// per API response, but lines can be missed between agent state changes). +func (a *AntigravityAgent) CalculateTokenUsageSince(ctx context.Context, sessionID string, baseline json.RawMessage) (*agent.TokenUsage, error) { + snaps, err := readStatusSnapshots(ctx, sessionID) + if err != nil || len(snaps) == 0 { + return nil, nil //nolint:nilerr,nilnil // no data -> no token counts, never an error + } + + var base statusSnapshot + if len(baseline) > 0 { + _ = json.Unmarshal(baseline, &base) //nolint:errcheck // unparseable baseline -> zero baseline + } + + latest := snaps[len(snaps)-1] + usage := &agent.TokenUsage{ + InputTokens: max(0, latest.ContextWindow.TotalInputTokens-base.ContextWindow.TotalInputTokens), + OutputTokens: max(0, latest.ContextWindow.TotalOutputTokens-base.ContextWindow.TotalOutputTokens), + } + + // The strictly-after (.After, not >=) filter is load-bearing for + // multi-turn correctness: turn N+1's baseline IS turn N's latest snapshot, + // so excluding the equal-timestamp boundary line prevents re-counting it. + // Changing this to >= would double-count the boundary line every turn. + baseTS, baseTSErr := time.Parse(time.RFC3339Nano, base.Timestamp) + for _, s := range snaps { + // If baseTS is unparseable we count cache/apicalls over all lines; accepted because input/output remain exact via the totals delta. + if base.Timestamp != "" && baseTSErr == nil { + ts, parseErr := time.Parse(time.RFC3339Nano, s.Timestamp) + if parseErr != nil || !ts.After(baseTS) { + continue + } + } + usage.APICallCount++ + if cu := s.ContextWindow.CurrentUsage; cu != nil { + usage.CacheCreationTokens += cu.CacheCreationInputTokens + usage.CacheReadTokens += cu.CacheReadInputTokens + } + } + + if usage.InputTokens == 0 && usage.OutputTokens == 0 && usage.CacheCreationTokens == 0 && usage.CacheReadTokens == 0 { + return nil, nil //nolint:nilnil // nothing observed this turn + } + return usage, nil +} + +// statusTailWindow bounds how many bytes readLastSnapshotFrom reads from the +// end of the file. Snapshot lines are well under 1 KB, so 64 KB always covers +// the final line with huge margin. +const statusTailWindow = 64 * 1024 + +// snapshotFileExists reports whether a conversation's snapshot file is present +// in the store, without following a symlink at any component. It is checked +// BEFORE a reader takes the conversation lock: flock.AcquireIn creates the lock +// file it opens, so a baseline read at every TurnStart on a conversation that +// never produces a snapshot — or whose snapshot was already pruned — would +// otherwise leave an orphan .jsonl.lock behind for the whole retention +// window (the prune cannot tell such an orphan from a lock a tee has just taken +// while creating its file). The check-then-lock gap is deliberate and cheap: a +// tee creating the file in between costs one turn's baseline, which lands in +// the same "no snapshot yet" degradation these readers already document. +func snapshotFileExists(st statusStore, name string) (bool, error) { + if _, err := osroot.LstatNoSymlinks(st.root, name); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("antigravity status: stat: %w", err) + } + return true, nil +} + +// readLastStatusSnapshot opens name inside the store and returns its final +// snapshot; a missing file is nil, nil. +func readLastStatusSnapshot(ctx context.Context, st statusStore, name string) (*statusSnapshot, error) { + if exists, err := snapshotFileExists(st, name); err != nil { + return nil, err + } else if !exists { + return nil, nil //nolint:nilnil // no snapshot yet — and no lock file left behind for it + } + release, err := lockStatusFileForRead(ctx, st, name) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil //nolint:nilnil // no status directory yet means no snapshots yet + } + return nil, err + } + defer release() + f, err := osroot.OpenNoFollow(st.root, name) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil //nolint:nilnil // a missing file is "no snapshots yet", not an error + } + return nil, fmt.Errorf("antigravity status: open: %w", err) + } + defer func() { _ = f.Close() }() + return readLastSnapshotFrom(f) +} + +// lockStatusFile takes the per-conversation advisory lock that +// AppendStatusSnapshot writes under. The readers take it too: agy does not +// serialise its title-command invocations, so a baseline or delta read that +// ran unlocked could observe the last line half-written by a concurrent tee +// and treat the torn JSON as "no snapshot" — a silently dropped token +// baseline for that turn. The lock file is created on first use; a status +// directory that does not exist yet is reported through fs.ErrNotExist. +// +// The writer waits without bound: it IS the tee, its critical section is one +// read and one append, and serialising the appends is the whole point. +func lockStatusFile(st statusStore, name string) (release func(), err error) { + release, err = flock.AcquireIn(st.root, name+statusLockSuffix) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, err //nolint:wrapcheck // preserved so callers can read it as "no snapshots yet" + } + return nil, fmt.Errorf("antigravity status: lock: %w", err) + } + return release, nil +} + +// pruneStaleSnapshot removes one stale conversation's snapshot file, holding +// that conversation's lock if it has one. Reports whether the file is gone. +// +// A lock file beside the snapshot means some tee has been mid-append for this +// conversation, so take that lock — non-blocking — before unlinking, the way +// the orphan-lock loop is careful about its own: a dormant conversation +// resumed between the caller's stat and this unlink is mid-append, and pulling +// the file from under its open fd loses the per-line detail between the +// baseline and now. A held lock means it is alive right now, which is reason +// enough to leave it for the next prune. +// +// No lock file means no tee ever was, because AppendStatusSnapshot takes the +// lock BEFORE creating the .jsonl — the ordering the orphan-lock loop already +// relies on. So absence is evidence here, and the file is unlinked directly. +// The try-lock is not a free probe: flock opens with O_CREATE|O_EXCL, so +// asking for a lock that does not exist CREATES one, and it would be left +// behind for a conversation that is being deleted — an orphan the loop below +// cannot collect this pass (it walks an entries snapshot taken before the lock +// existed) and will not collect on the next one until it has aged past the +// cutoff itself. +func pruneStaleSnapshot(st statusStore, name string) bool { + target := path.Join(st.dir, name) + hasLock, err := snapshotFileExists(st, target+statusLockSuffix) + if err != nil { + return false + } + if hasLock { + release, locked := tryLockStatusFile(st, target) + if !locked { + return false + } + defer release() + } + return osroot.RemoveNoSymlinks(st.root, target) == nil +} + +// tryLockStatusFile takes a conversation's lock only if it is free right now. +// An already-expired deadline selects flock's non-blocking path: one LOCK_NB +// attempt, then the context error rather than a wait. locked is false when +// another process holds it, and for a lock file that cannot be created — +// both mean "do not touch this conversation's snapshots". +func tryLockStatusFile(st statusStore, name string) (release func(), locked bool) { + ctx, cancel := context.WithTimeout(context.Background(), 0) + defer cancel() + release, err := flock.AcquireContextIn(ctx, st.root, name+statusLockSuffix) + if err != nil { + return nil, false + } + return release, true +} + +// statusReadLockTimeout bounds how long a reader waits for the conversation +// lock. A variable so tests can shorten it. +var statusReadLockTimeout = 2 * time.Second + +// lockStatusFileForRead is lockStatusFile for the readers, which run inside +// agy's hooks (SnapshotTokenBaseline at PreInvocation, CalculateTokenUsageSince +// at Stop). A hook must not stall behind a tee that hangs while holding the +// lock — the same rule the turn-start session-state lock follows — so the wait +// is bounded, and on timeout the read proceeds unlocked, which is exactly the +// pre-lock behaviour: at worst a torn last line reads as "no snapshot" for one +// turn, against a hook that never returns. The returned release is always +// safe to call. +func lockStatusFileForRead(ctx context.Context, st statusStore, name string) (release func(), err error) { + acqCtx, cancel := context.WithTimeout(ctx, statusReadLockTimeout) + defer cancel() + release, err = flock.AcquireContextIn(acqCtx, st.root, name+statusLockSuffix) + if err == nil { + return release, nil + } + if errors.Is(err, fs.ErrNotExist) { + return nil, err //nolint:wrapcheck // preserved so callers can read it as "no snapshots yet" + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + logging.Debug(logging.WithComponent(ctx, "antigravity"), + "status lock busy; reading token snapshots unlocked", + slog.String("file", name)) + return func() {}, nil + } + return nil, fmt.Errorf("antigravity status: lock: %w", err) +} + +// readLastSnapshotFrom returns the snapshot on the final non-empty line of f, +// or nil if the file has no usable line. It reads a bounded tail window instead +// of streaming the whole file: it is shared by the per-fire dedup comparison +// in AppendStatusSnapshot (on the already-open, locked descriptor) and by +// every-TurnStart SnapshotTokenBaseline, and agy fires the title command on +// each agent state change — a front-to-back scan would cost O(file) per fire, +// O(n^2) over a conversation. +func readLastSnapshotFrom(f *os.File) (*statusSnapshot, error) { + info, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("antigravity status: stat: %w", err) + } + + offset := info.Size() - statusTailWindow + if offset < 0 { + offset = 0 + } + buf := make([]byte, info.Size()-offset) + if _, err := f.ReadAt(buf, offset); err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("antigravity status: read tail: %w", err) + } + + // When the window starts mid-file, the first chunk may be a partial line — + // discard through the first newline so only whole lines are considered. + if offset > 0 { + nl := bytes.IndexByte(buf, '\n') + if nl < 0 { + return nil, nil //nolint:nilnil // single line larger than the window — treat as no usable snapshot + } + buf = buf[nl+1:] + } + + var lastLine []byte + for _, line := range bytes.Split(buf, []byte("\n")) { + if line = bytes.TrimSpace(line); len(line) > 0 { + lastLine = line + } + } + if len(lastLine) == 0 { + return nil, nil //nolint:nilnil // no lines yet — caller handles nil gracefully + } + + var snap statusSnapshot + if err := json.Unmarshal(lastLine, &snap); err != nil { + return nil, nil //nolint:nilerr,nilnil // malformed last line — treat as no prior snapshot + } + return &snap, nil +} + +// readStatusSnapshots reads all valid snapshot lines from the JSONL file for +// the given conversationID. A missing file returns nil, nil (not an error). +func readStatusSnapshots(ctx context.Context, conversationID string) ([]statusSnapshot, error) { + st, err := openStatusStore() + if err != nil { + return nil, err + } + name := st.fileName(conversationID) + if exists, err := snapshotFileExists(st, name); err != nil { + return nil, err + } else if !exists { + return nil, nil // no snapshot yet — and no lock file left behind for it + } + release, err := lockStatusFileForRead(ctx, st, name) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, err + } + defer release() + f, err := osroot.OpenNoFollow(st.root, name) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("antigravity status: open for read: %w", err) + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + + var snaps []statusSnapshot + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + var snap statusSnapshot + if err := json.Unmarshal([]byte(line), &snap); err != nil { + continue // skip malformed lines + } + snaps = append(snaps, snap) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("antigravity status: scan: %w", err) + } + return snaps, nil +} + +// pruneStaleStatusFiles removes other conversations' snapshot files that have +// not been written to within statusRetention, and lock files that are both +// orphaned (no snapshot file beside them) and older than the same cutoff. +// +// A lock file is never pruned while its snapshot file exists: flock does not +// touch mtime, so a lock file is as old as the conversation's first snapshot, +// and a conversation still running past the retention window would otherwise +// have its lock unlinked from under the tee holding it. The next tee would +// create a fresh lock file, lock a different inode, and the dedup read/append +// AppendStatusSnapshot serialises with that lock would race again. +// +// Nor is an orphan pruned on sight: AppendStatusSnapshot takes the lock BEFORE +// it creates the snapshot file, so a prune running from another conversation's +// first append can observe a lock file whose .jsonl does not exist yet. That +// lock is milliseconds old; requiring it to be older than the cutoff leaves it +// alone, while a lock left behind by an earlier prune of its snapshot file is +// at least as old as that file's last write and goes in the same pass. +func pruneStaleStatusFiles(st statusStore, activeConversationID string) { + activePrefix := filepath.Base(activeConversationID) + ".jsonl" + entries, err := osroot.ReadDirNoSymlinks(st.root, st.dirName()) + if err != nil { + return + } + cutoff := time.Now().Add(-statusRetention) + present := make(map[string]bool, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + present[entry.Name()] = true + } + } + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || strings.HasPrefix(name, activePrefix) || strings.HasSuffix(name, statusLockSuffix) { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + if pruneStaleSnapshot(st, name) { + delete(present, name) + } + } + } + // A lock file whose snapshot file is gone (pruned above, or by an earlier + // run) guards nothing any more — once it is old enough that it cannot be a + // lock another tee is holding while it creates its snapshot file. + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, statusLockSuffix) || strings.HasPrefix(name, activePrefix) { + continue + } + if present[strings.TrimSuffix(name, statusLockSuffix)] { + continue + } + info, err := entry.Info() + if err != nil || !info.ModTime().Before(cutoff) { + continue + } + _ = osroot.RemoveNoSymlinks(st.root, path.Join(st.dir, name)) //nolint:errcheck // best-effort prune + } +} diff --git a/cmd/entire/cli/agent/antigravity/statusline_test.go b/cmd/entire/cli/agent/antigravity/statusline_test.go new file mode 100644 index 0000000000..ecac4825f2 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/statusline_test.go @@ -0,0 +1,969 @@ +package antigravity + +import ( + "context" + "encoding/json" + "errors" + "os" + "path" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// Note: these tests use t.Setenv and/or t.Chdir, so t.Parallel() is not called. + +func TestAppendStatusSnapshot_WritesLineKeyedByConversation(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + payload := []byte(`{"conversation_id":"conv-1","agent_state":"working","context_window":{"total_input_tokens":1000,"total_output_tokens":50,"context_window_size":200000,"current_usage":{"input_tokens":900,"output_tokens":50,"cache_creation_input_tokens":100,"cache_read_input_tokens":800}}}`) + + if err := AppendStatusSnapshot(payload); err != nil { + t.Fatalf("AppendStatusSnapshot: %v", err) + } + + snaps, err := readStatusSnapshots(context.Background(), "conv-1") + if err != nil { + t.Fatalf("readStatusSnapshots: %v", err) + } + if len(snaps) != 1 { + t.Fatalf("got %d snapshots, want 1", len(snaps)) + } + + s := snaps[0] + if s.ContextWindow.TotalInputTokens != 1000 { + t.Errorf("TotalInputTokens = %d, want 1000", s.ContextWindow.TotalInputTokens) + } + if s.ContextWindow.TotalOutputTokens != 50 { + t.Errorf("TotalOutputTokens = %d, want 50", s.ContextWindow.TotalOutputTokens) + } + if s.ContextWindow.CurrentUsage == nil { + t.Fatal("CurrentUsage is nil") + } + if s.ContextWindow.CurrentUsage.CacheReadInputTokens != 800 { + t.Errorf("CacheReadInputTokens = %d, want 800", s.ContextWindow.CurrentUsage.CacheReadInputTokens) + } + if s.Timestamp == "" { + t.Error("Timestamp is empty") + } +} + +func TestAppendStatusSnapshot_DedupsUnchangedContextWindow(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + // First payload + p1 := []byte(`{"conversation_id":"conv-2","agent_state":"working","context_window":{"total_input_tokens":1000,"total_output_tokens":50}}`) + // Second payload: same context_window, different agent_state + p2 := []byte(`{"conversation_id":"conv-2","agent_state":"idle","context_window":{"total_input_tokens":1000,"total_output_tokens":50}}`) + // Third payload: different context_window + p3 := []byte(`{"conversation_id":"conv-2","agent_state":"working","context_window":{"total_input_tokens":2000,"total_output_tokens":100}}`) + + for _, p := range [][]byte{p1, p2, p3} { + if err := AppendStatusSnapshot(p); err != nil { + t.Fatalf("AppendStatusSnapshot: %v", err) + } + } + + snaps, err := readStatusSnapshots(context.Background(), "conv-2") + if err != nil { + t.Fatalf("readStatusSnapshots: %v", err) + } + if len(snaps) != 2 { + t.Errorf("got %d snapshots, want 2 (dedup should have skipped p2)", len(snaps)) + } +} + +func TestAppendStatusSnapshot_IgnoresGarbageAndMissingFields(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + for _, payload := range []string{"not json", "{}", `{"conversation_id":"conv-3"}`} { + if err := AppendStatusSnapshot([]byte(payload)); err != nil { + t.Errorf("AppendStatusSnapshot(%q) returned error: %v", payload, err) + } + } + + // dir must be empty (no snapshot files written) + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + if len(entries) != 0 { + t.Errorf("expected empty dir, got %d entries", len(entries)) + } +} + +func TestReadStatusSnapshots_SkipsMalformedLines(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + // Write a file manually with valid / garbage / valid lines + validLine1, err := json.Marshal(statusSnapshot{ + Timestamp: "2026-01-01T00:00:00Z", + ConversationID: "conv-4", + ContextWindow: statusContextWindow{TotalInputTokens: 10, TotalOutputTokens: 1}, + }) + if err != nil { + t.Fatal(err) + } + validLine2, err := json.Marshal(statusSnapshot{ + Timestamp: "2026-01-01T00:01:00Z", + ConversationID: "conv-4", + ContextWindow: statusContextWindow{TotalInputTokens: 20, TotalOutputTokens: 2}, + }) + if err != nil { + t.Fatal(err) + } + + filePath := filepath.Join(dir, "conv-4.jsonl") + content := string(validLine1) + "\nGARBAGE LINE\n" + string(validLine2) + "\n" + if err := os.WriteFile(filePath, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + snaps, err := readStatusSnapshots(context.Background(), "conv-4") + if err != nil { + t.Fatalf("readStatusSnapshots: %v", err) + } + if len(snaps) != 2 { + t.Errorf("got %d snapshots, want 2 (malformed line skipped)", len(snaps)) + } +} + +func TestReadStatusSnapshots_MissingFileReturnsEmpty(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + snaps, err := readStatusSnapshots(context.Background(), "no-such-conv") + if err != nil { + t.Fatalf("readStatusSnapshots: %v", err) + } + if len(snaps) != 0 { + t.Errorf("got %d snapshots, want 0", len(snaps)) + } +} + +func BenchmarkAppendStatusSnapshot_GrownFile(b *testing.B) { + dir := b.TempDir() + b.Setenv(statusDirEnv, dir) + + // Seed 500 distinct snapshots + for i := range 500 { + payload, err := json.Marshal(map[string]any{ + "conversation_id": "bench-conv", + "agent_state": "working", + "context_window": map[string]any{ + "total_input_tokens": 1000 + i, + "total_output_tokens": 50 + i, + }, + }) + if err != nil { + b.Fatal(err) + } + if err := AppendStatusSnapshot(payload); err != nil { + b.Fatalf("seed %d: %v", i, err) + } + } + + // The duplicate payload matches the last seeded snapshot (dedup path) + dupPayload, err := json.Marshal(map[string]any{ + "conversation_id": "bench-conv", + "agent_state": "working", + "context_window": map[string]any{ + "total_input_tokens": 1000 + 499, + "total_output_tokens": 50 + 499, + }, + }) + if err != nil { + b.Fatal(err) + } + + b.ResetTimer() + for range b.N { + if err := AppendStatusSnapshot(dupPayload); err != nil { + b.Fatal(err) + } + } +} + +func TestAppendStatusSnapshot_PrunesStaleFilesOnCreate(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + // Pre-seed a stale file for another conversation (mtime older than retention) + stale := filepath.Join(dir, "old-conv.jsonl") + if err := os.WriteFile(stale, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-statusRetention - time.Hour) + if err := os.Chtimes(stale, old, old); err != nil { + t.Fatal(err) + } + // And a fresh file for a third conversation that must survive + fresh := filepath.Join(dir, "fresh-conv.jsonl") + if err := os.WriteFile(fresh, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + + // First write for a new conversation triggers the prune + payload := []byte(`{"conversation_id":"conv-new","context_window":{"total_input_tokens":1}}`) + if err := AppendStatusSnapshot(payload); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Errorf("stale file should be pruned, stat err = %v", err) + } + if _, err := os.Stat(fresh); err != nil { + t.Errorf("fresh file must survive prune: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "conv-new.jsonl")); err != nil { + t.Errorf("active file must exist: %v", err) + } +} + +// TestAppendStatusSnapshot_PruneKeepsLiveLockFiles: a lock file is as old as +// its conversation's first snapshot (flock never touches mtime), so pruning it +// by age alone would unlink it from under a tee that holds it and let the next +// tee lock a different inode — the dedup race the lock exists to close. A lock +// file is pruned only once its snapshot file is gone AND it is older than the +// retention cutoff: the first rule keeps long-lived conversations' locks, the +// second keeps a lock another tee has just taken but not yet written beside. +func TestAppendStatusSnapshot_PruneKeepsLiveLockFiles(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + old := time.Now().Add(-statusRetention - time.Hour) + + // A long-lived conversation: snapshot file written recently, lock file + // created long ago. + live := filepath.Join(dir, "live-conv.jsonl") + if err := os.WriteFile(live, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + liveLock := live + statusLockSuffix + if err := os.WriteFile(liveLock, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(liveLock, old, old); err != nil { + t.Fatal(err) + } + + // A finished conversation: both files stale. + gone := filepath.Join(dir, "gone-conv.jsonl") + if err := os.WriteFile(gone, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + goneLock := gone + statusLockSuffix + if err := os.WriteFile(goneLock, nil, 0o600); err != nil { + t.Fatal(err) + } + for _, p := range []string{gone, goneLock} { + if err := os.Chtimes(p, old, old); err != nil { + t.Fatal(err) + } + } + + // An orphaned lock file left by an earlier prune run, long ago. + orphanLock := filepath.Join(dir, "orphan-conv.jsonl"+statusLockSuffix) + if err := os.WriteFile(orphanLock, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(orphanLock, old, old); err != nil { + t.Fatal(err) + } + + // Another conversation's first tee, caught between taking its lock and + // creating its snapshot file: a fresh lock with no .jsonl beside it yet. + // Unlinking it here would leave that tee holding a lock on a dead inode. + inFlightLock := filepath.Join(dir, "inflight-conv.jsonl"+statusLockSuffix) + if err := os.WriteFile(inFlightLock, nil, 0o600); err != nil { + t.Fatal(err) + } + + payload := []byte(`{"conversation_id":"conv-new","context_window":{"total_input_tokens":1}}`) + if err := AppendStatusSnapshot(payload); err != nil { + t.Fatal(err) + } + + for _, p := range []string{liveLock, inFlightLock} { + if _, err := os.Stat(p); err != nil { + t.Errorf("%s must survive the prune: %v", filepath.Base(p), err) + } + } + for _, p := range []string{gone, goneLock, orphanLock} { + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Errorf("%s should be pruned, stat err = %v", filepath.Base(p), err) + } + } +} + +// writeSnapshotFixture writes the given snapshots as JSONL to the snapshot file +// for conversationID, using the statusDirEnv override already set by the test. +func writeSnapshotFixture(t *testing.T, conversationID string, snaps []statusSnapshot) { + t.Helper() + path, err := statusFilePath(conversationID) + if err != nil { + t.Fatalf("statusFilePath: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + var buf []byte + for _, s := range snaps { + line, marshalErr := json.Marshal(s) + if marshalErr != nil { + t.Fatalf("marshal snapshot: %v", marshalErr) + } + buf = append(buf, line...) + buf = append(buf, '\n') + } + if err := os.WriteFile(path, buf, 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } +} + +func TestCalculateTokenUsageSince_DeltaFromBaseline(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + snaps := []statusSnapshot{ + { + Timestamp: "2026-06-03T10:00:00.000000000Z", + ConversationID: "c1", + ContextWindow: statusContextWindow{ + TotalInputTokens: 1000, + TotalOutputTokens: 100, + CurrentUsage: &statusCurrentUsage{CacheCreationInputTokens: 200, CacheReadInputTokens: 700}, + }, + }, + { + Timestamp: "2026-06-03T10:05:00.000000000Z", + ConversationID: "c1", + ContextWindow: statusContextWindow{ + TotalInputTokens: 3000, + TotalOutputTokens: 250, + CurrentUsage: &statusCurrentUsage{CacheCreationInputTokens: 50, CacheReadInputTokens: 1900}, + }, + }, + { + Timestamp: "2026-06-03T10:06:00.000000000Z", + ConversationID: "c1", + ContextWindow: statusContextWindow{ + TotalInputTokens: 4500, + TotalOutputTokens: 400, + CurrentUsage: &statusCurrentUsage{CacheCreationInputTokens: 0, CacheReadInputTokens: 1500}, + }, + }, + } + writeSnapshotFixture(t, "c1", snaps) + + baseline, err := json.Marshal(snaps[0]) + if err != nil { + t.Fatalf("marshal baseline: %v", err) + } + + a := &AntigravityAgent{} + usage, err := a.CalculateTokenUsageSince(context.Background(), "c1", baseline) + if err != nil { + t.Fatalf("CalculateTokenUsageSince: %v", err) + } + if usage == nil { + t.Fatal("usage is nil") + } + if usage.InputTokens != 3500 { + t.Errorf("InputTokens = %d, want 3500", usage.InputTokens) + } + if usage.OutputTokens != 300 { + t.Errorf("OutputTokens = %d, want 300", usage.OutputTokens) + } + if usage.CacheCreationTokens != 50 { + t.Errorf("CacheCreationTokens = %d, want 50", usage.CacheCreationTokens) + } + if usage.CacheReadTokens != 3400 { + t.Errorf("CacheReadTokens = %d, want 3400", usage.CacheReadTokens) + } + if usage.APICallCount != 2 { + t.Errorf("APICallCount = %d, want 2", usage.APICallCount) + } +} + +func TestCalculateTokenUsageSince_NilBaselineCountsFromZero(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + snaps := []statusSnapshot{ + { + Timestamp: "2026-06-03T10:00:00.000000000Z", + ConversationID: "c2", + ContextWindow: statusContextWindow{ + TotalInputTokens: 1000, + TotalOutputTokens: 100, + CurrentUsage: &statusCurrentUsage{CacheReadInputTokens: 700}, + }, + }, + } + writeSnapshotFixture(t, "c2", snaps) + + a := &AntigravityAgent{} + usage, err := a.CalculateTokenUsageSince(context.Background(), "c2", nil) + if err != nil { + t.Fatalf("CalculateTokenUsageSince: %v", err) + } + if usage == nil { + t.Fatal("usage is nil") + } + if usage.InputTokens != 1000 { + t.Errorf("InputTokens = %d, want 1000", usage.InputTokens) + } + if usage.OutputTokens != 100 { + t.Errorf("OutputTokens = %d, want 100", usage.OutputTokens) + } +} + +func TestCalculateTokenUsageSince_NoDataReturnsNilNil(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + a := &AntigravityAgent{} + usage, err := a.CalculateTokenUsageSince(context.Background(), "missing-conv", nil) + if err != nil { + t.Fatalf("CalculateTokenUsageSince: %v", err) + } + if usage != nil { + t.Errorf("usage = %+v, want nil", usage) + } +} + +func TestSnapshotTokenBaseline_ReturnsLatestSnapshot(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + snaps := []statusSnapshot{ + { + Timestamp: "2026-06-03T10:00:00.000000000Z", + ConversationID: "c3", + ContextWindow: statusContextWindow{TotalInputTokens: 1000}, + }, + { + Timestamp: "2026-06-03T10:05:00.000000000Z", + ConversationID: "c3", + ContextWindow: statusContextWindow{TotalInputTokens: 2000}, + }, + } + writeSnapshotFixture(t, "c3", snaps) + + a := &AntigravityAgent{} + baseline, err := a.SnapshotTokenBaseline(context.Background(), "c3") + if err != nil { + t.Fatalf("SnapshotTokenBaseline: %v", err) + } + if len(baseline) == 0 { + t.Fatal("baseline is empty") + } + var snap statusSnapshot + if err := json.Unmarshal(baseline, &snap); err != nil { + t.Fatalf("unmarshal baseline: %v", err) + } + if snap.ContextWindow.TotalInputTokens != 2000 { + t.Errorf("baseline TotalInputTokens = %d, want 2000", snap.ContextWindow.TotalInputTokens) + } +} + +func TestSnapshotTokenBaseline_EmptyStoreReturnsNil(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + a := &AntigravityAgent{} + baseline, err := a.SnapshotTokenBaseline(context.Background(), "no-such-conv") + if err != nil { + t.Fatalf("SnapshotTokenBaseline: %v", err) + } + if baseline != nil { + t.Errorf("baseline = %v, want nil", baseline) + } +} + +func TestCalculateTokenUsageSince_ClampsWhenTotalsGoBackwards(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + // Simulate conversation-id reuse where cumulative totals reset lower than the baseline. + snaps := []statusSnapshot{ + { + Timestamp: "2026-06-03T10:05:00.000000000Z", + ConversationID: "c1", + ContextWindow: statusContextWindow{TotalInputTokens: 500, TotalOutputTokens: 30}, + }, + } + writeSnapshotFixture(t, "c1", snaps) + + // Baseline has higher totals than the latest snapshot — totals went backwards. + baseSnap := statusSnapshot{ + Timestamp: "2026-06-03T10:00:00.000000000Z", + ContextWindow: statusContextWindow{TotalInputTokens: 5000, TotalOutputTokens: 400}, + } + baseline, err := json.Marshal(baseSnap) + if err != nil { + t.Fatalf("marshal baseline: %v", err) + } + + a := &AntigravityAgent{} + usage, err := a.CalculateTokenUsageSince(context.Background(), "c1", baseline) + if err != nil { + t.Fatalf("CalculateTokenUsageSince: %v", err) + } + // The single line has no current_usage, so once max(0, ...) clamps the + // negative input/output deltas to zero, every field is zero and the method + // returns (nil, nil) — the all-zero "nothing observed" path. If the clamp + // were removed, the negative deltas would make usage non-nil, so asserting + // nil here pins the clamp behavior directly. + if usage != nil { + t.Errorf("usage = %+v, want nil (negative deltas clamped to zero -> all-zero -> nil)", usage) + } +} + +func TestCalculateTokenUsageSince_UnparseableBaselineCountsAllLines(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + snaps := []statusSnapshot{ + { + Timestamp: "2026-06-03T10:00:00.000000000Z", + ConversationID: "c1", + ContextWindow: statusContextWindow{ + TotalInputTokens: 1000, + TotalOutputTokens: 100, + CurrentUsage: &statusCurrentUsage{CacheReadInputTokens: 700}, + }, + }, + { + Timestamp: "2026-06-03T10:05:00.000000000Z", + ConversationID: "c1", + ContextWindow: statusContextWindow{ + TotalInputTokens: 3000, + TotalOutputTokens: 250, + CurrentUsage: &statusCurrentUsage{CacheReadInputTokens: 900}, + }, + }, + } + writeSnapshotFixture(t, "c1", snaps) + + // Baseline with an unparseable timestamp — documents accepted degradation: + // input/output stay exact via the totals subtraction, but cache/apicall + // counts cover all lines rather than only post-baseline lines. + baseline := json.RawMessage(`{"ts":"not-a-timestamp","context_window":{"total_input_tokens":1000,"total_output_tokens":100}}`) + + a := &AntigravityAgent{} + usage, err := a.CalculateTokenUsageSince(context.Background(), "c1", baseline) + if err != nil { + t.Fatalf("CalculateTokenUsageSince: %v", err) + } + if usage == nil { + t.Fatal("usage is nil") + } + if usage.InputTokens != 2000 { + t.Errorf("InputTokens = %d, want 2000 (3000-1000)", usage.InputTokens) + } + if usage.OutputTokens != 150 { + t.Errorf("OutputTokens = %d, want 150 (250-100)", usage.OutputTokens) + } + // Both lines are counted because the baseline timestamp didn't parse. + if usage.APICallCount != 2 { + t.Errorf("APICallCount = %d, want 2 (all lines counted)", usage.APICallCount) + } + if usage.CacheReadTokens != 1600 { + t.Errorf("CacheReadTokens = %d, want 1600 (700+900)", usage.CacheReadTokens) + } +} + +// TestAppendStatusSnapshot_CurrentUsageChangeIsNotDeduped proves that two +// payloads with IDENTICAL total_input_tokens/total_output_tokens but DIFFERENT +// current_usage are NOT deduped: both must be persisted. The delta calculation +// sums per-line cache fields from current_usage, so collapsing two lines that +// differ only in current_usage would silently drop cache accounting. +func TestAppendStatusSnapshot_CurrentUsageChangeIsNotDeduped(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + // Same totals {1000,100}, different current_usage cache_read. + a := []byte(`{"conversation_id":"conv-cu","agent_state":"working","context_window":{"total_input_tokens":1000,"total_output_tokens":100,"current_usage":{"cache_read_input_tokens":700}}}`) + b := []byte(`{"conversation_id":"conv-cu","agent_state":"working","context_window":{"total_input_tokens":1000,"total_output_tokens":100,"current_usage":{"cache_read_input_tokens":900}}}`) + + for _, p := range [][]byte{a, b} { + if err := AppendStatusSnapshot(p); err != nil { + t.Fatalf("AppendStatusSnapshot: %v", err) + } + } + + snaps, err := readStatusSnapshots(context.Background(), "conv-cu") + if err != nil { + t.Fatalf("readStatusSnapshots: %v", err) + } + if len(snaps) != 2 { + t.Fatalf("got %d snapshots, want 2 (current_usage change must not be deduped)", len(snaps)) + } +} + +// TestCalculateTokenUsageSince_NoDoubleCountWhenBaselineIsLatest proves the +// load-bearing strictly-.After filter prevents double-counting across turns. +// Simulating turn N+1: use turn N's LATEST snapshot as the baseline and append +// nothing new. CalculateTokenUsageSince must return (nil, nil) — zero delta, +// APICallCount 0 — because no snapshot is strictly after the baseline timestamp +// and totals − baseline = 0. +func TestCalculateTokenUsageSince_NoDoubleCountWhenBaselineIsLatest(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + snaps := []statusSnapshot{ + { + Timestamp: "2026-06-03T10:00:00.000000000Z", + ConversationID: "c1", + ContextWindow: statusContextWindow{ + TotalInputTokens: 1000, + TotalOutputTokens: 100, + CurrentUsage: &statusCurrentUsage{CacheCreationInputTokens: 200, CacheReadInputTokens: 700}, + }, + }, + { + Timestamp: "2026-06-03T10:05:00.000000000Z", + ConversationID: "c1", + ContextWindow: statusContextWindow{ + TotalInputTokens: 3000, + TotalOutputTokens: 250, + CurrentUsage: &statusCurrentUsage{CacheCreationInputTokens: 50, CacheReadInputTokens: 1900}, + }, + }, + } + writeSnapshotFixture(t, "c1", snaps) + + a := &AntigravityAgent{} + + // Turn N: full usage from nil baseline. + full, err := a.CalculateTokenUsageSince(context.Background(), "c1", nil) + if err != nil { + t.Fatalf("CalculateTokenUsageSince (full): %v", err) + } + if full == nil || full.InputTokens != 3000 { + t.Fatalf("full usage = %+v, want InputTokens 3000", full) + } + + // Capture the latest snapshot (T2 line) exactly as the lifecycle does. + baseline, err := a.SnapshotTokenBaseline(context.Background(), "c1") + if err != nil { + t.Fatalf("SnapshotTokenBaseline: %v", err) + } + if len(baseline) == 0 { + t.Fatal("baseline is empty") + } + + // Turn N+1: nothing new appended. Delta from the latest baseline is zero. + usage, err := a.CalculateTokenUsageSince(context.Background(), "c1", baseline) + if err != nil { + t.Fatalf("CalculateTokenUsageSince (delta): %v", err) + } + if usage != nil { + t.Errorf("usage = %+v, want nil (no snapshot strictly after baseline; zero delta)", usage) + } +} + +// Concurrent tees are the norm (agy fires the title command on every state +// change without serializing), and the dedup read and the append must be one +// critical section: without the lock a second invocation could append between +// them and the duplicate would land. +func TestAppendStatusSnapshot_ConcurrentDuplicatesCollapseToOneLine(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + payload := []byte(`{"conversation_id":"conv-race","context_window":{"total_input_tokens":4242,"total_output_tokens":17}}`) + const writers = 16 + var wg sync.WaitGroup + errs := make(chan error, writers) + for range writers { + wg.Add(1) + go func() { + defer wg.Done() + errs <- AppendStatusSnapshot(payload) + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("AppendStatusSnapshot: %v", err) + } + } + + snaps, err := readStatusSnapshots(context.Background(), "conv-race") + if err != nil { + t.Fatalf("readStatusSnapshots: %v", err) + } + if len(snaps) != 1 { + t.Fatalf("got %d snapshot lines for identical concurrent payloads, want exactly 1", len(snaps)) + } +} + +// The store is a root anchor: a symlink planted where a snapshot file belongs is +// refused by every reader and writer, never followed. +func TestStatusStore_RefusesSymlinkedSnapshotFile(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + target := filepath.Join(t.TempDir(), "elsewhere.jsonl") + if err := os.WriteFile(target, []byte(`{"ts":"2026-01-01T00:00:00Z","conversation_id":"conv-link","context_window":{"total_input_tokens":1}}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(dir, "conv-link.jsonl")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + if snaps, err := readStatusSnapshots(context.Background(), "conv-link"); err == nil { + t.Fatalf("readStatusSnapshots followed a symlink: got %d snapshots, want an error", len(snaps)) + } + payload := []byte(`{"conversation_id":"conv-link","context_window":{"total_input_tokens":2}}`) + if err := AppendStatusSnapshot(payload); err == nil { + t.Fatal("AppendStatusSnapshot wrote through a symlink, want an error") + } + data, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if strings.Count(string(data), "\n") != 1 { + t.Fatalf("symlink target was modified:\n%s", data) + } +} + +// TestReaders_TakeTheConversationLock: SnapshotTokenBaseline and +// CalculateTokenUsageSince read under the same per-conversation lock the tee +// appends under, so a baseline or delta read cannot observe a torn last line +// from a concurrent append and treat it as "no snapshot". Pinned by holding the +// lock externally and watching the reader wait for it. +func TestReaders_TakeTheConversationLock(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + writeSnapshotFixture(t, "c-lock", []statusSnapshot{{ + Timestamp: "2026-06-03T10:00:00.000000000Z", + ConversationID: "c-lock", + ContextWindow: statusContextWindow{TotalInputTokens: 1}, + }}) + + st, err := openStatusStore() + if err != nil { + t.Fatal(err) + } + release, err := lockStatusFile(st, st.fileName("c-lock")) + if err != nil { + t.Fatal(err) + } + + done := make(chan error, 1) + go func() { + a := &AntigravityAgent{} + baseline, err := a.SnapshotTokenBaseline(context.Background(), "c-lock") + if err != nil { + done <- err + return + } + if len(baseline) == 0 { + done <- errors.New("SnapshotTokenBaseline returned no baseline for a conversation with a snapshot") + return + } + _, err = a.CalculateTokenUsageSince(context.Background(), "c-lock", nil) + done <- err + }() + + select { + case <-done: + t.Fatal("readers completed while another process held the conversation lock") + case <-time.After(200 * time.Millisecond): + } + release() + select { + case err := <-done: + if err != nil { + t.Fatalf("readers after the lock was released: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("readers did not proceed after the lock was released") + } +} + +// TestReaders_DegradeWhenTheLockIsStuck: the readers run inside agy's hooks, +// so a tee that hangs while holding the conversation lock must not hang the +// hook. The wait is bounded; on timeout the read proceeds unlocked (the +// pre-lock behaviour) instead of blocking the turn. +func TestReaders_DegradeWhenTheLockIsStuck(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + writeSnapshotFixture(t, "c-stuck", []statusSnapshot{{ + Timestamp: "2026-06-03T10:00:00.000000000Z", + ConversationID: "c-stuck", + ContextWindow: statusContextWindow{TotalInputTokens: 7}, + }}) + + prev := statusReadLockTimeout + statusReadLockTimeout = 100 * time.Millisecond + t.Cleanup(func() { statusReadLockTimeout = prev }) + + st, err := openStatusStore() + if err != nil { + t.Fatal(err) + } + release, err := lockStatusFile(st, st.fileName("c-stuck")) + if err != nil { + t.Fatal(err) + } + defer release() // held for the whole test: the "stuck tee" + + done := make(chan error, 1) + go func() { + baseline, err := (&AntigravityAgent{}).SnapshotTokenBaseline(context.Background(), "c-stuck") + if err != nil { + done <- err + return + } + if len(baseline) == 0 { + done <- errors.New("SnapshotTokenBaseline degraded to no baseline instead of reading unlocked") + return + } + done <- nil + }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("reader hung behind a stuck lock holder; the wait must be bounded") + } +} + +// Without the override the store lives at /antigravity/status: a +// two-segment name that must be slash-separated for osroot to create it. Every +// other test here runs under ENTIRE_ANTIGRAVITY_STATUS_DIR, where the dir is +// "" and the mkdir is skipped — which is how a filepath.Join here shipped. +func TestStatusStore_DefaultDirIsCreatedUnderTheCacheRoot(t *testing.T) { + cache := t.TempDir() + t.Setenv(statusDirEnv, "") + t.Setenv("XDG_CACHE_HOME", cache) + + payload := []byte(`{"conversation_id":"conv-default","context_window":{"total_input_tokens":7,"total_output_tokens":1}}`) + if err := AppendStatusSnapshot(payload); err != nil { + t.Fatalf("AppendStatusSnapshot: %v", err) + } + want := filepath.Join(cache, "entire", "antigravity", "status", "conv-default.jsonl") + if _, err := os.Stat(want); err != nil { + t.Fatalf("snapshot file not created under the cache root: %v", err) + } + snaps, err := readStatusSnapshots(context.Background(), "conv-default") + if err != nil || len(snaps) != 1 { + t.Fatalf("readStatusSnapshots = %d snapshots, %v; want 1", len(snaps), err) + } +} + +// A read of a conversation that has no snapshot must not leave a lock file +// behind: flock.AcquireIn creates the lock it opens, and every TurnStart takes +// a baseline, so conversations that never produce a snapshot would otherwise +// litter the store with orphans for the whole retention window. +func TestStatusReaders_LeaveNoLockFileForAnUnknownConversation(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + a := &AntigravityAgent{} + + raw, err := a.SnapshotTokenBaseline(context.Background(), "conv-never") + if err != nil || raw != nil { + t.Fatalf("SnapshotTokenBaseline = %q, %v; want nil, nil", raw, err) + } + usage, err := a.CalculateTokenUsageSince(context.Background(), "conv-never", nil) + if err != nil || usage != nil { + t.Fatalf("CalculateTokenUsageSince = %v, %v; want nil, nil", usage, err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), statusLockSuffix) { + t.Fatalf("reader left an orphan lock file %s behind", e.Name()) + } + } +} + +// A dormant conversation resumed between the prune's stat and its unlink is +// mid-append, and removing the file from under its open fd loses the per-line +// detail CalculateTokenUsageSince derives between the baseline and now. The +// prune takes the conversation's own lock first, so a held lock defers the +// delete to the next run — the same care the lock-file loop already takes. +func TestAppendStatusSnapshot_PruneSkipsALockedStaleFile(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + // Stale by mtime, so the prune would otherwise remove it. + stale := filepath.Join(dir, "resumed-conv.jsonl") + if err := os.WriteFile(stale, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-statusRetention - time.Hour) + if err := os.Chtimes(stale, old, old); err != nil { + t.Fatal(err) + } + + // Stand in for the tee that just resumed it and holds its lock. + st, err := openStatusStore() + if err != nil { + t.Fatal(err) + } + release, err := lockStatusFile(st, path.Join(st.dir, "resumed-conv.jsonl")) + if err != nil { + t.Fatalf("take the conversation lock: %v", err) + } + defer release() + + // First write for a different conversation triggers the prune. + payload := []byte(`{"conversation_id":"conv-new","context_window":{"total_input_tokens":1}}`) + if err := AppendStatusSnapshot(payload); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(stale); err != nil { + t.Errorf("a stale file whose conversation holds its lock must survive the prune: %v", err) + } +} + +// The try-lock is not a free probe: flock opens with O_CREATE|O_EXCL, so asking +// for a lock that does not exist creates one. Doing that while pruning would +// leave an orphan lock behind for every conversation deleted — the orphan-lock +// loop cannot collect it in the same pass, and on the next it must age past the +// cutoff first, so each deletion leaks a file for another retention window. +// A conversation with no lock file has never had a tee mid-append, since the +// lock is taken before the .jsonl is created, so it is unlinked directly. +func TestAppendStatusSnapshot_PruneLeavesNoOrphanLockBehind(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + // Stale, and deliberately without a lock file beside it. + stale := filepath.Join(dir, "lockless-conv.jsonl") + if err := os.WriteFile(stale, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-statusRetention - time.Hour) + if err := os.Chtimes(stale, old, old); err != nil { + t.Fatal(err) + } + + payload := []byte(`{"conversation_id":"conv-new","context_window":{"total_input_tokens":1}}`) + if err := AppendStatusSnapshot(payload); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Errorf("a stale file with no lock must still be pruned, stat err = %v", err) + } + if _, err := os.Stat(stale + statusLockSuffix); !os.IsNotExist(err) { + t.Error("pruning created a lock file for the conversation it deleted") + } +} diff --git a/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_pre_invocation.json b/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_pre_invocation.json new file mode 100644 index 0000000000..3d3230d4e9 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_pre_invocation.json @@ -0,0 +1,8 @@ +{ + "invocationNum": 1, + "initialNumSteps": 5, + "conversationId": "ec33ebf9-0cba-4100-8142-c61503f6c587", + "workspacePaths": ["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/workspace/project"], + "transcriptPath": "/workspace/project/.gemini/jetski/transcript.jsonl", + "artifactDirectoryPath": "/workspace/project/.gemini/jetski/artifacts" +} diff --git a/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_pre_tool_use.json b/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_pre_tool_use.json new file mode 100644 index 0000000000..784c013522 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_pre_tool_use.json @@ -0,0 +1,15 @@ +{ + "toolCall": { + "name": "run_command", + "args": { + "CommandLine": "npm test", + "Cwd": "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/workspace/project", + "WaitMsBeforeAsync": 5000 + } + }, + "stepIdx": 19, + "conversationId": "ec33ebf9-0cba-4100-8142-c61503f6c587", + "workspacePaths": ["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/workspace/project"], + "transcriptPath": "/workspace/project/.gemini/jetski/transcript.jsonl", + "artifactDirectoryPath": "/workspace/project/.gemini/jetski/artifacts" +} diff --git a/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_stop.json b/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_stop.json new file mode 100644 index 0000000000..e645a2de2b --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_stop.json @@ -0,0 +1,10 @@ +{ + "executionNum": 1, + "terminationReason": "model_stop", + "error": "", + "fullyIdle": true, + "conversationId": "ec33ebf9-0cba-4100-8142-c61503f6c587", + "workspacePaths": ["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/workspace/project"], + "transcriptPath": "/workspace/project/.gemini/jetski/transcript.jsonl", + "artifactDirectoryPath": "/workspace/project/.gemini/jetski/artifacts" +} diff --git a/cmd/entire/cli/agent/antigravity/testdata/transcript_sample.jsonl b/cmd/entire/cli/agent/antigravity/testdata/transcript_sample.jsonl new file mode 100644 index 0000000000..6dadc4b85f --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/testdata/transcript_sample.jsonl @@ -0,0 +1,13 @@ +{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-05-20T23:46:29Z","content":"\nread a.txt and tell me what it says, then exit\n\n\nThe current local time is: 2026-05-20T19:46:29-04:00.\n\n\nThe user changed setting `Model Selection` from None to Gemini 3.5 Flash (High). No need to comment on this change if the user doesn't ask about it. If reporting what model you are, please use a human readable name instead of the exact string.\n"} +{"step_index":1,"source":"SYSTEM","type":"CONVERSATION_HISTORY","status":"DONE","created_at":"2026-05-20T23:46:29Z"} +{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-20T23:46:29Z","tool_calls":[{"name":"list_dir","args":{"DirectoryPath":"\"/Users/peytonmontei/.gemini/antigravity-cli/scratch\"","toolAction":"\"Listing the scratch directory\"","toolSummary":"\"Directory listing\""}}]} +{"step_index":3,"source":"MODEL","type":"LIST_DIRECTORY","status":"DONE","created_at":"2026-05-20T23:46:31Z","content":"Created At: 2026-05-20T23:46:31Z\nCompleted At: 2026-05-20T23:46:31Z\nEmpty directory"} +{"step_index":5,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-20T23:46:33Z","thinking":"**Investigating File Locations**\n\nThe `/Users/peytonmontei/.gemini/antigravity-cli/scratch` directory is empty. I'm expanding the search, now examining the home directory and checking for `a.txt` within `/Users/peytonmontei/.gemini/antigravity-cli`.\n\n\n","tool_calls":[{"name":"run_command","args":{"CommandLine":"\"find . -name \\\"a.txt\\\" -maxdepth 3\"","Cwd":"\"/Users/peytonmontei/.gemini/antigravity-cli/scratch\"","WaitMsBeforeAsync":"2000","toolAction":"\"Searching for a.txt\"","toolSummary":"\"File search\""}}]} +{"step_index":6,"source":"MODEL","type":"RUN_COMMAND","status":"DONE","created_at":"2026-05-20T23:46:34Z","content":"Created At: 2026-05-20T23:46:34Z\nCompleted At: 2026-05-20T23:46:34Z\n\n\t\t\t\tThe command completed successfully.\n\t\t\tStdout:\n\t\t\t\n\t\t\tStderr:\n\t\t\t\n"} +{"step_index":7,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-20T23:46:34Z","tool_calls":[{"name":"list_dir","args":{"DirectoryPath":"\"/Users/peytonmontei/.gemini/antigravity-cli\"","toolAction":"\"Listing the app data directory\"","toolSummary":"\"Directory listing\""}}]} +{"step_index":8,"source":"MODEL","type":"LIST_DIRECTORY","status":"ERROR","created_at":"2026-05-20T23:46:34Z","content":"Created At: 2026-05-20T23:46:34Z\nCompleted At: 2026-05-20T23:46:34Z\nEncountered error in step execution: Permission denied for read_file(/Users/peytonmontei/.gemini/antigravity-cli). Matches hardcoded system protection boundary rule."} +{"step_index":9,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-20T23:46:34Z","tool_calls":[{"name":"list_permissions","args":{"toolAction":"\"Listing current permissions\"","toolSummary":"\"Permission listing\""}}]} +{"step_index":10,"source":"MODEL","type":"GENERIC","status":"DONE","created_at":"2026-05-20T23:46:35Z","content":"Created At: 2026-05-20T23:46:35Z\nCompleted At: 2026-05-20T23:46:35Z\nYour current permission grants (listed in the order they are checked) are:\n- command(*): allowed\n- execute_url(*): allowed\n- read_url(*): allowed\n- mcp(*): allowed\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/scratch): allowed\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/scratch): allowed\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/browser_recordings): allowed\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/browser_recordings): allowed\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/html_artifacts): allowed\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/html_artifacts): allowed\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/knowledge): allowed\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/knowledge): allowed\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/worktrees): allowed\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/worktrees): allowed\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/mcp): allowed\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/mcp): denied\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/mcp_config.json): ask\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/mcp_config.json): ask\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/skills): allowed\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/skills): ask\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/conversations): ask\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/conversations): denied\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/mcp_config.json): ask\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/mcp_config.json): ask\n- read_file(/Users/peytonmontei/.gemini/config/skills): allowed\n- write_file(/Users/peytonmontei/.gemini/config/skills): ask\n- read_file(/Users/peytonmontei/.gemini/config/plugins): allowed\n- write_file(/Users/peytonmontei/.gemini/config/plugins): ask\n- read_file(/Users/peytonmontei/.gemini/config/agents): allowed\n- write_file(/Users/peytonmontei/.gemini/config/agents): ask\n- read_file(/Users/peytonmontei/.gemini/config/hooks.json): ask\n- write_file(/Users/peytonmontei/.gemini/config/hooks.json): ask\n- read_file(/Users/peytonmontei/.gemini/config/sidecars): allowed\n- write_file(/Users/peytonmontei/.gemini/config/sidecars): ask\n- read_file(/Users/peytonmontei/.gemini/config/projects): allowed\n- write_file(/Users/peytonmontei/.gemini/config/projects): denied\n- read_file(/Users/peytonmontei/.gemini/config/config.json): denied\n- write_file(/Users/peytonmontei/.gemini/config/config.json): denied\n- read_file(.env): ask\n- write_file(.env): ask\n- read_file(.env.local): ask\n- write_file(.env.local): ask\n- read_file(.env.production): ask\n- write_file(.env.production): ask\n- read_file(.env.development): ask\n- write_file(.env.development): ask\n- read_file(.env.staging): ask\n- write_file(.env.staging): ask\n- read_file(.npmrc): ask\n- write_file(.npmrc): ask\n- read_file(.pypirc): ask\n- write_file(.pypirc): ask\n- read_file(.netrc): ask\n- write_file(.netrc): ask\n- read_file(.git-credentials): ask\n- write_file(.git-credentials): ask\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli): denied\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli): denied\n- read_file(/Users/peytonmontei/.gemini/config): denied\n- write_file(/Users/peytonmontei/.gemini/config): denied\n- command(echo): allowed\n- command(date): allowed\n"} +{"step_index":11,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-20T23:46:35Z","thinking":"**Investigating File Permissions**\n\nOkay, so I've confirmed that I have read/write access to a specific file path, but the directory containing that file is showing as denied. That's a bit of a head-scratcher. Now, I'm trying to figure out where the `a.txt` file exists. Let's start with a search.\n\n\n","tool_calls":[{"name":"run_command","args":{"CommandLine":"\"find ~ -name \\\"a.txt\\\" -not -path '*/.*' 2>/dev/null\"","Cwd":"\"/Users/peytonmontei/.gemini/antigravity-cli/scratch\"","WaitMsBeforeAsync":"3000","toolAction":"\"Locating a.txt on the system\"","toolSummary":"\"System-wide search\""}}]} +{"step_index":12,"source":"MODEL","type":"RUN_COMMAND","status":"RUNNING","created_at":"2026-05-20T23:46:36Z","content":"Created At: 2026-05-20T23:46:36Z\nTool is running as a background task with task id: fd874856-8453-4f63-be51-35196f6d87f5/task-12\nTask Description: find ~ -name \"a.txt\" -not -path '*/.*' 2>/dev/null\nTask logs are available at: file:///Users/peytonmontei/.gemini/antigravity-cli/brain/fd874856-8453-4f63-be51-35196f6d87f5/.system_generated/tasks/task-12.log"} +{"step_index":13,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-20T23:46:39Z","content":"An asynchronous background search has been started to locate `a.txt` on your filesystem. I will wait for it to complete."} diff --git a/cmd/entire/cli/agent/antigravity/title_install.go b/cmd/entire/cli/agent/antigravity/title_install.go new file mode 100644 index 0000000000..95c40da42a --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/title_install.go @@ -0,0 +1,401 @@ +package antigravity + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/internal/flock" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/osroot" + "github.com/entireio/cli/internal/entireclient/userdirs" +) + +// agy reads its window-title command from the GLOBAL config +// ~/.gemini/antigravity-cli/settings.json — a single slot: +// +// {"title": {"type": "command", "command": ""}} +// +// We occupy that slot with the title-tee shim (the title script receives the +// same state JSON as the statusline script — agy's only token-usage surface). +// A pre-existing user command is preserved INSIDE the shim invocation — via +// --wrap '' where agy runs the slot through sh, or --wrap-b64 +// where it runs it through cmd.exe — making the config +// self-describing: uninstall restores the original without any backup file. Because the slot is global, per-repo +// `entire disable` does NOT uninstall it (other repos may rely on it); only +// agent removal does. + +// configDirEnv overrides the agy config directory (tests). +const configDirEnv = "ENTIRE_ANTIGRAVITY_CONFIG_DIR" + +const titleTeeMarker = "hooks antigravity title-tee" + +type titleConfig struct { + Type string `json:"type"` + Command string `json:"command"` +} + +// agySettingsFileName is agy's global settings file inside its config dir. +const agySettingsFileName = "settings.json" + +// agyConfigDir returns the agy config directory, honouring the override env var. +func agyConfigDir() (string, error) { + if dir := os.Getenv(configDirEnv); dir != "" { + // The same absolute-path rule the statusline override and userdirs' + // own overrides are held to, from the one helper that states it. + if err := userdirs.RequireAbsoluteOverride(configDirEnv, dir); err != nil { + return "", err //nolint:wrapcheck // the helper already names the variable + } + return dir, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to resolve home dir: %w", err) + } + return filepath.Join(home, ".gemini", "antigravity-cli"), nil +} + +// openAgyConfigRoot opens an *os.Root over agy's config directory so +// settings.json is read and written as a NAME inside it, never through a +// symlink (docs/development/filesystem-safety.md). The directory is agy's own, +// resolved from HOME (or the operator override), so it is the trusted base; +// with create it is made first — the root is the directory itself, so it +// cannot be created through it. The caller closes the root: this is not one of +// the process-wide anchors, and the override changes between tests. +func openAgyConfigRoot(create bool) (*os.Root, error) { + dir, err := agyConfigDir() + if err != nil { + return nil, err + } + if create { + if err := os.MkdirAll(dir, 0o750); err != nil { + return nil, fmt.Errorf("failed to create agy config dir: %w", err) + } + } + root, err := os.OpenRoot(dir) + if err != nil { + return nil, err //nolint:wrapcheck // preserved for errors.Is(err, fs.ErrNotExist) at call sites + } + return root, nil +} + +// titleTeeCommand returns the full shell command string for the title-tee shim. +// If original is non-empty, the original command is wrapped via --wrap. +// +// The command always names the `entire` binary (resolved via $PATH): the title +// slot lives in agy's GLOBAL settings.json and is invoked from whatever +// directory agy runs in, so it must never depend on a repository path. +func titleTeeCommand(original string) string { + base := "entire hooks antigravity title-tee" + if original == "" { + return base + } + if agent.HookHostIsWindows() { + // agy hands the slot to cmd.exe on Windows, where POSIX single quotes + // are literal characters and the tee has no sh to re-run the original + // with. The original travels base64url-encoded instead — an alphabet + // ([A-Za-z0-9_-], no padding) no shell touches — and the tee runs it + // through cmd.exe itself, exactly as agy would have. + return base + " " + strings.TrimSpace(wrapB64Flag) + " " + base64.RawURLEncoding.EncodeToString([]byte(original)) + } + return base + " --wrap " + shellSingleQuote(original) +} + +// wrapFlag and wrapB64Flag are the two spellings of a preserved original +// command inside a tee command string, each surrounded by spaces so that an +// original that merely CONTAINS the text (see +// TestInstallTitle_WrapsCommandContainingWrapSubstring) cannot be mistaken +// for the flag. +const ( + wrapFlag = " --wrap " + wrapB64Flag = " --wrap-b64 " +) + +// shellSingleQuote wraps s in POSIX single quotes. Embedded single quotes are +// rewritten with the standard close-escape-reopen technique (see the +// strings.ReplaceAll below) so the result is safe inside a single-quoted shell +// argument. +// +// Trust boundary: s is only ever the title command the user already configured +// in agy's own global settings.json — a command agy runs verbatim, as the +// user, on every state change. Quoting it here changes nothing about what it +// may do; it only guarantees that agy hands it to `entire ... --wrap` as ONE +// argument, so that title-tee later re-executes exactly the command that was +// there (via `sh -c`, the same shell agy would have used) and uninstall can +// restore it byte for byte. No content from a repository, a hook payload, or +// any other party reaches this function, and `entire` never composes a shell +// command from anything but that user-authored string. +func shellSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// lockAgySettings serialises the read-modify-write of agy's global +// settings.json across entire processes: two `entire agent add antigravity` +// runs from different repos on one machine (or an add racing a remove) would +// otherwise both read the same slot, and the second atomic write would replace +// the first's, leaving the title slot wrapped twice, unwrapped, or restored to +// the wrong original with no error anywhere. The lock file sits beside +// settings.json, like the status store's beside its snapshot file. With create +// the config directory is made first; without it a missing directory is +// reported through errors.Is(err, fs.ErrNotExist) for callers that then have +// nothing to do. errors.Is rather than os.IsNotExist because it keeps working +// if any layer below starts wrapping: openAgyConfigRoot and the osroot helpers +// currently return ENOENT unwrapped on purpose, which os.IsNotExist depends on. +func lockAgySettings(create bool) (release func(), err error) { + root, err := openAgyConfigRoot(create) + if err != nil { + return nil, err + } + defer root.Close() + release, err = flock.AcquireIn(root, agySettingsFileName+statusLockSuffix) + if err != nil { + return nil, fmt.Errorf("failed to lock agy settings: %w", err) + } + return release, nil +} + +// InstallTitleTee installs the title-tee shim into agy's global settings.json. +// If a user's own title command is already present, it is preserved via --wrap. +// The call is idempotent: if our marker is already in the command, it returns nil. +func InstallTitleTee() error { + release, err := lockAgySettings(true) + if err != nil { + return err + } + defer release() + + rawFile, err := readAgySettings() + if err != nil { + return err + } + + // Parse existing title entry (if any). Unparseable → treat as absent. + var existing titleConfig + if raw, ok := rawFile["title"]; ok { + _ = json.Unmarshal(raw, &existing) //nolint:errcheck // treat unparseable as absent + } + + // Idempotency: already contains our marker. + if strings.Contains(existing.Command, titleTeeMarker) { + return nil + } + + // Build new title config, wrapping any pre-existing command. + cfg := titleConfig{ + Type: hookTypeCommand, + Command: titleTeeCommand(existing.Command), + } + + cfgBytes, err := jsonutil.MarshalWithNoHTMLEscape(cfg) + if err != nil { + return fmt.Errorf("failed to marshal title config: %w", err) + } + rawFile["title"] = cfgBytes + + return writeAgySettings(rawFile) +} + +// TitleTeeInstalled reports whether agy's global settings.json declares a +// title command containing the title-tee marker. It is used by `entire doctor` +// to warn when Antigravity hooks are installed in a repo but the global title +// slot — agy's only token-usage surface — has not been claimed, which would +// leave token counts missing from checkpoints. A missing or unparseable +// settings file reports false. +func TitleTeeInstalled() bool { + rawFile, err := readAgySettings() + if err != nil { + return false + } + + raw, ok := rawFile["title"] + if !ok { + return false + } + + var existing titleConfig + if err := json.Unmarshal(raw, &existing); err != nil { + return false + } + return strings.Contains(existing.Command, titleTeeMarker) +} + +// UninstallTitleTee removes or restores the title entry in agy's global settings.json: +// - bare tee (no --wrap) → delete "title" key +// - tee with --wrap 'X' → restore X +// - any other (foreign) cmd → leave untouched +// - missing settings file → no-op +func UninstallTitleTee() error { + // A missing config directory means agy never ran here: nothing to + // uninstall, and no reason to create the directory just to lock in it. + release, err := lockAgySettings(false) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + defer release() + + // A missing settings file reads as an empty map, and an empty map has no + // title key, so there is nothing to uninstall; a symlinked settings.json is + // refused by the read, exactly as install refuses it. + rawFile, err := readAgySettings() + if err != nil { + return err + } + + raw, ok := rawFile["title"] + if !ok { + return nil // no title key — nothing to do + } + + var existing titleConfig + if err := json.Unmarshal(raw, &existing); err != nil { + return nil // unparseable title entry — leave it alone rather than destroying user data + } + + // Not our command → leave untouched. + if !strings.Contains(existing.Command, titleTeeMarker) { + return nil + } + + wrapped, hasWrap := extractWrappedCommand(existing.Command) + if hasWrap { + // Restore the original command. + restored := titleConfig{ + Type: hookTypeCommand, + Command: wrapped, + } + restoredBytes, err := jsonutil.MarshalWithNoHTMLEscape(restored) + if err != nil { + return fmt.Errorf("failed to marshal restored title config: %w", err) + } + rawFile["title"] = restoredBytes + } else { + // Only delete the key when the command has the shape of a bare tee we + // would have written ourselves. Anything else containing the marker is + // a user-authored wrapper (e.g. "my-wrapper.sh 'entire hooks + // antigravity title-tee'") or a corrupted command — leaving it alone + // is always safer than deleting the user's config. + if !isBareTitleTeeCommand(existing.Command) { + return nil + } + delete(rawFile, "title") + } + + return writeAgySettings(rawFile) +} + +// isBareTitleTeeCommand reports whether command is one of the bare (no --wrap) +// tee commands any Entire install could have written: the production form, or +// the legacy local-dev form `go run '/cmd/entire/main.go' hooks +// antigravity title-tee` that older versions wrote (local-dev mode was removed +// in a9a676e79). The legacy form is matched by SHAPE, from ANY repo/worktree: +// uninstall may run from a different worktree (or outside a repo) than install +// did, and an exact-path comparison would silently orphan the global entry, +// leaving agy to spawn a failing `go run` on every state change after the +// original worktree is deleted. +func isBareTitleTeeCommand(command string) bool { + if command == titleTeeCommand("") { + return true + } + return strings.HasPrefix(command, "go run ") && + strings.HasSuffix(command, " hooks antigravity title-tee") +} + +// extractWrappedCommand parses the preserved original out of a title-tee +// command string: the --wrap '' form, or the --wrap-b64 +// form written on Windows hosts. It returns the original command and true if +// found and valid, or ("", false) otherwise. The quoted form is tried first: +// it is the only one whose payload can itself contain either flag's text. +func extractWrappedCommand(command string) (string, bool) { + idx := strings.Index(command, wrapFlag) + if idx < 0 { + return extractBase64WrappedCommand(command) + } + rest := strings.TrimSpace(command[idx+len(wrapFlag):]) + if len(rest) < 2 || rest[0] != '\'' || rest[len(rest)-1] != '\'' { + return "", false + } + // Strip outer single quotes and reverse the '\'' escaping. + inner := rest[1 : len(rest)-1] + return strings.ReplaceAll(inner, `'\''`, "'"), true +} + +// extractBase64WrappedCommand parses the --wrap-b64 form. A token that +// is anything but one base64url word, or that decodes to nothing, is treated +// as malformed — uninstall then leaves the entry alone, as it does for a +// malformed quoted form. +func extractBase64WrappedCommand(command string) (string, bool) { + idx := strings.Index(command, wrapB64Flag) + if idx < 0 { + return "", false + } + token := strings.TrimSpace(command[idx+len(wrapB64Flag):]) + if token == "" || strings.ContainsAny(token, " \t") { + return "", false + } + decoded, err := base64.RawURLEncoding.DecodeString(token) + if err != nil || len(decoded) == 0 { + return "", false + } + return string(decoded), true +} + +// readAgySettings reads and parses settings.json into a raw map. +// A missing directory or file returns an empty map (not an error); a +// symlinked settings.json is refused rather than read through. +func readAgySettings() (map[string]json.RawMessage, error) { + rawFile := make(map[string]json.RawMessage) + root, err := openAgyConfigRoot(false) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return rawFile, nil + } + return nil, fmt.Errorf("failed to open agy config dir: %w", err) + } + defer root.Close() + data, err := osroot.ReadFileNoFollow(root, agySettingsFileName) + if errors.Is(err, fs.ErrNotExist) { + return rawFile, nil + } + if err != nil { + return nil, fmt.Errorf("failed to read agy settings: %w", err) + } + if err := json.Unmarshal(data, &rawFile); err != nil { + return nil, fmt.Errorf("failed to parse agy settings: %w", err) + } + return rawFile, nil +} + +// writeAgySettings marshals rawFile and writes settings.json atomically inside +// agy's config dir, creating the dir as needed. settings.json is +// machine-global (its title slot is shared by every repo on the machine), so a +// crash mid-write must not truncate it, and a symlink at the file is refused +// rather than replaced. +func writeAgySettings(rawFile map[string]json.RawMessage) error { + output, err := jsonutil.MarshalIndentWithNewline(rawFile, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal agy settings: %w", err) + } + root, err := openAgyConfigRoot(true) + if err != nil { + return fmt.Errorf("failed to open agy config dir: %w", err) + } + defer root.Close() + if info, err := osroot.LstatNoSymlinks(root, agySettingsFileName); err == nil && info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("failed to write agy settings: %w", osroot.ErrSymlinkedPath) + } else if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("failed to inspect agy settings: %w", err) + } + if err := jsonutil.WriteFileAtomicIn(root, agySettingsFileName, output, 0o600); err != nil { + return fmt.Errorf("failed to write agy settings: %w", err) + } + return nil +} diff --git a/cmd/entire/cli/agent/antigravity/title_install_test.go b/cmd/entire/cli/agent/antigravity/title_install_test.go new file mode 100644 index 0000000000..2670f285a3 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/title_install_test.go @@ -0,0 +1,553 @@ +package antigravity + +import ( + "encoding/base64" + "encoding/json" + "github.com/entireio/cli/cmd/entire/cli/agent" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// writeAgySettingsFile writes content to /settings.json. +func writeAgySettingsFile(t *testing.T, dir, content string) { + t.Helper() + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("writeAgySettingsFile: mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(content), 0o600); err != nil { + t.Fatalf("writeAgySettingsFile: write: %v", err) + } +} + +// readTitleCommand parses /settings.json and returns the title.command value, +// or "" if the file is absent or the key is not present. +func readTitleCommand(t *testing.T, dir string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, "settings.json")) + if err != nil { + return "" + } + var s struct { + Title *struct { + Type string `json:"type"` + Command string `json:"command"` + } `json:"title"` + Theme string `json:"theme"` + } + if err := json.Unmarshal(data, &s); err != nil { + t.Fatalf("readTitleCommand: unmarshal: %v", err) + } + if s.Title == nil { + return "" + } + return s.Title.Command +} + +// mustJSON marshals s to a JSON string value (quoted). +func mustJSON(t *testing.T, s string) []byte { + t.Helper() + b, err := json.Marshal(s) + if err != nil { + t.Fatalf("mustJSON: %v", err) + } + return b +} + +func TestInstallTitle_FreshConfig(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + if err := InstallTitleTee(); err != nil { + t.Fatalf("InstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + want := "entire hooks antigravity title-tee" + if got != want { + t.Errorf("title.command = %q, want %q", got, want) + } +} + +func TestInstallTitle_WrapsExistingCommand(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + writeAgySettingsFile(t, cfgDir, `{"theme":"dark","title":{"type":"command","command":"~/bin/my-status.sh"}}`) + + if err := InstallTitleTee(); err != nil { + t.Fatalf("InstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + want := "entire hooks antigravity title-tee --wrap '~/bin/my-status.sh'" + if got != want { + t.Errorf("title.command = %q, want %q", got, want) + } + + // Unknown-key preservation: "theme" must still be present in raw file. + raw, err := os.ReadFile(filepath.Join(cfgDir, "settings.json")) + if err != nil { + t.Fatalf("read settings.json: %v", err) + } + if !strings.Contains(string(raw), `"theme"`) { + t.Error(`settings.json lost "theme" key after install`) + } +} + +// TestInstallTitle_WindowsHostWrapsExistingCommandBase64 pins the Windows +// form: agy runs the title slot through cmd.exe there, so the preserved +// original travels base64url-encoded rather than in POSIX single quotes, and +// uninstall restores it byte for byte. The name carries "Windows" so the +// windows-latest CI job selects it. +func TestInstallTitle_WindowsHostWrapsExistingCommandBase64(t *testing.T) { + // No t.Parallel — uses t.Setenv and the process-global hook-host override. + restore := agent.SetWindowsHookProbeForTesting("windows", nil) + defer restore() + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + // Quotes, an ampersand and a percent: everything cmd.exe would tear apart. + const original = `powershell -c "Write-Host 'x' & echo %CD%"` + writeAgySettingsFile(t, cfgDir, `{"title":{"type":"command","command":`+string(mustJSON(t, original))+`}}`) + + if err := InstallTitleTee(); err != nil { + t.Fatalf("InstallTitleTee: %v", err) + } + got := readTitleCommand(t, cfgDir) + want := "entire hooks antigravity title-tee --wrap-b64 " + base64.RawURLEncoding.EncodeToString([]byte(original)) + if got != want { + t.Errorf("title.command = %q, want %q", got, want) + } + if strings.ContainsAny(strings.TrimPrefix(got, "entire hooks antigravity title-tee --wrap-b64 "), `'"&%^|<>()=`) { + t.Errorf("encoded form carries a cmd.exe metacharacter: %q", got) + } + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + if got := readTitleCommand(t, cfgDir); got != original { + t.Errorf("after uninstall title.command = %q, want the original %q", got, original) + } +} + +func TestExtractWrappedCommand_Forms(t *testing.T) { + t.Parallel() + b64 := func(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) } + cases := []struct { + name string + command string + want string + ok bool + }{ + {"quoted form", "entire hooks antigravity title-tee --wrap '~/bin/x.sh'", "~/bin/x.sh", true}, + {"base64 form", "entire hooks antigravity title-tee --wrap-b64 " + b64(`echo "a" & b`), `echo "a" & b`, true}, + // The quoted payload may itself mention the base64 flag; the quoted + // form wins because it is parsed first. + {"quoted payload naming the b64 flag", "entire hooks antigravity title-tee --wrap 'x --wrap-b64 abc'", "x --wrap-b64 abc", true}, + {"base64 token with trailing junk", "entire hooks antigravity title-tee --wrap-b64 " + b64("x") + " extra", "", false}, + {"base64 token not base64url", "entire hooks antigravity title-tee --wrap-b64 not*base64!", "", false}, + {"empty base64 token", "entire hooks antigravity title-tee --wrap-b64 ", "", false}, + {"bare tee", "entire hooks antigravity title-tee", "", false}, + {"unquoted legacy", "entire hooks antigravity title-tee --wrap unquoted", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, ok := extractWrappedCommand(tc.command) + if ok != tc.ok || got != tc.want { + t.Errorf("extractWrappedCommand(%q) = (%q, %v), want (%q, %v)", tc.command, got, ok, tc.want, tc.ok) + } + }) + } +} + +func TestInstallTitle_Idempotent(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + if err := InstallTitleTee(); err != nil { + t.Fatalf("first InstallTitleTee: %v", err) + } + first := readTitleCommand(t, cfgDir) + + if err := InstallTitleTee(); err != nil { + t.Fatalf("second InstallTitleTee: %v", err) + } + second := readTitleCommand(t, cfgDir) + + if first != second { + t.Errorf("idempotency: first=%q second=%q", first, second) + } +} + +func TestUninstallTitle_RestoresOriginal(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + writeAgySettingsFile(t, cfgDir, `{"title":{"type":"command","command":"entire hooks antigravity title-tee --wrap '~/bin/my-status.sh'"}}`) + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + want := "~/bin/my-status.sh" + if got != want { + t.Errorf("title.command after uninstall = %q, want %q", got, want) + } +} + +func TestUninstallTitle_RemovesBareTee(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + writeAgySettingsFile(t, cfgDir, `{"title":{"type":"command","command":"entire hooks antigravity title-tee"}}`) + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + if got != "" { + t.Errorf("title.command after bare-tee uninstall = %q, want empty", got) + } +} + +func TestUninstallTitle_LeavesForeignCommandAlone(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + writeAgySettingsFile(t, cfgDir, `{"title":{"type":"command","command":"~/bin/my-status.sh"}}`) + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + want := "~/bin/my-status.sh" + if got != want { + t.Errorf("title.command after foreign-command uninstall = %q, want %q", got, want) + } +} + +func TestUninstallTitle_LeavesUserWrappedTeeAlone(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + // User-authored wrapper that happens to contain the marker string. + const cmd = "my-wrapper.sh 'entire hooks antigravity title-tee'" + writeAgySettingsFile(t, cfgDir, `{"title":{"type":"command","command":"`+cmd+`"}}`) + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + if got != cmd { + t.Errorf("title.command = %q, want %q (user wrapper should be left alone)", got, cmd) + } +} + +func TestUninstallTitle_LeavesMalformedWrapAlone(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + // Contains the marker + a --wrap flag but unquoted (malformed) — safer to leave than delete. + const cmd = "entire hooks antigravity title-tee --wrap unquoted" + writeAgySettingsFile(t, cfgDir, `{"title":{"type":"command","command":"`+cmd+`"}}`) + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + if got != cmd { + t.Errorf("title.command = %q, want %q (malformed wrap should be left alone)", got, cmd) + } +} + +func TestInstallTitle_WrapsCommandContainingWrapSubstring(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + // Original command itself contains "--wrap fancy" — must survive round-trip. + const original = "~/bin/title.sh --wrap fancy" + origJSON := mustJSON(t, original) + content := `{"title":{"type":"command","command":` + string(origJSON) + `}}` + writeAgySettingsFile(t, cfgDir, content) + + if err := InstallTitleTee(); err != nil { + t.Fatalf("InstallTitleTee: %v", err) + } + + // Installed command should wrap the original. + installed := readTitleCommand(t, cfgDir) + want := "entire hooks antigravity title-tee --wrap '~/bin/title.sh --wrap fancy'" + if installed != want { + t.Errorf("installed title.command = %q, want %q", installed, want) + } + + // Uninstall should restore the original exactly. + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + got := readTitleCommand(t, cfgDir) + if got != original { + t.Errorf("round-trip: got %q, want %q", got, original) + } +} + +func TestUninstallTitle_RemovesBareLocalDevTee(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + // The bare local-dev tee command older versions wrote (local-dev mode was + // removed; see isBareTitleTeeCommand) — must still be removed, from any path. + localDevCmd := "go run '/some/other/worktree/cmd/entire/main.go' hooks antigravity title-tee" + cmdJSON := mustJSON(t, localDevCmd) + content := `{"title":{"type":"command","command":` + string(cmdJSON) + `}}` + writeAgySettingsFile(t, cfgDir, content) + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + if got != "" { + t.Errorf("title.command after localDev bare-tee uninstall = %q, want empty", got) + } +} + +func TestInstallUninstall_RoundTripsEmbeddedSingleQuotes(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + original := `echo 'hi there' | awk '{print $1}'` + origJSON := mustJSON(t, original) + content := `{"title":{"type":"command","command":` + string(origJSON) + `}}` + writeAgySettingsFile(t, cfgDir, content) + + if err := InstallTitleTee(); err != nil { + t.Fatalf("InstallTitleTee: %v", err) + } + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + if got != original { + t.Errorf("round-trip: got %q, want %q", got, original) + } +} + +// TestTitleTeeInstalled covers the three states the doctor check cares about: +// our marker present (true), no title key (false), and a foreign command (false). +func TestTitleTeeInstalled(t *testing.T) { + t.Run("configured", func(t *testing.T) { + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + if err := InstallTitleTee(); err != nil { + t.Fatalf("InstallTitleTee: %v", err) + } + if !TitleTeeInstalled() { + t.Error("TitleTeeInstalled() = false, want true after InstallTitleTee") + } + }) + + t.Run("absent", func(t *testing.T) { + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + // No settings.json at all. + if TitleTeeInstalled() { + t.Error("TitleTeeInstalled() = true, want false with no settings file") + } + // settings.json with no title key. + writeAgySettingsFile(t, cfgDir, `{"theme":"dark"}`) + if TitleTeeInstalled() { + t.Error("TitleTeeInstalled() = true, want false with no title key") + } + }) + + t.Run("foreign command", func(t *testing.T) { + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + writeAgySettingsFile(t, cfgDir, + `{"title":{"type":"command","command":"my-own-title-script.sh"}}`) + if TitleTeeInstalled() { + t.Error("TitleTeeInstalled() = true, want false for a foreign command") + } + }) +} + +// TestUninstallTitleTee_LocalDevFromOtherWorktree pins the cross-worktree +// uninstall: a localDev bare tee installed from worktree A embeds A's absolute +// main.go path, and `entire agent remove antigravity` may run from worktree B +// or outside a repo. The bare-command check must match by SHAPE (go run … +// hooks antigravity title-tee), not by re-resolving the local path at +// uninstall time — otherwise the global title entry is silently orphaned and +// agy spawns a failing `go run` on every state change after A is deleted. +func TestUninstallTitleTee_LocalDevFromOtherWorktree(t *testing.T) { + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + // Run from a non-repo CWD so localDevMainPath() cannot resolve the + // original worktree's path. + t.Chdir(t.TempDir()) + + settings := `{"title":{"type":"command","command":"go run '/deleted/worktree-a/cmd/entire/main.go' hooks antigravity title-tee"}}` + if err := os.WriteFile(filepath.Join(cfgDir, "settings.json"), []byte(settings), 0o600); err != nil { + t.Fatal(err) + } + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + data, err := os.ReadFile(filepath.Join(cfgDir, "settings.json")) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if _, ok := raw["title"]; ok { + t.Errorf("localDev bare tee from another worktree must be removed on uninstall, got %s", data) + } +} + +// TestUninstallTitleTee_UserWrapperLeftAlone pins the safety property the +// shape check must preserve: a user-authored wrapper that merely CONTAINS the +// tee command is not ours and must not be deleted. +func TestUninstallTitleTee_UserWrapperLeftAlone(t *testing.T) { + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + t.Chdir(t.TempDir()) + + settings := `{"title":{"type":"command","command":"my-wrapper.sh 'entire hooks antigravity title-tee'"}}` + if err := os.WriteFile(filepath.Join(cfgDir, "settings.json"), []byte(settings), 0o600); err != nil { + t.Fatal(err) + } + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + data, err := os.ReadFile(filepath.Join(cfgDir, "settings.json")) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if _, ok := raw["title"]; !ok { + t.Error("user-authored wrapper containing the tee marker must be left untouched") + } +} + +// TestInstallTitle_RefusesSymlinkedSettingsFile: settings.json is a name inside +// agy's config directory, and a symlink at it is refused rather than followed +// or replaced. Following it would merge the target's contents into what Entire +// writes; the atomic rename would then silently swap the user's link for a +// regular file. Both happen without a word, so the legible answer is to stop. +func TestInstallTitle_RefusesSymlinkedSettingsFile(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + target := filepath.Join(t.TempDir(), "real-settings.json") + if err := os.WriteFile(target, []byte(`{"theme":"dark"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(cfgDir, "settings.json")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + if err := InstallTitleTee(); err == nil { + t.Fatal("InstallTitleTee() error = nil, want refusal for a symlinked settings.json") + } + + // The link is intact and its target untouched. + info, err := os.Lstat(filepath.Join(cfgDir, "settings.json")) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatal("the user's symlink was replaced by a regular file") + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != `{"theme":"dark"}` { + t.Fatalf("link target was modified: %s", got) + } + if TitleTeeInstalled() { + t.Fatal("TitleTeeInstalled() = true through a symlink it must not read") + } +} + +// TestUninstallTitle_MissingConfigDirIsNoOp: a machine that never ran agy has +// no config directory at all, and uninstall must read that as nothing to do +// rather than as an error. +func TestUninstallTitle_MissingConfigDirIsNoOp(t *testing.T) { + // No t.Parallel — uses t.Setenv + t.Setenv(configDirEnv, filepath.Join(t.TempDir(), "never-created")) + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee() with no config dir: %v", err) + } + if TitleTeeInstalled() { + t.Fatal("TitleTeeInstalled() = true with no config dir") + } +} + +// TestInstallTitle_SerialisesOnTheSettingsLock: install and uninstall are a +// read-modify-write of agy's machine-global settings.json, so two entire +// processes (an add in one repo racing an add or remove in another) must take +// turns. Pinned by holding the lock externally and watching install wait. +func TestInstallTitle_SerialisesOnTheSettingsLock(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + release, err := lockAgySettings(true) + if err != nil { + t.Fatal(err) + } + + done := make(chan error, 1) + go func() { done <- InstallTitleTee() }() + + select { + case <-done: + t.Fatal("InstallTitleTee completed while another process held the settings lock") + case <-time.After(200 * time.Millisecond): + } + release() + select { + case err := <-done: + if err != nil { + t.Fatalf("InstallTitleTee after the lock was released: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("InstallTitleTee did not proceed after the lock was released") + } + if got, want := readTitleCommand(t, cfgDir), "entire hooks antigravity title-tee"; got != want { + t.Fatalf("title.command = %q, want %q", got, want) + } +} diff --git a/cmd/entire/cli/agent/antigravity/transcript.go b/cmd/entire/cli/agent/antigravity/transcript.go new file mode 100644 index 0000000000..54f9fc430e --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/transcript.go @@ -0,0 +1,494 @@ +package antigravity + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/osroot" +) + +// Compile-time interface assertions. +var ( + _ agent.PromptExtractor = (*AntigravityAgent)(nil) + _ agent.TranscriptAnalyzer = (*AntigravityAgent)(nil) + _ agent.LateTranscriptWriter = (*AntigravityAgent)(nil) +) + +// Antigravity 2.0 (agy) writes JSONL transcripts at +// ~/.gemini/antigravity-cli/brain//.system_generated/logs/transcript_full.jsonl (the hook payload sends transcript_full; agy also writes a truncated transcript.jsonl alongside it) +// The on-disk schema is a sequence of "step" objects: +// { +// "step_index": int, +// "source": "USER_EXPLICIT" | "SYSTEM" | "MODEL" | ..., +// "type": "USER_INPUT" | "CONVERSATION_HISTORY" | "PLANNER_RESPONSE" | ..., +// "status": "DONE" | ..., +// "created_at": RFC3339 timestamp, +// "content": string (optional — user request / model text), +// "tool_calls": [ { "name": string, "args": object } ] (optional) +// } +// Prompt extraction and field-aware modified-file/position analysis +// (TranscriptAnalyzer) are implemented below. ReadTranscript/Chunk/Reassemble +// remain JSONL passthrough, and token counting is handled out-of-band +// elsewhere. See testdata/transcript_sample.jsonl for a captured fixture. + +// agyStep is one line of agy's step-based JSONL transcript. +type agyStep struct { + StepIndex int `json:"step_index"` + Source string `json:"source"` + Type string `json:"type"` + Content string `json:"content"` + ToolCalls []agyStepToolCall `json:"tool_calls"` + // TruncatedFields is set by agy when it trimmed parts of the step while + // persisting it (observed on PLANNER_RESPONSE steps: ~0.8% of + // replace_file_content calls in a daily-driver corpus lose TargetFile while + // every other arg survives). Kept raw: only its presence matters here. + TruncatedFields json.RawMessage `json:"truncated_fields"` +} + +// truncated reports whether agy flagged the step as having truncated fields. +// Absent, null and empty-array all mean "intact". +func (s *agyStep) truncated() bool { + t := bytes.TrimSpace(s.TruncatedFields) + return len(t) > 0 && string(t) != "null" && string(t) != "[]" && string(t) != "{}" +} + +type agyStepToolCall struct { + Name string `json:"name"` + Args map[string]json.RawMessage `json:"args"` +} + +var userRequestRe = regexp.MustCompile(`(?s)\s*(.*?)\s*`) + +// extractUserRequest returns the inner text of the first block, +// or the whole trimmed content if no wrapper is present. +func extractUserRequest(content string) string { + if m := userRequestRe.FindStringSubmatch(content); m != nil { + return strings.TrimSpace(m[1]) + } + // No wrapper: assume the content is itself the prompt. A hypothetical + // metadata-only USER_INPUT step would surface verbatim — acceptable for v1. + return strings.TrimSpace(content) +} + +// forEachNonBlankLine iterates data's non-blank JSONL lines, counting them +// with the codex splitJSONL convention (blank lines skipped BEFORE counting), +// and calls fn for each line past fromOffset. Returns the total non-blank +// line count. +// +// This is the single owner of agy's transcript-offset metric: the position +// one method stores (GetTranscriptPosition → CheckpointTranscriptStart) is +// consumed as fromOffset by the others (ExtractPrompts, +// ExtractModifiedFilesFromOffset), so the counting MUST stay byte-identical +// across all of them — hence one iterator instead of three hand-synced loops. +func forEachNonBlankLine(data []byte, fromOffset int, fn func(raw []byte)) int { + lineNum := 0 + for _, raw := range bytes.Split(data, []byte("\n")) { + if len(bytes.TrimSpace(raw)) == 0 { + continue + } + lineNum++ + if fn != nil && lineNum > fromOffset { + fn(raw) + } + } + return lineNum +} + +// ExtractPrompts implements agent.PromptExtractor. agy's PreInvocation hook +// carries no prompt, so the user prompt is recovered from the transcript's +// USER_INPUT steps. fromOffset is a count of non-blank lines already consumed. +func (a *AntigravityAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { + // Every analyzer reads through ReadTranscript: one contained read when the + // path is inside agy's brain directory, and the package's single ratcheted + // unconfined read otherwise (see agent/transcript_read_guard_test.go). + data, err := a.ReadTranscript(sessionRef) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("antigravity: read transcript for prompts: %w", err) + } + return extractPromptsFromContent(data, fromOffset), nil +} + +// ExtractPromptsFromTranscript implements agent.TranscriptPromptExtractor over +// transcript bytes the caller already holds — condensation's copy of the +// transcript — with the same offset metric as ExtractPrompts. It never reads a +// path, so the prompts it returns always describe the bytes being checkpointed. +func (a *AntigravityAgent) ExtractPromptsFromTranscript(content []byte, fromOffset int) ([]string, error) { //nolint:unparam // the error return is the agent.TranscriptPromptExtractor contract + return extractPromptsFromContent(content, fromOffset), nil +} + +// extractPromptsFromContent is the shared body of both prompt extractors: the +// USER_REQUEST text of every USER_INPUT step after fromOffset non-blank lines. +func extractPromptsFromContent(data []byte, fromOffset int) []string { + var prompts []string + forEachNonBlankLine(data, fromOffset, func(raw []byte) { + var step agyStep + if json.Unmarshal(raw, &step) != nil { + return + } + if step.Type != "USER_INPUT" { + return + } + if text := extractUserRequest(step.Content); text != "" { + prompts = append(prompts, text) + } + }) + return prompts +} + +// CondensedStep is one summarizable unit of an agy transcript. It exists for +// the summarizer (cmd/entire/cli/summarize), which owns the prompt shape but +// not agy's wire format: without it, agy transcripts fell through to the +// Claude JSONL parser, condensed to nothing, and `explain --generate` reported +// "transcript has no content to summarize" for a 2.4 KB transcript. +type CondensedStep struct { + // Role is one of the CondensedRole* constants. + Role string + // Text is the user request (unwrapped from ) or the + // assistant's text; empty for tool steps. + Text string + // ToolName and ToolArgs describe a tool call; ToolArgs values are decoded + // from agy's double-encoded JSON strings where possible. + ToolName string + ToolArgs map[string]any +} + +// Roles of a CondensedStep. +const ( + CondensedRoleUser = "user" + CondensedRoleAssistant = "assistant" + CondensedRoleTool = "tool" +) + +// CondenseTranscript reduces agy step JSONL to user requests, assistant text +// and tool calls, in order. GENERIC steps (tool output) and SYSTEM_MESSAGE +// steps (agy's own injected notices) are skipped; malformed lines are skipped. +func CondenseTranscript(content []byte) []CondensedStep { + var steps []CondensedStep + forEachNonBlankLine(content, 0, func(raw []byte) { + var step agyStep + if json.Unmarshal(raw, &step) != nil { + return + } + switch step.Type { + case "USER_INPUT": + if text := extractUserRequest(step.Content); text != "" { + steps = append(steps, CondensedStep{Role: CondensedRoleUser, Text: text}) + } + case "PLANNER_RESPONSE": + if text := strings.TrimSpace(step.Content); text != "" { + steps = append(steps, CondensedStep{Role: CondensedRoleAssistant, Text: text}) + } + for _, tc := range step.ToolCalls { + if tc.Name == "" { + continue + } + steps = append(steps, CondensedStep{Role: CondensedRoleTool, ToolName: tc.Name, ToolArgs: decodeAgyArgs(tc.Args)}) + } + } + }) + return steps +} + +// decodeAgyArgs decodes a tool call's args, undoing agy's double encoding of +// string values (decodeAgyString) and leaving other JSON values as decoded Go +// values. Best-effort: an undecodable value is dropped. +func decodeAgyArgs(args map[string]json.RawMessage) map[string]any { + if len(args) == 0 { + return nil + } + out := make(map[string]any, len(args)) + for key, raw := range args { + if s, ok := decodeAgyStringOK(raw); ok { + out[key] = s + continue + } + var v any + if json.Unmarshal(raw, &v) == nil && v != nil { + out[key] = v + } + } + return out +} + +// GetTranscriptPosition implements agent.TranscriptAnalyzer. It returns the +// number of non-blank JSONL lines in the transcript, which the framework uses +// as a stable offset to bound subsequent extraction to a single checkpoint +// range. A missing file yields (0, nil) so a not-yet-flushed transcript (agy +// writes asynchronously) doesn't fail the hook. +func (a *AntigravityAgent) GetTranscriptPosition(path string) (int, error) { + if path == "" { + return 0, nil + } + data, err := a.ReadTranscript(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return 0, nil + } + return 0, fmt.Errorf("antigravity: transcript position: %w", err) + } + return forEachNonBlankLine(data, 0, nil), nil +} + +// CountTranscriptPosition implements agent.LateTranscriptWriter: agy writes +// its transcript only after the Stop hook, and its offset metric counts +// non-blank lines. Delegating to forEachNonBlankLine keeps this byte-identical +// with GetTranscriptPosition/ExtractPrompts (see the iterator's doc comment). +func (a *AntigravityAgent) CountTranscriptPosition(content []byte) int { + return forEachNonBlankLine(content, 0, nil) +} + +// SliceTranscriptFromPosition implements agent.LateTranscriptWriter. It scopes +// content the same way CountTranscriptPosition counts it, through the one +// iterator that owns the metric. transcript.SliceFromLine cannot stand in: it +// counts raw \n-delimited lines, so a single interior blank line puts the +// summary's window a line off from the offset that was stored for it. +func (a *AntigravityAgent) SliceTranscriptFromPosition(content []byte, startOffset int) []byte { + var kept [][]byte + forEachNonBlankLine(content, startOffset, func(raw []byte) { + kept = append(kept, raw) + }) + if len(kept) == 0 { + return nil + } + return append(bytes.Join(kept, []byte("\n")), '\n') +} + +// ExtractModifiedFilesFromOffset implements agent.TranscriptAnalyzer. It scans +// agy step lines after startOffset for mutating tool calls and returns the +// target file paths they touch, deduplicated, alongside the new line position. +// +// Path convention: returned paths are ABSOLUTE and symlink-resolved — the same +// shape lifecycle.go's parsePreToolUse records into FilesTouched. The framework +// relativizes downstream via FilterAndNormalizePaths -> paths.ToRelativePath +// against the worktree root, so we must NOT pre-relativize here. We mirror +// parsePreToolUse exactly: decode the double-encoded TargetFile arg, then +// resolveAgySymlinks so the path matches what attribution diffs against (e.g. +// macOS /tmp -> /private/tmp). Both helpers live in lifecycle.go (same package) +// and are reused, not duplicated. +// +// The blank-skip -> lineNum++ -> (lineNum <= startOffset) ordering matches +// ExtractPrompts so positions stay consistent across analyzer methods. +func (a *AntigravityAgent) ExtractModifiedFilesFromOffset(ctx context.Context, path string, startOffset int) (files []string, currentPosition int, err error) { + if path == "" { + return nil, 0, nil + } + data, readErr := a.ReadTranscript(path) + if readErr != nil { + if errors.Is(readErr, fs.ErrNotExist) { + return nil, 0, nil + } + return nil, 0, fmt.Errorf("antigravity: extract modified files: %w", readErr) + } + seen := map[string]bool{} + dropped := 0 + lineNum := forEachNonBlankLine(data, startOffset, func(raw []byte) { + var step agyStep + if json.Unmarshal(raw, &step) != nil { + return + } + for _, tc := range step.ToolCalls { + switch tc.Name { + case "write_to_file", "replace_file_content", "multi_replace_file_content": + target := resolveAgySymlinks(decodeAgyString(tc.Args["TargetFile"])) + if target == "" { + // A mutating call with no decodable TargetFile is a file we + // know was modified but cannot name. When agy flagged the + // step as truncated that is the cause (TargetFile was + // trimmed away); say so instead of silently skipping, because + // this analyzer feeds the fallbacks (late-flush, first-turn + // mid-turn commit) where git status may not cover the file. + // The file is not necessarily lost: the live PreToolUse hook + // saw the untruncated call, and a later call on the same + // file names it — this list alone is what is incomplete. + if step.truncated() { + dropped++ + logging.Warn(logging.WithComponent(ctx, "antigravity"), + "transcript step truncated by agy; a modified file cannot be named from this step (it may still be captured by the PreToolUse hook or a later call)", + slog.String("transcript", path), + slog.Int("step_index", step.StepIndex), + slog.String("tool", tc.Name)) + } + continue + } + if !seen[target] { + seen[target] = true + files = append(files, target) + } + } + } + }) + if dropped > 0 { + logging.Warn(logging.WithComponent(ctx, "antigravity"), + "antigravity transcript-derived file list is incomplete (truncated steps); hook-captured files are unaffected", + slog.String("transcript", path), + slog.Int("dropped_truncated_calls", dropped)) + } + return files, lineNum, nil +} + +// ReadTranscript reads a transcript agy's hook payload named. When the path is +// inside agy's brain directory — where every real payload points — the read +// goes through the agent's SessionStore: a name inside a trusted root, no +// symlink followed at any component. A path outside it is the read-side gap +// docs/development/filesystem-safety.md describes (closing it needs RepoPath on +// HookInput), and it stays the one unconfined read the ratchet in +// agent/transcript_read_guard_test.go allows this file; it is never anchored on +// the path's own parent, which would contain nothing while looking like it did. +func (a *AntigravityAgent) ReadTranscript(sessionRef string) ([]byte, error) { + if store, name, err := a.transcriptStore(sessionRef); err == nil { + data, readErr := store.ReadFile(name) + if readErr != nil { + return nil, fmt.Errorf("antigravity: read transcript: %w", readErr) + } + return data, nil + } + data, err := os.ReadFile(sessionRef) //nolint:gosec // the ratcheted unconfined transcript read; see doc comment + if err != nil { + return nil, fmt.Errorf("antigravity: read transcript: %w", err) + } + return data, nil +} + +func (a *AntigravityAgent) ChunkTranscript(_ context.Context, content []byte, maxSize int) ([][]byte, error) { + chunks, err := agent.ChunkJSONL(content, maxSize) + if err != nil { + return nil, fmt.Errorf("antigravity: chunk transcript: %w", err) + } + return chunks, nil +} + +func (a *AntigravityAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + return agent.ReassembleJSONL(chunks), nil +} + +// PrepareTranscript implements the optional TranscriptPreparer interface. The +// framework calls this in handleLifecycleTurnEnd BEFORE its fileExists check +// — so we use it to handle agy's asynchronous transcript write. +// +// Background: agy writes its transcript file at +// +// ~/.gemini/antigravity-cli/brain//.system_generated/logs/transcript_full.jsonl +// +// AFTER the Stop hook fires (sometimes seconds later, depending on session +// shutdown timing). Our TurnEnd event maps to Stop, so we routinely race the +// transcript write. Without PrepareTranscript, the framework's fileExists +// check fails with "transcript file not found" and our hook returns exit 1, +// terminating agy's agent turn. +// +// We briefly wait for the real transcript first. If it is still missing, +// we materialise an empty placeholder. files_touched is already captured via +// the PreToolUse hook (independent of transcript content), so condensation can +// still produce a meaningful checkpoint from an empty transcript. +// +// The placeholder is a WRITE on a hook-supplied path, so it is created only +// inside agy's brain directory (the agent's SessionStore): parents with +// MkdirAllNoSymlink, the file with O_EXCL through the root, and a path outside +// that directory refused rather than anchored on its own parent +// (docs/development/filesystem-safety.md, "The Root Anchors"). Tests place +// transcripts under ENTIRE_TEST_ANTIGRAVITY_BRAIN_DIR for the same reason. +func (a *AntigravityAgent) PrepareTranscript(ctx context.Context, transcriptRef string) error { + if transcriptRef == "" { + return nil + } + store, name, err := a.transcriptStore(transcriptRef) + if err != nil { + return fmt.Errorf("antigravity: prepare transcript: %w", err) + } + + deadline := time.Now().Add(1 * time.Second) + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + deadline = ctxDeadline + } + + // Poll until the transcript has content, the deadline passes, or ctx ends + // (the select below wakes at once on an already-cancelled ctx). A cancelled + // ctx stops the WAIT, not the fallback: the lifecycle only logs this + // function's error and then requires the file to exist, so returning early + // here would fail the Stop hook — the exact outcome the placeholder exists + // to prevent. Both exits fall through to the exclusive create. +poll: + for { + info, err := store.Lstat(name) + if err == nil { + // Refuse a symlink rather than read through it: the path came from + // agy's hook payload, and a link here would send the condensation + // read wherever it points (docs/development/filesystem-safety.md). + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("antigravity: transcript %s: %w", transcriptRef, osroot.ErrSymlinkedPath) + } + if info.Size() > 0 { + return nil + } + } else if !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("antigravity: stat transcript: %w", err) + } + + if !time.Now().Before(deadline) { + break + } + + wait := 50 * time.Millisecond + if remaining := time.Until(deadline); remaining < wait { + wait = remaining + } + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + break poll + case <-timer.C: + } + } + + // Exclusive create: if agy wrote the real transcript between the last poll + // and here, it wins and the placeholder is skipped — never replaced. + if err := store.CreateExclusive(name, 0o600); err != nil { + if errors.Is(err, fs.ErrExist) { + return nil + } + return fmt.Errorf("antigravity: create empty transcript placeholder: %w", err) + } + return nil +} + +// transcriptStore resolves transcriptRef to agy's brain-directory session store +// (GetSessionDir, which ignores the repo path: agy keeps one brain per user) +// and a name inside it. A path outside the store is reported through +// agent.ErrOutsideSessionStore; it is never re-anchored on the path's own +// parent, the derived base the filesystem-safety rules refuse because it +// contains nothing while looking like it does. +func (a *AntigravityAgent) transcriptStore(transcriptRef string) (*agent.SessionStore, string, error) { + // agy's payload always carries an absolute path. SessionStore.Name would + // accept a relative one as a name inside the store, which is not what a + // relative path means to the callers that pass one (fixtures resolved + // against the working directory), so it is classified as outside instead. + if !filepath.IsAbs(transcriptRef) { + return nil, "", fmt.Errorf("%w: %s is not absolute", agent.ErrOutsideSessionStore, transcriptRef) + } + store, err := agent.OpenSessionStore(a, "") + if err != nil { + return nil, "", err //nolint:wrapcheck // the store already names the agent and directory + } + name, err := store.Name(transcriptRef) + if err != nil { + return nil, "", err //nolint:wrapcheck // preserved for errors.Is(err, agent.ErrOutsideSessionStore) + } + return store, name, nil +} diff --git a/cmd/entire/cli/agent/antigravity/transcript_test.go b/cmd/entire/cli/agent/antigravity/transcript_test.go new file mode 100644 index 0000000000..862e3ab637 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/transcript_test.go @@ -0,0 +1,572 @@ +package antigravity + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestChunkAndReassemble_RoundTrip(t *testing.T) { + t.Parallel() + a := &AntigravityAgent{} + original := []byte(`{"role":"user","content":"hi"}` + "\n" + `{"role":"assistant","content":"hello"}` + "\n") + chunks, err := a.ChunkTranscript(context.Background(), original, 1024) + if err != nil { + t.Fatal(err) + } + out, err := a.ReassembleTranscript(chunks) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(out, original) { + t.Errorf("round-trip mismatch:\n in: %q\n out: %q", original, out) + } +} + +// brainTranscriptPath points the agent's session store (GetSessionDir) at a +// fresh brain directory and returns a transcript path inside it, in agy's +// layout. PrepareTranscript materialises a placeholder only inside that store +// (a write on a hook-supplied path), so every test that expects one has to +// place the path where agy would. Uses t.Setenv, so callers cannot t.Parallel. +func brainTranscriptPath(t *testing.T) string { + t.Helper() + brain := filepath.Join(t.TempDir(), ".gemini", "antigravity-cli", "brain") + t.Setenv(antigravityTestBrainDirEnv, brain) + return (&AntigravityAgent{}).ResolveSessionFile(brain, "conv") +} + +// TestPrepareTranscript_AbsentFileCreatesPlaceholder verifies the +// TranscriptPreparer creates an empty file when agy hasn't flushed its +// transcript yet (the common case at Stop hook time). Without this, the +// framework's fileExists check in handleLifecycleTurnEnd would fail and our +// hook would exit non-zero, aborting agy's turn. +func TestPrepareTranscript_AbsentFileCreatesPlaceholder(t *testing.T) { + // Non-existent parent dirs (the brain directory itself included) also + // exercise directory creation through the store. + path := brainTranscriptPath(t) + a := &AntigravityAgent{} + if err := a.PrepareTranscript(context.Background(), path); err != nil { + t.Fatalf("PrepareTranscript: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("placeholder not created: %v", err) + } + if info.Size() != 0 { + t.Errorf("placeholder size = %d, want 0 (empty)", info.Size()) + } +} + +// TestPrepareTranscript_PresentFilePreserved verifies PrepareTranscript leaves +// an already-written transcript untouched. This is the case when agy's writer +// races ahead of the Stop hook. +func TestPrepareTranscript_PresentFilePreserved(t *testing.T) { + path := brainTranscriptPath(t) + original := []byte(`{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE"}` + "\n") + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, original, 0o600); err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + if err := a.PrepareTranscript(context.Background(), path); err != nil { + t.Fatalf("PrepareTranscript: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Errorf("PrepareTranscript should not overwrite an existing transcript\n before: %q\n after: %q", original, got) + } +} + +func TestPrepareTranscript_WaitsForDelayedTranscript(t *testing.T) { + path := brainTranscriptPath(t) + original := []byte(`{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE"}` + "\n") + + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + writeErr := make(chan error, 1) + go func() { + time.Sleep(100 * time.Millisecond) + writeErr <- os.WriteFile(path, original, 0o600) + }() + + a := &AntigravityAgent{} + if err := a.PrepareTranscript(context.Background(), path); err != nil { + t.Fatalf("PrepareTranscript: %v", err) + } + if err := <-writeErr; err != nil { + t.Fatalf("delayed write: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Fatalf("PrepareTranscript() should wait for delayed transcript, got %q want %q", got, original) + } +} + +// TestPrepareTranscript_RefusesPathOutsideBrainDir: the placeholder is a write +// on a hook-supplied path, so it lands inside agy's brain directory or nowhere. +// Anchoring on the path's own parent instead would contain nothing. +func TestPrepareTranscript_RefusesPathOutsideBrainDir(t *testing.T) { + brainTranscriptPath(t) // pins the store somewhere else + outside := filepath.Join(t.TempDir(), "elsewhere", "transcript_full.jsonl") + + a := &AntigravityAgent{} + err := a.PrepareTranscript(context.Background(), outside) + if !errors.Is(err, agent.ErrOutsideSessionStore) { + t.Fatalf("PrepareTranscript() error = %v, want agent.ErrOutsideSessionStore", err) + } + if _, statErr := os.Lstat(outside); !os.IsNotExist(statErr) { + t.Fatalf("a placeholder must not be created outside the store; Lstat err = %v", statErr) + } +} + +// TestPrepareTranscript_EmptyRefIsNoOp verifies an empty transcript path is +// a graceful no-op (defensive — the framework probably never passes empty, +// but agents have been bitten by empty refs in the past). +func TestPrepareTranscript_EmptyRefIsNoOp(t *testing.T) { + t.Parallel() + a := &AntigravityAgent{} + if err := a.PrepareTranscript(context.Background(), ""); err != nil { + t.Errorf("PrepareTranscript(\"\") should not error, got %v", err) + } +} + +func TestExtractPrompts_StripsUserRequestWrapper(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "transcript.jsonl") + lines := []string{ + `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nread a.txt and exit\n\n\nThe current local time is: x.\n"}`, + `{"step_index":1,"source":"SYSTEM","type":"CONVERSATION_HISTORY","status":"DONE"}`, + `{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","content":"ok"}`, + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + prompts, err := a.ExtractPrompts(path, 0) + if err != nil { + t.Fatalf("ExtractPrompts: %v", err) + } + if len(prompts) != 1 { + t.Fatalf("want 1 prompt, got %d: %#v", len(prompts), prompts) + } + if prompts[0] != "read a.txt and exit" { + t.Errorf("want stripped request, got %q", prompts[0]) + } +} + +func TestExtractPrompts_RespectsOffsetAndMissingFile(t *testing.T) { + t.Parallel() + a := &AntigravityAgent{} + got, err := a.ExtractPrompts(filepath.Join(t.TempDir(), "nope.jsonl"), 0) + if err != nil || got != nil { + t.Fatalf("missing file: want (nil,nil), got (%#v,%v)", got, err) + } +} + +func TestExtractPrompts_RealFixture(t *testing.T) { + t.Parallel() + a := &AntigravityAgent{} + prompts, err := a.ExtractPrompts("testdata/transcript_sample.jsonl", 0) + if err != nil { + t.Fatalf("ExtractPrompts: %v", err) + } + if len(prompts) != 1 || prompts[0] != "read a.txt and tell me what it says, then exit" { + t.Fatalf("unexpected prompts: %#v", prompts) + } +} + +func TestExtractPrompts_SkipsLinesAtOrBelowOffset(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "t.jsonl") + lines := []string{ + `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"first"}`, + `{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","content":"ok"}`, + `{"step_index":2,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"second"}`, + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + + // offset 0 → both prompts + all, err := a.ExtractPrompts(path, 0) + if err != nil { + t.Fatalf("ExtractPrompts(0): %v", err) + } + if len(all) != 2 || all[0] != "first" || all[1] != "second" { + t.Fatalf("offset 0: want [first second], got %#v", all) + } + + // offset 1 → first non-blank line consumed, so only the second USER_INPUT remains + rest, err := a.ExtractPrompts(path, 1) + if err != nil { + t.Fatalf("ExtractPrompts(1): %v", err) + } + if len(rest) != 1 || rest[0] != "second" { + t.Fatalf("offset 1: want [second], got %#v", rest) + } +} + +func TestGetTranscriptPosition_CountsLines(t *testing.T) { + t.Parallel() + a := &AntigravityAgent{} + pos, err := a.GetTranscriptPosition("testdata/transcript_sample.jsonl") + if err != nil { + t.Fatal(err) + } + if pos <= 0 { + t.Fatalf("want > 0 lines, got %d", pos) + } + if p, e := a.GetTranscriptPosition(filepath.Join(t.TempDir(), "no.jsonl")); p != 0 || e != nil { + t.Fatalf("missing: want (0,nil) got (%d,%v)", p, e) + } +} + +func TestExtractModifiedFiles_FromToolCalls(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "t.jsonl") + lines := []string{ + `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"go"}`, + `{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"write_to_file","args":{"TargetFile":"\"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/repo/a.txt\"","Overwrite":"true"}}]}`, + `{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"replace_file_content","args":{"TargetFile":"\"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/repo/b.txt\""}}]}`, + `{"step_index":3,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"list_dir","args":{"DirectoryPath":"\"/repo\""}}]}`, + // Re-mutate /repo/a.txt on a later step: must be deduplicated, not double-counted. + `{"step_index":4,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"write_to_file","args":{"TargetFile":"\"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/repo/a.txt\"","Overwrite":"true"}}]}`, + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + files, pos, err := a.ExtractModifiedFilesFromOffset(context.Background(), path, 0) + if err != nil { + t.Fatal(err) + } + if pos != 5 { + t.Errorf("want pos 5, got %d", pos) + } + want := map[string]bool{"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/repo/a.txt": true, "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/repo/b.txt": true} + if len(files) != 2 { + t.Fatalf("want 2 modified files (deduped), got %#v", files) + } + for _, f := range files { + if !want[f] { + t.Errorf("unexpected modified file %q", f) + } + } +} + +// TestExtractModifiedFiles_PathConvention pins the path convention: the +// analyzer returns ABSOLUTE, symlink-resolved paths (the same shape +// lifecycle.go's parsePreToolUse records into FilesTouched). The framework +// relativizes downstream via FilterAndNormalizePaths -> paths.ToRelativePath +// against the worktree root, so returning absolute here is correct and must +// NOT be pre-relativized. This test creates a real file under a temp dir and +// asserts the returned path is the absolute, symlink-resolved location. +func TestExtractModifiedFiles_PathConvention(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Resolve symlinks on the temp dir itself (macOS /tmp -> /private/tmp) so + // our expectation matches what resolveAgySymlinks produces. + resolvedDir, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(resolvedDir, "sub", "real.txt") + if mkErr := os.MkdirAll(filepath.Dir(target), 0o750); mkErr != nil { + t.Fatal(mkErr) + } + if wErr := os.WriteFile(target, []byte("x"), 0o600); wErr != nil { + t.Fatal(wErr) + } + + transcript := filepath.Join(dir, "t.jsonl") + // Note the double-encoded TargetFile arg, mirroring agy's wire format. + line := `{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"write_to_file","args":{"TargetFile":` + + jsonQuote(t, jsonQuote(t, target)) + `,"Overwrite":"true"}}]}` + if wErr := os.WriteFile(transcript, []byte(line+"\n"), 0o600); wErr != nil { + t.Fatal(wErr) + } + + a := &AntigravityAgent{} + files, _, err := a.ExtractModifiedFilesFromOffset(context.Background(), transcript, 0) + if err != nil { + t.Fatal(err) + } + if len(files) != 1 { + t.Fatalf("want 1 file, got %#v", files) + } + if !filepath.IsAbs(files[0]) { + t.Errorf("expected an absolute path, got %q", files[0]) + } + if files[0] != target { + t.Errorf("want absolute symlink-resolved path %q, got %q", target, files[0]) + } +} + +// jsonQuote returns s wrapped as a JSON string literal (used to build the +// double-encoded TargetFile arg in the path-convention test). +func jsonQuote(t *testing.T, s string) string { + t.Helper() + b, err := json.Marshal(s) + if err != nil { + t.Fatalf("jsonQuote(%q): %v", s, err) + } + return string(b) +} + +// TestExtractModifiedFiles_TruncatedStepDoesNotPanicAndKeepsOthers pins the +// degrade path for agy's `truncated_fields`: a mutating tool call whose +// TargetFile was trimmed away (observed live, ~0.8% of replace_file_content +// calls in a daily-driver corpus) must not abort extraction or poison the +// other files in the same range. The dropped call is reported via a WARN log +// (not asserted here; the logging package has no capture hook) — the +// contract this test locks in is "no error, other files intact, position +// still advances". +func TestExtractModifiedFiles_TruncatedStepDoesNotPanicAndKeepsOthers(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "t.jsonl") + lines := []string{ + `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"go"}`, + // TargetFile trimmed by agy; every other arg survived. + `{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","truncated_fields":["tool_calls[0].args.TargetFile"],"tool_calls":[{"name":"replace_file_content","args":{"ReplacementChunks":"\"[]\"","TargetContent":"\"x\""}}]}`, + `{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"write_to_file","args":{"TargetFile":"\"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/repo/b.txt\""}}]}`, + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + files, pos, err := a.ExtractModifiedFilesFromOffset(context.Background(), path, 0) + if err != nil { + t.Fatalf("truncated step must degrade, not error: %v", err) + } + if pos != 3 { + t.Errorf("want pos 3, got %d", pos) + } + if len(files) != 1 || files[0] != "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/repo/b.txt" { + t.Fatalf("want only the intact file, got %#v", files) + } +} + +func TestAgyStepTruncated(t *testing.T) { + t.Parallel() + cases := map[string]bool{ + ``: false, + `null`: false, + `[]`: false, + `["tool_calls[0].args.TargetFile"]`: true, + `{"tool_calls":["TargetFile"]}`: true, + } + for raw, want := range cases { + step := agyStep{TruncatedFields: json.RawMessage(raw)} + if got := step.truncated(); got != want { + t.Errorf("truncated_fields=%s: truncated() = %v, want %v", raw, got, want) + } + } +} + +// A cancelled context must still leave the placeholder behind: the lifecycle +// only logs PrepareTranscript's error and then requires the file to exist, so +// an early return here would fail the Stop hook the placeholder exists to save. +func TestPrepareTranscript_CancelledContextStillCreatesPlaceholder(t *testing.T) { + path := brainTranscriptPath(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + a := &AntigravityAgent{} + if err := a.PrepareTranscript(ctx, path); err != nil { + t.Fatalf("PrepareTranscript with cancelled ctx: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("placeholder missing after cancelled ctx: %v", err) + } + if info.Size() != 0 { + t.Fatalf("placeholder size = %d, want 0", info.Size()) + } +} + +// TestPrepareTranscript_RefusesSymlinkedTranscript: a symlink at the transcript +// path is refused rather than followed (filesystem-safety.md). A Stat would have +// reported the target's size and, for a dangling link, created a file at the far +// end of it; the store's Lstat reports the link itself and PrepareTranscript +// stops there, leaving the link's target untouched. +func TestPrepareTranscript_RefusesSymlinkedTranscript(t *testing.T) { + path := brainTranscriptPath(t) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "victim.jsonl") + if err := os.Symlink(target, path); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + a := &AntigravityAgent{} + if err := a.PrepareTranscript(context.Background(), path); err == nil { + t.Fatal("PrepareTranscript() error = nil, want refusal for a symlinked transcript") + } + if _, statErr := os.Lstat(target); !os.IsNotExist(statErr) { + t.Fatalf("nothing may be created at the link's target; Lstat err = %v", statErr) + } +} + +// TestReadTranscript_InsideBrainDirRefusesSymlink: a transcript inside agy's +// brain directory is read through the session store, so a symlink there is +// refused instead of followed to wherever it points. Outside the store the +// read is the ratcheted unconfined one (agent/transcript_read_guard_test.go). +func TestReadTranscript_InsideBrainDirRefusesSymlink(t *testing.T) { + path := brainTranscriptPath(t) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "secret.jsonl") + if err := os.WriteFile(target, []byte(`{"step_index":0,"type":"USER_INPUT","content":"leak"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + a := &AntigravityAgent{} + if _, err := a.ReadTranscript(path); err == nil { + t.Fatal("ReadTranscript() error = nil, want refusal for a symlinked transcript inside the store") + } + prompts, err := a.ExtractPrompts(path, 0) + if err == nil || len(prompts) != 0 { + t.Fatalf("ExtractPrompts() = %v, %v; want refusal and no prompts", prompts, err) + } + if _, posErr := a.GetTranscriptPosition(path); posErr == nil { + t.Fatal("GetTranscriptPosition() error = nil, want refusal") + } +} + +// The bytes extractor is what condensation uses; it must agree with the +// path-based one line for line, including the offset metric (non-blank lines). +func TestExtractPromptsFromTranscript_MatchesPathBasedExtractor(t *testing.T) { + t.Parallel() + content := []byte(`{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nfirst ask\n"} + +{"step_index":1,"type":"PLANNER_RESPONSE","status":"DONE"} +{"step_index":2,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nadd another\n"} +`) + a := &AntigravityAgent{} + all, err := a.ExtractPromptsFromTranscript(content, 0) + if err != nil || len(all) != 2 || all[0] != "first ask" || all[1] != "add another" { + t.Fatalf("offset 0: got %v, %v", all, err) + } + later, err := a.ExtractPromptsFromTranscript(content, 2) + if err != nil || len(later) != 1 || later[0] != "add another" { + t.Fatalf("offset 2 (after two non-blank lines): got %v, %v", later, err) + } + + path := filepath.Join(t.TempDir(), "t.jsonl") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + fromPath, err := a.ExtractPrompts(path, 2) + if err != nil || len(fromPath) != 1 || fromPath[0] != later[0] { + t.Fatalf("path-based extractor disagrees: %v, %v", fromPath, err) + } +} + +// CondenseTranscript is what the summarizer sees. Shapes are the ones agy 1.2.7 +// really writes: a wrapped USER_REQUEST, a PLANNER_RESPONSE carrying only +// tool_calls with double-encoded args, a GENERIC tool-output step (skipped), +// and a PLANNER_RESPONSE with the assistant's text. +func TestCondenseTranscript_RealStepShapes(t *testing.T) { + t.Parallel() + content := []byte(`{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nCreate red.md\n"} +{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","tool_calls":[{"name":"write_to_file","args":{"TargetFile":"\"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/ws/red.md\"","Overwrite":"false"}}]} +{"step_index":2,"source":"MODEL","type":"GENERIC","status":"DONE","content":"Created At: ..."} +{"step_index":3,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","content":"Created [red.md](file:///ws/red.md)."} +{"step_index":4,"source":"SYSTEM","type":"SYSTEM_MESSAGE","status":"DONE","content":"not from the user"} +not json +`) + steps := CondenseTranscript(content) + if len(steps) != 3 { + t.Fatalf("got %d steps, want 3: %+v", len(steps), steps) + } + if steps[0].Role != CondensedRoleUser || steps[0].Text != "Create red.md" { + t.Errorf("step 0 = %+v, want the unwrapped user request", steps[0]) + } + if steps[1].Role != CondensedRoleTool || steps[1].ToolName != "write_to_file" || steps[1].ToolArgs["TargetFile"] != "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/ws/red.md" { + t.Errorf("step 1 = %+v, want the tool call with its TargetFile decoded", steps[1]) + } + if steps[2].Role != CondensedRoleAssistant || steps[2].Text != "Created [red.md](file:///ws/red.md)." { + t.Errorf("step 2 = %+v, want the assistant text", steps[2]) + } +} + +// agy double-encodes string args, so an empty string arrives as the raw value +// `"\"\""`. It must survive as "", not as the two-character literal `""` that +// a plain decode of the raw value produces — tool args go into generated +// summaries, where a corrupted value reads as real content. +func TestDecodeAgyArgs_PreservesEmptyStrings(t *testing.T) { + t.Parallel() + + got := decodeAgyArgs(map[string]json.RawMessage{ + "doubleEmpty": json.RawMessage(`"\"\""`), + "plainEmpty": json.RawMessage(`""`), + "doubleValue": json.RawMessage(`"\"hi\""`), + "number": json.RawMessage(`42`), + }) + + for key, want := range map[string]any{ + "doubleEmpty": "", + "plainEmpty": "", + "doubleValue": "hi", + "number": float64(42), + } { + if got[key] != want { + t.Errorf("decodeAgyArgs()[%q] = %#v, want %#v", key, got[key], want) + } + } +} + +// The offset stored for a checkpoint counts non-blank lines, so the slice that +// scopes a summary to that checkpoint has to count them the same way. A raw +// \n-line slicer drifts by one for each interior blank line, which silently +// puts the summary's window over the wrong turns. +func TestSliceTranscriptFromPosition_IgnoresInteriorBlankLines(t *testing.T) { + t.Parallel() + + const tail = `{"n":3}` + "\n" + `{"n":4}` + "\n" + dense := `{"n":1}` + "\n" + `{"n":2}` + "\n" + tail + sparse := `{"n":1}` + "\n\n" + `{"n":2}` + "\n \n" + tail + + a := &AntigravityAgent{} + if got := a.CountTranscriptPosition([]byte(sparse)); got != 4 { + t.Fatalf("CountTranscriptPosition(sparse) = %d, want 4", got) + } + + for name, content := range map[string]string{"dense": dense, "sparse": sparse} { + if got := string(a.SliceTranscriptFromPosition([]byte(content), 2)); got != tail { + t.Errorf("SliceTranscriptFromPosition(%s, 2) = %q, want %q", name, got, tail) + } + } + + if got := a.SliceTranscriptFromPosition([]byte(dense), 4); got != nil { + t.Errorf("nothing past the end should slice to nil, got %q", got) + } +} diff --git a/cmd/entire/cli/agent/antigravity/types.go b/cmd/entire/cli/agent/antigravity/types.go new file mode 100644 index 0000000000..955266f472 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/types.go @@ -0,0 +1,89 @@ +package antigravity + +import "encoding/json" + +// HooksFile maps hook names to their event configurations. +// The top-level key is the hook name (user-defined, e.g. "my-linter-hook"). +type HooksFile = map[string]HookConfig + +// HookConfig defines the event handlers for a named hook entry. It mirrors +// agy's hooks.json schema (https://antigravity.google/docs — Hooks), including +// event keys we don't install (PostToolUse/PostInvocation) so round-tripping a +// user's file never drops data and the install idempotency comparison detects +// stale Entire entries that still carry them. +type HookConfig struct { + Enabled *bool `json:"enabled,omitempty"` + PreToolUse []ToolHandler `json:"PreToolUse,omitempty"` + PostToolUse []ToolHandler `json:"PostToolUse,omitempty"` + PreInvocation []SimpleHandler `json:"PreInvocation,omitempty"` + PostInvocation []SimpleHandler `json:"PostInvocation,omitempty"` + Stop []SimpleHandler `json:"Stop,omitempty"` +} + +// ToolHandler is a matcher + handlers entry used for PreToolUse / PostToolUse. +type ToolHandler struct { + Matcher string `json:"matcher,omitempty"` + Hooks []HookCommand `json:"hooks,omitempty"` +} + +// hookTypeCommand is the only handler type agy defines for hooks.json and +// for the global title slot: a shell command. +const hookTypeCommand = "command" + +// SimpleHandler is a direct handler entry used for PreInvocation, PostInvocation, and Stop. +type SimpleHandler struct { + Type string `json:"type,omitempty"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// HookCommand is a single executable hook command. +type HookCommand struct { + Type string `json:"type,omitempty"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// Payload structs below decode only the fields the integration consumes. +// agy sends more (workspacePaths, artifactDirectoryPath, stepIdx, +// initialNumSteps, executionNum, terminationReason, error) — see the agy hooks +// docs for the full schema; add fields here only when something reads them. + +// CommonPayload contains the system metadata fields the integration consumes +// from every hook payload. +type CommonPayload struct { + ConversationID string `json:"conversationId"` + TranscriptPath string `json:"transcriptPath"` +} + +// ToolCall represents a proposed or completed tool invocation. +type ToolCall struct { + Name string `json:"name"` + Args json.RawMessage `json:"args"` +} + +// PreToolUsePayload is the stdin payload for the PreToolUse hook. +type PreToolUsePayload struct { + CommonPayload + + ToolCall ToolCall `json:"toolCall"` +} + +// InvocationPayload is the stdin payload for the PreInvocation hook. +// +// invocationNum is 0-indexed (the first model invocation of a conversation is +// 0). initialNumSteps is deliberately not decoded: agy inserts the user prompt +// as a step before the first model call, so it is already 1 on the first +// invocation and unusable as a "first?" signal. +type InvocationPayload struct { + CommonPayload + + InvocationNum int `json:"invocationNum"` +} + +// StopPayload is the stdin payload for the Stop hook. +type StopPayload struct { + CommonPayload + + FullyIdle bool `json:"fullyIdle"` // Required +} diff --git a/cmd/entire/cli/agent/antigravity/types_test.go b/cmd/entire/cli/agent/antigravity/types_test.go new file mode 100644 index 0000000000..24d3e6649c --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/types_test.go @@ -0,0 +1,83 @@ +package antigravity + +import ( + "encoding/json" + "os" + "testing" +) + +const ( + testConversationID = "ec33ebf9-0cba-4100-8142-c61503f6c587" + testTranscriptPath = "/workspace/project/.gemini/jetski/transcript.jsonl" + testWorkspacePath = "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/workspace/project" +) + +// The payload structs decode only the fields the integration consumes; the +// fixtures carry agy's full documented payloads, so these tests also pin that +// unknown fields (workspacePaths, stepIdx, initialNumSteps, executionNum, +// terminationReason, error, artifactDirectoryPath) are tolerated. + +func TestParsePreToolUsePayload(t *testing.T) { + t.Parallel() + data, err := os.ReadFile("testdata/hook_stdin_pre_tool_use.json") + if err != nil { + t.Fatal(err) + } + var p PreToolUsePayload + if err := json.Unmarshal(data, &p); err != nil { + t.Fatalf("unmarshal PreToolUsePayload: %v", err) + } + if p.ConversationID != testConversationID { + t.Errorf("ConversationID = %q", p.ConversationID) + } + if p.ToolCall.Name != "run_command" { + t.Errorf("ToolCall.Name = %q", p.ToolCall.Name) + } + if p.TranscriptPath != testTranscriptPath { + t.Errorf("TranscriptPath = %q", p.TranscriptPath) + } +} + +func TestParsePreInvocationPayload(t *testing.T) { + t.Parallel() + data, err := os.ReadFile("testdata/hook_stdin_pre_invocation.json") + if err != nil { + t.Fatal(err) + } + var p InvocationPayload + if err := json.Unmarshal(data, &p); err != nil { + t.Fatalf("unmarshal InvocationPayload (PreInvocation): %v", err) + } + if p.ConversationID != testConversationID { + t.Errorf("ConversationID = %q", p.ConversationID) + } + // Fixture mirrors a real agy 1.0.0 follow-up PreInvocation: invocationNum=1 + // (0-indexed in agy's wire format). See parsePreInvocation comment block. + if p.InvocationNum != 1 { + t.Errorf("InvocationNum = %d", p.InvocationNum) + } + if p.TranscriptPath != testTranscriptPath { + t.Errorf("TranscriptPath = %q", p.TranscriptPath) + } +} + +func TestParseStopPayload(t *testing.T) { + t.Parallel() + data, err := os.ReadFile("testdata/hook_stdin_stop.json") + if err != nil { + t.Fatal(err) + } + var p StopPayload + if err := json.Unmarshal(data, &p); err != nil { + t.Fatalf("unmarshal StopPayload: %v", err) + } + if p.ConversationID != testConversationID { + t.Errorf("ConversationID = %q", p.ConversationID) + } + if !p.FullyIdle { + t.Error("FullyIdle should be true") + } + if p.TranscriptPath != testTranscriptPath { + t.Errorf("TranscriptPath = %q", p.TranscriptPath) + } +} diff --git a/cmd/entire/cli/agent/architecture_test.go b/cmd/entire/cli/agent/architecture_test.go index bc9727e65b..4e852d9d98 100644 --- a/cmd/entire/cli/agent/architecture_test.go +++ b/cmd/entire/cli/agent/architecture_test.go @@ -6,13 +6,14 @@ import ( "go/token" "os" "path/filepath" + "slices" "strconv" "strings" "testing" ) // TestAgentPackages_NoForbiddenImports verifies that agent implementation packages -// (claudecode, geminicli, opencode, cursor, etc.) only import from allowed packages. +// (claudecode, codex, opencode, cursor, etc.) only import from allowed packages. // // This prevents agent implementations from coupling to framework internals // (strategy, checkpoint, session, commands, hook_registry, lifecycle) which @@ -61,6 +62,20 @@ func TestAgentPackages_NoForbiddenImports(t *testing.T) { repoPrefix + "testutil", // canonical isolated repository fixtures for agent tests } + // Imports allowed in ONE agent package, keyed by its directory name. + // Unlike allowedPrefixes, an entry here does not widen the contract for + // every agent: it is one agent's answer to one agent's problem, and a + // second agent that wants it has to say so here first. + scopedPrefixes := map[string][]string{ + "antigravity": { + // Cross-process advisory locks. agy fires its title command on + // every state change without serializing, so the title-tee takes + // a per-conversation flock around its dedup-and-append. No other + // agent's transport has that shape. + repoPrefix + "internal/flock", + }, + } + agentDir := findAgentDir(t) agentPkgs := discoverAgentPackages(t, agentDir) @@ -72,6 +87,13 @@ func TestAgentPackages_NoForbiddenImports(t *testing.T) { pkgName := filepath.Base(pkgDir) t.Run(pkgName, func(t *testing.T) { t.Parallel() + // Clone, don't append onto allowedPrefixes: appending would write + // this package's scoped entries into that slice's backing array + // whenever it has spare capacity, handing every package checked + // afterwards the exception scopedPrefixes exists to contain. It + // holds today only because allowedPrefixes is a literal whose cap + // equals its len; that is not a property to depend on. + allowedHere := append(slices.Clone(allowedPrefixes), scopedPrefixes[pkgName]...) imports := extractImports(t, pkgDir) for _, imp := range imports { if !strings.HasPrefix(imp, repoPrefix) && imp != forbiddenCLIPackage { @@ -97,16 +119,17 @@ func TestAgentPackages_NoForbiddenImports(t *testing.T) { continue } - // Check it's in the allowed list + // Check it's in the allowed list, or scoped to this package allowed := false - for _, prefix := range allowedPrefixes { + for _, prefix := range allowedHere { if imp == prefix || strings.HasPrefix(imp, prefix+"/") { allowed = true break } } if !allowed { - t.Errorf("unexpected internal import %q — if this is intentional, add it to allowedPrefixes in architecture_test.go", imp) + t.Errorf("unexpected internal import %q — if every agent may use it, add it to allowedPrefixes in architecture_test.go; "+ + "if it answers this agent's problem alone, add it to scopedPrefixes[%q] with the reason", imp, pkgName) } } }) diff --git a/cmd/entire/cli/agent/caller_session.go b/cmd/entire/cli/agent/caller_session.go index 4139b12149..246016b2b1 100644 --- a/cmd/entire/cli/agent/caller_session.go +++ b/cmd/entire/cli/agent/caller_session.go @@ -20,11 +20,9 @@ import ( // is a different question with a different answer whenever more than one // session shares a checkpoint store (see strategy.ResolveCallerSession). // -// Not every agent publishes one. Gemini CLI passes its session ID to its shell -// executor for background-process bookkeeping but never into the child -// environment, and opencode's shell tool performs no environment augmentation -// at all; both are absent here on purpose rather than by omission, and callers -// must degrade rather than assume. +// Not every agent publishes one. opencode's shell tool performs no environment +// augmentation at all; it is absent here on purpose rather than by omission, +// and callers must degrade rather than assume. // // Deliberately built-in only, so it has no DeclaredCaps entry: the external // agent protocol has no field for it, and an external plugin already receives diff --git a/cmd/entire/cli/agent/caller_session_test.go b/cmd/entire/cli/agent/caller_session_test.go index acdc2845b2..c5312bcc22 100644 --- a/cmd/entire/cli/agent/caller_session_test.go +++ b/cmd/entire/cli/agent/caller_session_test.go @@ -40,9 +40,8 @@ func TestCallerSessionEnvVar_MatchesTheVendorsName(t *testing.T) { } // The agents deliberately WITHOUT the capability are as load-bearing as the -// ones with it: Gemini CLI passes its session ID only to its own background -// bookkeeping, and opencode's shell tool augments no environment at all. If -// either gains the capability without an entry above, this fails and asks for +// ones with it: opencode's shell tool augments no environment at all. If it +// gains the capability without an entry above, this fails and asks for // the mapping to be pinned rather than left implicit. func TestCallerSessionEnvVar_UnpublishedAgentsStayUnpublished(t *testing.T) { for _, name := range List() { diff --git a/cmd/entire/cli/agent/capabilities.go b/cmd/entire/cli/agent/capabilities.go index ef5f5a56a0..a63c6cea58 100644 --- a/cmd/entire/cli/agent/capabilities.go +++ b/cmd/entire/cli/agent/capabilities.go @@ -5,7 +5,7 @@ package agent // below use this interface to gate capability access: an agent must both implement // the optional interface AND declare the capability as true. // -// Built-in agents (Claude Code, Gemini CLI, etc.) do NOT implement this interface. +// Built-in agents (Claude Code, Codex, etc.) do NOT implement this interface. // For those agents, the As* helpers fall through to a direct type assertion, // preserving existing behavior. type CapabilityDeclarer interface { @@ -149,6 +149,42 @@ func AsTokenCalculator(ag Agent) (TokenCalculator, bool) { return declaredCapability[TokenCalculator](ag, func(c DeclaredCaps) bool { return c.TokenCalculator }) } +// AsLateTranscriptWriter returns the agent as LateTranscriptWriter if supported. +// External (CapabilityDeclarer) agents are excluded: the late-transcript trait +// is wire-format knowledge the external protocol does not currently express, +// and DeclaredCaps has no field for this capability to opt into. +func AsLateTranscriptWriter(ag Agent) (LateTranscriptWriter, bool) { + if ag == nil { + return nil, false + } + lw, ok := ag.(LateTranscriptWriter) + if !ok { + return nil, false + } + if _, isDeclarer := ag.(CapabilityDeclarer); isDeclarer { + return nil, false + } + return lw, true +} + +// AsOutOfBandTokenSource returns the agent as OutOfBandTokenSource if supported. +// External (CapabilityDeclarer) agents are excluded because the out-of-band +// store is fed by a built-in shim subcommand they cannot provide, and +// DeclaredCaps has no field for this capability to opt into. +func AsOutOfBandTokenSource(ag Agent) (OutOfBandTokenSource, bool) { + if ag == nil { + return nil, false + } + src, ok := ag.(OutOfBandTokenSource) + if !ok { + return nil, false + } + if _, isDeclarer := ag.(CapabilityDeclarer); isDeclarer { + return nil, false + } + return src, true +} + // AsInventoryAwareExtractor returns the agent as InventoryAwareExtractor when // it implements the built-in-only inventory protocol. External agents cannot // declare this capability because its authoritative child ledger is internal to @@ -200,6 +236,13 @@ func AsPromptExtractor(ag Agent) (PromptExtractor, bool) { return declaredCapability[PromptExtractor](ag, func(c DeclaredCaps) bool { return c.TranscriptAnalyzer }) } +// AsTranscriptPromptExtractor returns the agent as TranscriptPromptExtractor +// under the same capability gate as AsPromptExtractor: it is transcript +// analysis over bytes instead of a path. +func AsTranscriptPromptExtractor(ag Agent) (TranscriptPromptExtractor, bool) { + return declaredCapability[TranscriptPromptExtractor](ag, func(c DeclaredCaps) bool { return c.TranscriptAnalyzer }) +} + // AsSubagentAwareExtractor returns the agent as SubagentAwareExtractor if it both // implements the interface and (for CapabilityDeclarer agents) has declared the capability. func AsSubagentAwareExtractor(ag Agent) (SubagentAwareExtractor, bool) { diff --git a/cmd/entire/cli/agent/capabilities_test.go b/cmd/entire/cli/agent/capabilities_test.go index 0dd3da1c70..20e891e1e2 100644 --- a/cmd/entire/cli/agent/capabilities_test.go +++ b/cmd/entire/cli/agent/capabilities_test.go @@ -2,6 +2,7 @@ package agent import ( "context" + "encoding/json" "io" "testing" @@ -65,7 +66,7 @@ func (m *mockFullAgent) AreHooksInstalled(context.Context) (bool, error) { retur // TranscriptAnalyzer func (m *mockFullAgent) GetTranscriptPosition(string) (int, error) { return 0, nil } -func (m *mockFullAgent) ExtractModifiedFilesFromOffset(string, int) ([]string, int, error) { +func (m *mockFullAgent) ExtractModifiedFilesFromOffset(context.Context, string, int) ([]string, int, error) { return nil, 0, nil } func (m *mockFullAgent) ExtractPrompts(string, int) ([]string, error) { return nil, nil } @@ -90,6 +91,15 @@ func (m *mockFullAgent) GenerateText(context.Context, string, string) (string, e return "", nil } +// OutOfBandTokenSource (mockFullAgent is a CapabilityDeclarer, so AsOutOfBandTokenSource +// must still exclude it — verifies the built-in-only gate). +func (m *mockFullAgent) SnapshotTokenBaseline(context.Context, string) (json.RawMessage, error) { + return nil, nil +} +func (m *mockFullAgent) CalculateTokenUsageSince(context.Context, string, json.RawMessage) (*TokenUsage, error) { + return nil, nil //nolint:nilnil // test mock +} + // StreamingTextGenerator func (m *mockFullAgent) GenerateTextStreaming(context.Context, string, string, ProgressFn) (string, error) { return "", nil @@ -129,6 +139,19 @@ func (m *mockBuiltinPromptAgent) ExtractPrompts(string, int) ([]string, error) { return []string{"test prompt"}, nil } +// mockBuiltinOOBAgent is a built-in agent that implements OutOfBandTokenSource +// but NOT CapabilityDeclarer. +type mockBuiltinOOBAgent struct { + mockBaseAgent +} + +func (m *mockBuiltinOOBAgent) SnapshotTokenBaseline(context.Context, string) (json.RawMessage, error) { + return nil, nil +} +func (m *mockBuiltinOOBAgent) CalculateTokenUsageSince(context.Context, string, json.RawMessage) (*TokenUsage, error) { + return nil, nil //nolint:nilnil // test mock +} + // --- Tests --- func TestAsHookSupport(t *testing.T) { @@ -431,6 +454,44 @@ func TestAsSubagentAwareExtractor(t *testing.T) { }) } +func TestAsOutOfBandTokenSource(t *testing.T) { + t.Parallel() + + t.Run("nil agent", func(t *testing.T) { + t.Parallel() + _, ok := AsOutOfBandTokenSource(nil) + if ok { + t.Error("expected false for nil agent") + } + }) + + t.Run("not implemented", func(t *testing.T) { + t.Parallel() + _, ok := AsOutOfBandTokenSource(&mockBaseAgent{}) + if ok { + t.Error("expected false for agent not implementing OutOfBandTokenSource") + } + }) + + t.Run("builtin agent", func(t *testing.T) { + t.Parallel() + src, ok := AsOutOfBandTokenSource(&mockBuiltinOOBAgent{}) + if !ok || src == nil { + t.Error("expected true for built-in agent implementing OutOfBandTokenSource") + } + }) + + t.Run("capability declarer excluded", func(t *testing.T) { + t.Parallel() + // mockFullAgent implements the interface but is a CapabilityDeclarer + // (external agent), so it must be excluded. + _, ok := AsOutOfBandTokenSource(&mockFullAgent{}) + if ok { + t.Error("expected false for CapabilityDeclarer agent") + } + }) +} + func TestAsPromptExtractor(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/agent/chunking.go b/cmd/entire/cli/agent/chunking.go index dddfd55840..bbf0b14d63 100644 --- a/cmd/entire/cli/agent/chunking.go +++ b/cmd/entire/cli/agent/chunking.go @@ -2,12 +2,12 @@ package agent import ( "context" - "encoding/json" "fmt" "sort" "strings" "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/transcript/geminilegacy" ) const ( @@ -53,6 +53,17 @@ func ReassembleTranscript(chunks [][]byte, agentType types.AgentType) ([]byte, e } // Try to get the agent by type and use its format-aware reassembly + // Gemini CLI is no longer a registered agent, but its chunked transcripts + // are still in stored checkpoints, and they are JSON documents that JSONL + // reassembly would corrupt. + if agentType == AgentTypeGemini { + result, err := geminilegacy.ReassembleChunks(chunks) + if err != nil { + return nil, fmt.Errorf("gemini reassembly failed: %w", err) + } + return result, nil + } + if agentType != "" { ag, err := GetByAgentType(agentType) if err == nil { @@ -173,32 +184,3 @@ func SortChunkFiles(files []string, baseName string) []string { return sorted } - -// geminiTranscriptDetect is used for detecting Gemini JSON format. -type geminiTranscriptDetect struct { - Messages []interface{} `json:"messages"` -} - -// DetectAgentTypeFromContent detects the agent type from transcript content. -// Returns AgentTypeGemini if it appears to be Gemini JSON format, empty AgentType otherwise. -// This is used when the agent type is unknown but we need to chunk/reassemble correctly. -func DetectAgentTypeFromContent(content []byte) types.AgentType { - // Quick check: Gemini JSON starts with { and has a messages array - trimmed := strings.TrimSpace(string(content)) - if !strings.HasPrefix(trimmed, "{") { - return "" - } - - // Try to parse as Gemini JSON format (object with messages array) - var transcript geminiTranscriptDetect - if err := json.Unmarshal(content, &transcript); err != nil { - return "" - } - - // Must have at least one message to be considered Gemini format - if len(transcript.Messages) > 0 { - return AgentTypeGemini - } - - return "" -} diff --git a/cmd/entire/cli/agent/chunking_test.go b/cmd/entire/cli/agent/chunking_test.go index cc826a764b..9725608a15 100644 --- a/cmd/entire/cli/agent/chunking_test.go +++ b/cmd/entire/cli/agent/chunking_test.go @@ -4,8 +4,6 @@ import ( "context" "strings" "testing" - - "github.com/entireio/cli/cmd/entire/cli/agent/types" ) func TestChunkJSONL_SmallContent(t *testing.T) { @@ -228,44 +226,22 @@ func TestChunkJSONL_OversizedLineInMiddle(t *testing.T) { } } -func TestDetectAgentTypeFromContent(t *testing.T) { +// Gemini CLI is no longer registered, but its chunked transcripts in stored +// checkpoints are JSON documents: JSONL reassembly would join them into +// invalid JSON. +func TestReassembleTranscript_HistoricalGeminiChunks(t *testing.T) { t.Parallel() - tests := []struct { - name string - content []byte - expected types.AgentType - }{ - { - name: "Gemini JSON", - content: []byte(`{"messages":[{"type":"user","content":"hi"}]}`), - expected: AgentTypeGemini, - }, - { - name: "JSONL", - content: []byte(`{"type":"human","message":"hi"}`), - expected: "", - }, - { - name: "Empty messages array", - content: []byte(`{"messages":[]}`), - expected: "", // Empty messages should not be detected as Gemini - }, - { - name: "Invalid JSON", - content: []byte(`not json`), - expected: "", - }, + chunks := [][]byte{ + []byte(`{"messages":[{"type":"user","content":"hello"}]}`), + []byte(`{"messages":[{"type":"gemini","content":"hi"}]}`), } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - result := DetectAgentTypeFromContent(tt.content) - if result != tt.expected { - t.Errorf("DetectAgentTypeFromContent() = %q, want %q", result, tt.expected) - } - }) + result, err := ReassembleTranscript(chunks, AgentTypeGemini) + if err != nil { + t.Fatalf("ReassembleTranscript error: %v", err) + } + want := `{"messages":[{"type":"user","content":"hello"},{"type":"gemini","content":"hi"}]}` + if string(result) != want { + t.Errorf("ReassembleTranscript = %s, want %s", result, want) } } diff --git a/cmd/entire/cli/agent/claudecode/claude.go b/cmd/entire/cli/agent/claudecode/claude.go index 8a373f6eae..a9531bc675 100644 --- a/cmd/entire/cli/agent/claudecode/claude.go +++ b/cmd/entire/cli/agent/claudecode/claude.go @@ -236,7 +236,7 @@ func (c *ClaudeCodeAgent) GetTranscriptPosition(path string) (int, error) { // - files: list of file paths modified by Claude (from Write/Edit tools) // - currentPosition: total number of lines in the file // - error: any error encountered during reading -func (c *ClaudeCodeAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) { +func (c *ClaudeCodeAgent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) (files []string, currentPosition int, err error) { if path == "" { return nil, 0, nil } diff --git a/cmd/entire/cli/agent/codex/transcript.go b/cmd/entire/cli/agent/codex/transcript.go index c51afc211d..fe5c9288f3 100644 --- a/cmd/entire/cli/agent/codex/transcript.go +++ b/cmd/entire/cli/agent/codex/transcript.go @@ -286,7 +286,7 @@ func (c *CodexAgent) GetTranscriptPosition(path string) (int, error) { } // ExtractModifiedFilesFromOffset extracts files modified since a given line offset. -func (c *CodexAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) { +func (c *CodexAgent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) (files []string, currentPosition int, err error) { if path == "" { return nil, 0, nil } diff --git a/cmd/entire/cli/agent/codex/transcript_test.go b/cmd/entire/cli/agent/codex/transcript_test.go index cd70d8345c..7fac1d3422 100644 --- a/cmd/entire/cli/agent/codex/transcript_test.go +++ b/cmd/entire/cli/agent/codex/transcript_test.go @@ -2,6 +2,7 @@ package codex import ( "bytes" + "context" "encoding/json" "os" "path/filepath" @@ -134,7 +135,7 @@ func TestExtractModifiedFilesFromOffset(t *testing.T) { path := writeSampleRollout(t) // From beginning — should find all files - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 0) require.NoError(t, err) require.Equal(t, 12, pos) require.ElementsMatch(t, []string{"hello.txt", "docs/readme.md"}, files) @@ -146,7 +147,7 @@ func TestExtractModifiedFilesFromOffset_WithOffset(t *testing.T) { path := writeSampleRollout(t) // Skip first 7 lines (past the first apply_patch) — should only find second patch files - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 7) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 7) require.NoError(t, err) require.Equal(t, 12, pos) require.ElementsMatch(t, []string{"docs/readme.md", "hello.txt"}, files) @@ -157,7 +158,7 @@ func TestExtractModifiedFilesFromOffset_PastEnd(t *testing.T) { ag := &CodexAgent{} path := writeSampleRollout(t) - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 100) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 100) require.NoError(t, err) require.Equal(t, 12, pos) require.Empty(t, files) diff --git a/cmd/entire/cli/agent/copilotcli/AGENT.md b/cmd/entire/cli/agent/copilotcli/AGENT.md index 298d05b1a5..28a29fa23e 100644 --- a/cmd/entire/cli/agent/copilotcli/AGENT.md +++ b/cmd/entire/cli/agent/copilotcli/AGENT.md @@ -45,7 +45,7 @@ Copilot CLI has a complete hook system with 9 hook types, JSONL transcripts, and } ``` -Note: Uses `bash` key (not `command` like Claude Code/Gemini). Also supports `powershell` for Windows. Each entry can have optional `cwd`, `timeoutSec` (default 30), `env`, and `comment` fields. +Note: Uses `bash` key (not `command` like Claude Code/Cursor). Also supports `powershell` for Windows. Each entry can have optional `cwd`, `timeoutSec` (default 30), `env`, and `comment` fields. ### Hook Names and Event Mapping @@ -176,7 +176,7 @@ copilot --allow-all-tools --disable-builtin-mcps # prompt piped to stdin ``` This matches the pattern used by every other summary-capable agent in the -repo (Claude, Codex, Gemini, Cursor), which all converge on one transport +repo (Claude, Codex, Cursor), which all converge on one transport through the shared `agent.RunIsolatedTextGeneratorCLI` helper. It also sidesteps the OS `ARG_MAX` limit on long transcripts — and while Copilot's `--help` does not explicitly document stdin input, Copilot's own error diff --git a/cmd/entire/cli/agent/copilotcli/lifecycle.go b/cmd/entire/cli/agent/copilotcli/lifecycle.go index 8e5d28d88e..bc0ab74d90 100644 --- a/cmd/entire/cli/agent/copilotcli/lifecycle.go +++ b/cmd/entire/cli/agent/copilotcli/lifecycle.go @@ -234,6 +234,9 @@ func (c *CopilotCLIAgent) readSubagentEvidence(ctx context.Context, env *hookEnv if env.SessionID == "" || (env.AgentID == "" && env.AgentName == "") || env.TranscriptPath == "" { return subagentEvidence{}, false } + // No ValidateSessionID here: store.SessionFile below validates as its first + // statement, and a second copy reads as "the store does not" — the belief + // TestResolveSessionFileCallersAreSanctioned exists to remove. store, err := agent.OpenSessionStore(c, env.CWD) if err != nil { logging.Warn(ctx, "copilot-cli: cannot open session store for subagent stop", "err", err) @@ -279,10 +282,20 @@ func (c *CopilotCLIAgent) readHookEnvelope(stdin io.Reader) (*hookEnvelope, erro func (c *CopilotCLIAgent) resolveTranscriptRef(ctx context.Context, sessionID string) string { // GetSessionDir ignores the repoPath parameter for Copilot CLI since session // state is always in ~/.copilot/session-state/ (not repo-specific). - sessionDir, err := c.GetSessionDir("") + // + // Through the store, not c.ResolveSessionFile directly: sessionID is the raw + // hook payload's, Copilot's resolver puts it in a DIRECTORY position, and + // SessionFile is where that ID is validated and the result confirmed to be + // inside the store. See the contract on agent.Agent.ResolveSessionFile. + store, err := agent.OpenSessionStore(c, "") if err != nil { logging.Warn(ctx, "copilot-cli: failed to resolve transcript path", "sessionID", sessionID, "err", err) return "" } - return c.ResolveSessionFile(sessionDir, sessionID) + _, absPath, err := store.SessionFile(sessionID) + if err != nil { + logging.Warn(ctx, "copilot-cli: refusing unsafe transcript path", "sessionID", sessionID, "err", err) + return "" + } + return absPath } diff --git a/cmd/entire/cli/agent/copilotcli/lifecycle_test.go b/cmd/entire/cli/agent/copilotcli/lifecycle_test.go index ac5f63162d..c7e82dd31c 100644 --- a/cmd/entire/cli/agent/copilotcli/lifecycle_test.go +++ b/cmd/entire/cli/agent/copilotcli/lifecycle_test.go @@ -2,6 +2,7 @@ package copilotcli import ( "context" + "encoding/json" "os" "path/filepath" "strings" @@ -223,6 +224,55 @@ func TestParseHookEvent_AgentStop_ExtractsModel(t *testing.T) { } } +func TestParseHookEvent_UnsafeSessionIDSkipsTranscriptReads(t *testing.T) { + sessionDir := t.TempDir() + t.Setenv("ENTIRE_TEST_COPILOT_SESSION_DIR", sessionDir) + const ( + unsafeSessionID = "session. " + childID = "24d8773a-06e8-435c-9257-8ccb89a54f33" + ) + transcriptPath := filepath.Join(sessionDir, unsafeSessionID, "events.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(transcriptPath), 0o750)) + require.NoError(t, os.WriteFile(transcriptPath, []byte(strings.Join([]string{ + `{"type":"session.model_change","data":{"newModel":"claude-sonnet-5"}}`, + `{"type":"subagent.started","agentId":"` + childID + `","data":{"toolCallId":"toolu_unsafe","agentType":"general-purpose"}}`, + }, "\n")), 0o600)) + hookInput := func(fields map[string]any) string { + raw, err := json.Marshal(fields) + require.NoError(t, err) + return string(raw) + } + + // Model extraction reads transcriptPath and never touches the session ID, so + // an unsafe ID does not suppress it — gating it on the ID only dropped model + // attribution for an odd-looking session, while anyone able to set a hostile + // transcriptPath sends a UUID-shaped sessionId beside it. The unsafe ID is + // refused by DispatchLifecycleEvent before any handler runs. + t.Run("agent stop reads the model regardless of the session ID", func(t *testing.T) { + input := hookInput(map[string]any{ + "timestamp": 1771480085412, "cwd": "/repo", "sessionId": unsafeSessionID, + "transcriptPath": transcriptPath, "stopReason": "end_turn", + }) + event, err := (&CopilotCLIAgent{}).ParseHookEvent(context.Background(), HookNameAgentStop, strings.NewReader(input)) + require.NoError(t, err) + require.NotNil(t, event) + require.Equal(t, agent.TurnEnd, event.Type) + require.Equal(t, "claude-sonnet-5", event.Model) + }) + + // Subagent evidence is the real case: it resolves the parent transcript + // through the store, so an unsafe ID stops it. + t.Run("subagent stop", func(t *testing.T) { + input := hookInput(map[string]any{ + "timestamp": 1771480085412, "cwd": "/repo", "sessionId": unsafeSessionID, + "transcriptPath": transcriptPath, "agentId": childID, + }) + event, err := (&CopilotCLIAgent{}).ParseHookEvent(context.Background(), HookNameSubagentStop, strings.NewReader(input)) + require.NoError(t, err) + require.Nil(t, event) + }) +} + func TestParseHookEvent_AgentStop_NoTranscript_EmptyModel(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/agent/copilotcli/transcript.go b/cmd/entire/cli/agent/copilotcli/transcript.go index b5c19d8f31..0659a2ec8d 100644 --- a/cmd/entire/cli/agent/copilotcli/transcript.go +++ b/cmd/entire/cli/agent/copilotcli/transcript.go @@ -486,7 +486,7 @@ func (c *CopilotCLIAgent) GetTranscriptPosition(path string) (int, error) { // - files: list of file paths modified by Copilot (from tool.execution_complete events) // - currentPosition: total number of lines in the file // - error: any error encountered during reading -func (c *CopilotCLIAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) { +func (c *CopilotCLIAgent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) (files []string, currentPosition int, err error) { if path == "" { return nil, 0, nil } diff --git a/cmd/entire/cli/agent/copilotcli/transcript_test.go b/cmd/entire/cli/agent/copilotcli/transcript_test.go index 41bc53e4db..ad43f0c400 100644 --- a/cmd/entire/cli/agent/copilotcli/transcript_test.go +++ b/cmd/entire/cli/agent/copilotcli/transcript_test.go @@ -265,7 +265,7 @@ func TestExtractModifiedFilesFromOffset(t *testing.T) { ag := &CopilotCLIAgent{} path := writeTestJSONL(t, testJSONLLines) - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 0) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -287,7 +287,7 @@ func TestExtractModifiedFilesFromOffset(t *testing.T) { // Offset 5 means skip first 5 lines (tool.execution_complete is line 5) // so only lines 6 and 7 remain (assistant.message and assistant.turn_end) - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 5) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 5) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -303,7 +303,7 @@ func TestExtractModifiedFilesFromOffset(t *testing.T) { t.Parallel() ag := &CopilotCLIAgent{} - files, pos, err := ag.ExtractModifiedFilesFromOffset("", 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), "", 0) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/cmd/entire/cli/agent/cursor/lifecycle.go b/cmd/entire/cli/agent/cursor/lifecycle.go index 4915930fe7..70e8b0acfe 100644 --- a/cmd/entire/cli/agent/cursor/lifecycle.go +++ b/cmd/entire/cli/agent/cursor/lifecycle.go @@ -70,13 +70,21 @@ func (c *CursorAgent) resolveTranscriptRef(ctx context.Context, conversationID, return "" } - sessionDir, err := c.GetSessionDir(repoRoot) + // Through the store, not c.ResolveSessionFile directly: conversationID is the + // raw hook payload's and SessionFile is where it is validated and the result + // confirmed to be inside the store. See agent.Agent.ResolveSessionFile. + store, err := agent.OpenSessionStore(c, repoRoot) if err != nil { logging.Warn(ctx, "cursor: failed to get session dir for transcript resolution", "err", err) return "" } - return c.ResolveSessionFile(sessionDir, conversationID) + _, absPath, err := store.SessionFile(conversationID) + if err != nil { + logging.Warn(ctx, "cursor: refusing unsafe transcript path", "conversationID", conversationID, "err", err) + return "" + } + return absPath } func (c *CursorAgent) parseSessionStart(stdin io.Reader) (*agent.Event, error) { diff --git a/cmd/entire/cli/agent/cursor/transcript.go b/cmd/entire/cli/agent/cursor/transcript.go index b53e758d95..9ac616b960 100644 --- a/cmd/entire/cli/agent/cursor/transcript.go +++ b/cmd/entire/cli/agent/cursor/transcript.go @@ -2,6 +2,7 @@ package cursor import ( "bufio" + "context" "encoding/json" "errors" "fmt" @@ -154,7 +155,7 @@ func ExtractModifiedFiles(lines []transcript.Line) []string { // A missing transcript is not an error: Cursor reports transcript_path as null in // CLI mode, so ResolveSessionFile predicts a path that may not exist yet, and this // runs on capture paths that must fail open (matching GetTranscriptPosition). -func (c *CursorAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { +func (c *CursorAgent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) ([]string, int, error) { if path == "" { return nil, 0, nil } diff --git a/cmd/entire/cli/agent/cursor/transcript_test.go b/cmd/entire/cli/agent/cursor/transcript_test.go index ee7532c327..f34197f336 100644 --- a/cmd/entire/cli/agent/cursor/transcript_test.go +++ b/cmd/entire/cli/agent/cursor/transcript_test.go @@ -1,6 +1,7 @@ package cursor import ( + "context" "encoding/json" "os" "path/filepath" @@ -174,7 +175,7 @@ func TestCursorAgent_ExtractModifiedFilesFromOffset(t *testing.T) { // The sample is a text-only conversation, so there is nothing to attribute -- // but the position must still advance to the sample's line count. Returning a // constant 0 here is what the removed stub did. - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 0) if err != nil { t.Fatalf("ExtractModifiedFilesFromOffset() error = %v, want nil", err) } @@ -193,7 +194,7 @@ func TestCursorAgent_ExtractModifiedFilesFromOffset_NonexistentFile(t *testing.T t.Parallel() ag := &CursorAgent{} - files, pos, err := ag.ExtractModifiedFilesFromOffset("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/nonexistent/path.jsonl", 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), "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/nonexistent/path.jsonl", 0) if err != nil { t.Fatalf("ExtractModifiedFilesFromOffset() error = %v, want nil", err) } @@ -209,7 +210,7 @@ func TestCursorAgent_ExtractModifiedFilesFromOffset_EmptyPath(t *testing.T) { t.Parallel() ag := &CursorAgent{} - files, pos, err := ag.ExtractModifiedFilesFromOffset("", 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), "", 0) if err != nil { t.Fatalf("ExtractModifiedFilesFromOffset() error = %v", err) } @@ -349,7 +350,7 @@ func TestCursorAgent_ExtractModifiedFilesFromOffset_RealSession(t *testing.T) { t.Parallel() ag := &CursorAgent{} - files, pos, err := ag.ExtractModifiedFilesFromOffset(realSessionFixture, 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), realSessionFixture, 0) if err != nil { t.Fatalf("ExtractModifiedFilesFromOffset() error = %v", err) } @@ -381,7 +382,7 @@ func TestCursorAgent_ExtractModifiedFilesFromOffset_RealSessionOffsets(t *testin for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - files, pos, err := ag.ExtractModifiedFilesFromOffset(realSessionFixture, tt.offset) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), realSessionFixture, tt.offset) if err != nil { t.Fatalf("ExtractModifiedFilesFromOffset() error = %v", err) } diff --git a/cmd/entire/cli/agent/event.go b/cmd/entire/cli/agent/event.go index fb5881596f..326f8adddc 100644 --- a/cmd/entire/cli/agent/event.go +++ b/cmd/entire/cli/agent/event.go @@ -39,8 +39,8 @@ const ( SubagentEnd // ModelUpdate indicates the agent reported the LLM model being used. - // This fires on hooks that carry model info but have no other lifecycle action - // (e.g., Gemini CLI's BeforeModel). The framework stores the model as a hint + // This fires on hooks that carry model info but have no other lifecycle action. + // No built-in agent emits it today; external agents can. The framework stores the model as a hint // for subsequent TurnStart/TurnEnd events in the same session. ModelUpdate @@ -98,7 +98,7 @@ type Event struct { Prompt string // Model is the LLM model identifier (e.g., "claude-sonnet-4-20250514"). - // Populated on SessionStart (Claude Code), ModelUpdate (Gemini CLI BeforeModel), + // Populated on SessionStart (Claude Code), ModelUpdate, // and TurnStart/TurnEnd events when the agent provides model info. Model string @@ -202,6 +202,15 @@ type Event struct { // Metadata holds agent-specific state that the framework stores and makes available // on subsequent events. Examples: Pi's activeLeafId, Cursor's is_background_agent. Metadata map[string]string + + // SuppressIfSessionActive marks a TurnStart the dispatcher should drop when + // an active (mid-turn) session already exists for SessionID. It exists for + // agents whose per-invocation hooks can't distinguish a follow-up model call + // from the first call of a resumed turn (e.g. Antigravity's PreInvocation, + // which fires per model invocation): the parser emits a conditional TurnStart + // and the dispatcher resolves it against session state (which agent packages + // may not read directly). + SuppressIfSessionActive bool } // ReadAndParseHookInput decodes a single JSON hook payload from stdin into the @@ -212,7 +221,7 @@ type Event struct { // keep the write end of that pipe open for the hook's lifetime rather than // closing it after writing — notably on Windows/Git Bash, where a full payload // arrives but EOF never does. io.ReadAll then blocked indefinitely and the hook -// (e.g. gemini session-start) hung forever (issue #1398). A streaming +// hung forever (issue #1398). A streaming // json.Decoder returns as soon as one complete JSON value has been read, // independent of when — or whether — stdin is closed. func ReadAndParseHookInput[T any](stdin io.Reader) (*T, error) { diff --git a/cmd/entire/cli/agent/external/capabilities.go b/cmd/entire/cli/agent/external/capabilities.go index 64ef698cf0..75d17fb963 100644 --- a/cmd/entire/cli/agent/external/capabilities.go +++ b/cmd/entire/cli/agent/external/capabilities.go @@ -95,8 +95,8 @@ func (w *wrappedAgent) AreHooksInstalled(ctx context.Context) (bool, error) { func (w *wrappedAgent) GetTranscriptPosition(path string) (int, error) { return w.ea.GetTranscriptPosition(path) } -func (w *wrappedAgent) ExtractModifiedFilesFromOffset(path string, offset int) ([]string, int, error) { - return w.ea.ExtractModifiedFilesFromOffset(path, offset) +func (w *wrappedAgent) ExtractModifiedFilesFromOffset(ctx context.Context, path string, offset int) ([]string, int, error) { + return w.ea.ExtractModifiedFilesFromOffset(ctx, path, offset) } func (w *wrappedAgent) ExtractPrompts(ref string, offset int) ([]string, error) { return w.ea.ExtractPrompts(ref, offset) diff --git a/cmd/entire/cli/agent/external/discovery.go b/cmd/entire/cli/agent/external/discovery.go index 89b7171612..64f51927cd 100644 --- a/cmd/entire/cli/agent/external/discovery.go +++ b/cmd/entire/cli/agent/external/discovery.go @@ -90,6 +90,19 @@ func DiscoverAndRegisterNamedAlways(ctx context.Context, name types.AgentName) e return discoverAndRegisterNamed(ctx, name, discoveryTimeout) } +// BinaryOnPath reports whether an entire-agent- executable is on $PATH, +// without executing it. For callers that must defer to a plugin that could +// claim name but are not allowed to run plugins to find out (doctor). A lookup +// that fails for any reason other than "not found" counts as present, so the +// caller errs on the side of leaving the plugin's state alone. +func BinaryOnPath(name types.AgentName) bool { + if name == "" || strings.ContainsAny(string(name), `/\`) { + return false + } + _, err := lookPathExternalAgent(binaryPrefix + string(name)) + return !errors.Is(err, exec.ErrNotFound) +} + // discoveryCanceled reports whether either the caller's context or the derived // discovery-timeout context has been cancelled. // diff --git a/cmd/entire/cli/agent/external/external.go b/cmd/entire/cli/agent/external/external.go index 8c3324c0fd..80673c1ca2 100644 --- a/cmd/entire/cli/agent/external/external.go +++ b/cmd/entire/cli/agent/external/external.go @@ -157,7 +157,11 @@ func (e *Agent) GetSessionID(input *agent.HookInput) string { } func (e *Agent) GetSessionDir(repoPath string) (string, error) { - stdout, err := e.run(context.Background(), nil, "get-session-dir", "--repo-path", repoPath) + return e.getSessionDir(context.Background(), repoPath) +} + +func (e *Agent) getSessionDir(ctx context.Context, repoPath string) (string, error) { + stdout, err := e.run(ctx, nil, "get-session-dir", "--repo-path", repoPath) if err != nil { return "", fmt.Errorf("get-session-dir: %w", err) } @@ -198,6 +202,30 @@ func (e *Agent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) } func (e *Agent) WriteSession(ctx context.Context, session *agent.AgentSession) error { + // Preflight before marshalling: a ref that is going to be refused should not + // first be serialized into the payload it will never be sent in. + needsStoreCheck, err := agent.ValidateExternalSessionRef(session.SessionRef) + if err != nil { + return fmt.Errorf("write-session: validate session reference: %w", err) + } + if needsStoreCheck && session.RepoPath != "" { + // Fails closed: a filesystem-shaped reference cannot be checked for + // containment without the directory it is supposed to be inside, and + // this is the preflight's whole job. A plugin that cannot report its + // session directory is told which subprocess failed. + sessionDir, dirErr := e.getSessionDir(ctx, session.RepoPath) + if dirErr != nil { + return fmt.Errorf("write-session: get-session-dir: %w", dirErr) + } + store, storeErr := agent.OpenSessionStoreAt(e, sessionDir) + if storeErr != nil { + return fmt.Errorf("write-session: open session store: %w", storeErr) + } + if err := store.ValidateExternalWriteRef(session.SessionRef); err != nil { + return fmt.Errorf("write-session: validate session reference: %w", err) + } + } + data, err := marshalAgentSession(session) if err != nil { return fmt.Errorf("write-session: marshal: %w", err) @@ -313,7 +341,7 @@ func (e *Agent) GetTranscriptPosition(path string) (int, error) { return resp.Position, nil } -func (e *Agent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { +func (e *Agent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) ([]string, int, error) { stdout, err := e.run(context.Background(), nil, "extract-modified-files", "--path", path, "--offset", strconv.Itoa(startOffset)) if err != nil { diff --git a/cmd/entire/cli/agent/external/external_test.go b/cmd/entire/cli/agent/external/external_test.go index 3718ee7242..4c6f877483 100644 --- a/cmd/entire/cli/agent/external/external_test.go +++ b/cmd/entire/cli/agent/external/external_test.go @@ -3,6 +3,7 @@ package external import ( "context" "encoding/base64" + "encoding/json" "errors" "os" "os/exec" @@ -14,6 +15,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/osroot" ) // testBinaryDir creates a temp directory with a mock entire-agent-test binary. @@ -145,6 +147,43 @@ const validInfoJSON = `{ } }` +func newWriteRecordingAgent(t *testing.T) (*Agent, string, string) { + t.Helper() + var script string + if runtime.GOOS == osWindows { + script = strings.ReplaceAll(`@echo off +if "%1"=="info" goto info +if "%1"=="get-session-dir" goto sessiondir +if "%1"=="write-session" goto writesession +exit /b 1 +:info +echo {"protocol_version":1,"name":"test","type":"Test Agent","description":"A test agent"} +exit /b 0 +:sessiondir +set "session_dir=%~dp0sessions" +set "session_dir=%session_dir:\=\\%" +echo {"session_dir":"%session_dir%"} +exit /b 0 +:writesession +more > "%~dp0write-session-input" +exit /b 0 +`, "\n", "\r\n") + } else { + script = strings.Replace(mockInfoScript(validInfoJSON), + `echo '{"session_dir": "/tmp/sessions"}'`, + `printf '{"session_dir":"%s/sessions"}\n' "$(dirname "$0")"`, 1) + script = strings.Replace(script, + " write-session)\n exit 0\n ;;", + " write-session)\n cat > \"$(dirname \"$0\")/write-session-input\"\n ;;", 1) + } + binPath := testBinaryDir(t, script) + sessionDir := filepath.Join(filepath.Dir(binPath), "sessions") + if err := os.Mkdir(sessionDir, 0o750); err != nil { + t.Fatalf("create session directory: %v", err) + } + return newExternalAgent(t, binPath), sessionDir, filepath.Join(filepath.Dir(binPath), "write-session-input") +} + func TestRun_AppliesTimeoutWhenNoDeadline(t *testing.T) { // Not parallel: mutates package-level defaultRunTimeout. if _, err := exec.LookPath("sh"); err != nil { @@ -236,6 +275,168 @@ func TestNew_Valid(t *testing.T) { } } +func TestWriteSession_RejectsUnsafeReferenceBeforeSubprocess(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wantErr error + sessionRef func(t *testing.T, sessionDir, outsideDir string) string + }{ + { + name: "absolute outside store", + wantErr: agent.ErrOutsideSessionStore, + sessionRef: func(_ *testing.T, _, outsideDir string) string { + return filepath.Join(outsideDir, "session.jsonl") + }, + }, + { + name: "rooted path", + wantErr: agent.ErrOutsideSessionStore, + sessionRef: func(_ *testing.T, _, _ string) string { + return string(os.PathSeparator) + "outside.jsonl" + }, + }, + { + name: "relative parent traversal", + wantErr: agent.ErrUnsafeSessionName, + sessionRef: func(_ *testing.T, _, _ string) string { + return filepath.Join("..", "outside.jsonl") + }, + }, + { + name: "relative nested traversal", + wantErr: agent.ErrUnsafeSessionName, + sessionRef: func(_ *testing.T, _, _ string) string { + return filepath.FromSlash("nested/../../outside.jsonl") + }, + }, + { + name: "symlinked leaf", + wantErr: osroot.ErrSymlinkedPath, + sessionRef: func(t *testing.T, sessionDir, outsideDir string) string { + t.Helper() + ref := filepath.Join(sessionDir, "session.jsonl") + if err := os.Symlink(filepath.Join(outsideDir, "session.jsonl"), ref); err != nil { + t.Skipf("symlink not supported: %v", err) + } + return ref + }, + }, + { + name: "symlinked parent", + wantErr: osroot.ErrSymlinkedPath, + sessionRef: func(t *testing.T, sessionDir, outsideDir string) string { + t.Helper() + if err := os.Symlink(outsideDir, filepath.Join(sessionDir, "linked")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + return filepath.Join(sessionDir, "linked", "session.jsonl") + }, + }, + { + name: "symlink plus parent traversal", + wantErr: agent.ErrUnsafeSessionName, + sessionRef: func(t *testing.T, sessionDir, outsideDir string) string { + t.Helper() + targetDir := filepath.Join(outsideDir, "child") + if err := os.Mkdir(targetDir, 0o750); err != nil { + t.Fatalf("create symlink target: %v", err) + } + linked := filepath.Join(sessionDir, "linked") + if err := os.Symlink(targetDir, linked); err != nil { + t.Skipf("symlink not supported: %v", err) + } + return linked + string(os.PathSeparator) + ".." + string(os.PathSeparator) + "session.jsonl" + }, + }, + { + name: "alternate data stream", + wantErr: agent.ErrUnsafeSessionName, + sessionRef: func(_ *testing.T, sessionDir, _ string) string { + return filepath.Join(sessionDir, "session.jsonl:stream") + }, + }, + { + name: "missing store with Windows-normalized traversal", + wantErr: agent.ErrUnsafeSessionName, + sessionRef: func(t *testing.T, sessionDir, _ string) string { + t.Helper() + if err := os.Remove(sessionDir); err != nil { + t.Fatalf("remove session directory: %v", err) + } + return filepath.Join(sessionDir, ".. ", "session.jsonl") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ea, sessionDir, marker := newWriteRecordingAgent(t) + outsideDir := t.TempDir() + sessionRef := tt.sessionRef(t, sessionDir, outsideDir) + + err := ea.WriteSession(t.Context(), &agent.AgentSession{ + RepoPath: t.TempDir(), + SessionRef: sessionRef, + }) + if !errors.Is(err, tt.wantErr) { + t.Fatalf("WriteSession() error = %v, want %v", err, tt.wantErr) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("write-session subprocess was invoked; marker stat error = %v", err) + } + if _, err := os.Stat(filepath.Join(outsideDir, "session.jsonl")); !os.IsNotExist(err) { + t.Fatalf("outside file was created; stat error = %v", err) + } + }) + } +} + +func TestWriteSession_PreservesOpaqueRelativeReferenceWithMissingStore(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + sessionRef string + }{ + {name: "path-like key", sessionRef: "database/session-key"}, + {name: "dot component", sessionRef: "tenant/../session-key"}, + {name: "Windows device basename", sessionRef: "CON"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + sessionRef := tt.sessionRef + + ea, sessionDir, marker := newWriteRecordingAgent(t) + if err := os.Remove(sessionDir); err != nil { + t.Fatalf("remove session directory: %v", err) + } + + if err := ea.WriteSession(t.Context(), &agent.AgentSession{ + RepoPath: t.TempDir(), + SessionRef: sessionRef, + }); err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + data, err := os.ReadFile(marker) + if err != nil { + t.Fatalf("read write-session input: %v", err) + } + var got AgentSessionJSON + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("decode write-session input: %v", err) + } + if got.SessionRef != sessionRef { + t.Errorf("session_ref = %q, want %q", got.SessionRef, sessionRef) + } + }) + } +} + func TestNew_WrongProtocolVersion(t *testing.T) { t.Parallel() @@ -398,7 +599,7 @@ func TestExternalAgent_TranscriptAnalyzer(t *testing.T) { t.Errorf("GetTranscriptPosition() = %d, want 42", pos) } - files, curPos, err := ea.ExtractModifiedFilesFromOffset("/path", 0) + files, curPos, err := ea.ExtractModifiedFilesFromOffset(context.Background(), "/path", 0) if err != nil { t.Fatalf("ExtractModifiedFilesFromOffset: %v", err) } diff --git a/cmd/entire/cli/agent/factoryaidroid/lifecycle.go b/cmd/entire/cli/agent/factoryaidroid/lifecycle.go index eb8d23c5fe..298b18f056 100644 --- a/cmd/entire/cli/agent/factoryaidroid/lifecycle.go +++ b/cmd/entire/cli/agent/factoryaidroid/lifecycle.go @@ -88,7 +88,7 @@ func (f *FactoryAIDroidAgent) GetTranscriptPosition(path string) (int, error) { } // ExtractModifiedFilesFromOffset extracts files modified since a given line offset. -func (f *FactoryAIDroidAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { +func (f *FactoryAIDroidAgent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) ([]string, int, error) { lines, currentPos, err := ParseDroidTranscript(path, startOffset) if err != nil { return nil, 0, fmt.Errorf("failed to parse transcript: %w", err) diff --git a/cmd/entire/cli/agent/geminicli/discovery.go b/cmd/entire/cli/agent/geminicli/discovery.go deleted file mode 100644 index 09eab2265d..0000000000 --- a/cmd/entire/cli/agent/geminicli/discovery.go +++ /dev/null @@ -1,14 +0,0 @@ -package geminicli - -import ( - "context" - - "github.com/entireio/cli/cmd/entire/cli/agent" -) - -// DiscoverReviewSkills is a stub until the Gemini CLI on-disk extension layout -// is verified. Returns (nil, nil) so the picker relies on the per-agent -// install hint for Phase 1. -func (g *GeminiCLIAgent) DiscoverReviewSkills(_ context.Context) ([]agent.DiscoveredSkill, error) { - return nil, nil -} diff --git a/cmd/entire/cli/agent/geminicli/discovery_test.go b/cmd/entire/cli/agent/geminicli/discovery_test.go deleted file mode 100644 index 0b46090b22..0000000000 --- a/cmd/entire/cli/agent/geminicli/discovery_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package geminicli_test - -import ( - "context" - "testing" - - "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" -) - -// Compile-time pin: GeminiCLIAgent must satisfy SkillDiscoverer. -var _ agent.SkillDiscoverer = (*geminicli.GeminiCLIAgent)(nil) - -func TestGeminiCLIAgent_DiscoverReviewSkills_Stub(t *testing.T) { - t.Parallel() - a := &geminicli.GeminiCLIAgent{} - skills, err := a.DiscoverReviewSkills(context.Background()) - if err != nil { - t.Fatalf("stub should not error; got %v", err) - } - if skills != nil { - t.Errorf("stub should return nil skills; got %+v", skills) - } -} diff --git a/cmd/entire/cli/agent/geminicli/gemini.go b/cmd/entire/cli/agent/geminicli/gemini.go deleted file mode 100644 index c46144cd0e..0000000000 --- a/cmd/entire/cli/agent/geminicli/gemini.go +++ /dev/null @@ -1,417 +0,0 @@ -// Package geminicli implements the Agent interface for Gemini CLI. -package geminicli - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "log/slog" - "os" - "os/exec" - "path/filepath" - "time" - - "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/entireio/cli/cmd/entire/cli/agent/types" - "github.com/entireio/cli/cmd/entire/cli/logging" - "github.com/entireio/cli/cmd/entire/cli/paths" -) - -//nolint:gochecknoinits // Agent self-registration is the intended pattern -func init() { - agent.Register(agent.AgentNameGemini, NewGeminiCLIAgent) -} - -// GeminiCLIAgent implements the Agent interface for Gemini CLI. -// -//nolint:revive // GeminiCLIAgent is clearer than Agent in this context -type GeminiCLIAgent struct { - CommandRunner agent.TextCommandRunner -} - -func NewGeminiCLIAgent() agent.Agent { - return &GeminiCLIAgent{} -} - -// Name returns the agent registry key. -func (g *GeminiCLIAgent) Name() types.AgentName { - return agent.AgentNameGemini -} - -// Type returns the agent type identifier. -func (g *GeminiCLIAgent) Type() types.AgentType { - return agent.AgentTypeGemini -} - -// Description returns a human-readable description. -func (g *GeminiCLIAgent) Description() string { - return "Gemini CLI - Google's AI coding assistant" -} - -// DetectPresence checks if Gemini CLI is configured in the repository. -func (g *GeminiCLIAgent) DetectPresence(ctx context.Context) (bool, error) { - // Get worktree root to check for .gemini directory - // This is needed because the CLI may be run from a subdirectory - repoRoot, err := paths.WorktreeRoot(ctx) - if err != nil { - // Not in a git repo, fall back to CWD-relative check - repoRoot = "." - } - - // Check for .gemini directory - geminiDir := filepath.Join(repoRoot, ".gemini") - if _, err := os.Stat(geminiDir); err == nil { - return true, nil - } - // Check for .gemini/settings.json - settingsFile := filepath.Join(repoRoot, ".gemini", "settings.json") - if _, err := os.Stat(settingsFile); err == nil { - return true, nil - } - return false, nil -} - -// GetSessionID extracts the session ID from hook input. -func (g *GeminiCLIAgent) GetSessionID(input *agent.HookInput) string { - return input.SessionID -} - -// ProtectedDirs returns directories that Gemini uses for config/state. -func (g *GeminiCLIAgent) ProtectedDirs() []string { return []string{".gemini"} } - -// ResolveSessionFile returns the path to a Gemini session file. -// Gemini names files as session--.json where shortid is the first 8 chars -// of the session UUID. This searches for an existing file matching the pattern, falling -// back to constructing a filename matching Gemini's convention if no match is found. -func (g *GeminiCLIAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { - // Try to find existing file matching Gemini's naming convention: - // session-*-.json - shortID := agentSessionID - if len(shortID) > 8 { - shortID = shortID[:8] - } - - pattern := filepath.Join(sessionDir, "session-*-"+shortID+".json") - matches, err := filepath.Glob(pattern) - if err == nil && len(matches) > 0 { - // Return the most recent match (last alphabetically, since date is in the name) - return matches[len(matches)-1] - } - - // Fallback: construct filename matching Gemini's convention: session--.json - timestamp := time.Now().UTC().Format("2006-01-02T15-04") - return filepath.Join(sessionDir, "session-"+timestamp+"-"+shortID+".json") -} - -// GetSessionDir returns the directory where Gemini stores session transcripts. -// Gemini stores sessions in ~/.gemini/tmp//chats/ -func (g *GeminiCLIAgent) GetSessionDir(repoPath string) (string, error) { - // Check for test environment override - if override := os.Getenv("ENTIRE_TEST_GEMINI_PROJECT_DIR"); override != "" { - return override, nil - } - - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - - // Gemini uses a SHA256 hash of the project path for the directory name - projectDir := GetProjectHash(repoPath) - return filepath.Join(homeDir, ".gemini", "tmp", projectDir, "chats"), nil -} - -// GetSessionBaseDir returns the base directory containing per-project session subdirectories. -// Unlike GetSessionDir, this does NOT use ENTIRE_TEST_GEMINI_PROJECT_DIR because the -// test override points to a specific project dir, not the base containing all projects. -func (g *GeminiCLIAgent) GetSessionBaseDir() (string, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - return filepath.Join(homeDir, ".gemini", "tmp"), nil -} - -// ReadSession reads a session from Gemini's storage (JSON transcript file). -// The session data is stored in NativeData as raw JSON bytes. -func (g *GeminiCLIAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { - if input.SessionRef == "" { - return nil, errors.New("session reference (transcript path) is required") - } - - // Read the raw JSON file - data, err := os.ReadFile(input.SessionRef) - if err != nil { - return nil, fmt.Errorf("failed to read transcript: %w", err) - } - - // Parse to extract computed fields - modifiedFiles, err := ExtractModifiedFiles(data) - if err != nil { - // Non-fatal: we can still return the session without modified files - modifiedFiles = nil - } - - return &agent.AgentSession{ - SessionID: input.SessionID, - AgentName: g.Name(), - SessionRef: input.SessionRef, - StartTime: time.Now(), - NativeData: data, - ModifiedFiles: modifiedFiles, - }, nil -} - -// WriteSession writes a session to Gemini's storage (JSON transcript file). -// Uses the NativeData field which contains raw JSON bytes. -func (g *GeminiCLIAgent) WriteSession(_ context.Context, session *agent.AgentSession) error { - if session == nil { - return errors.New("session is nil") - } - - // Verify this session belongs to Gemini CLI - if session.AgentName != "" && session.AgentName != g.Name() { - return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, g.Name()) - } - - if session.SessionRef == "" { - return errors.New("session reference (transcript path) is required") - } - - if len(session.NativeData) == 0 { - return errors.New("session has no native data to write") - } - - // Write the raw JSON data through Gemini's own session store. Gemini nests - // chats under /chats/, so the store's parent-directory create - // is what makes a restore into a project directory that does not exist yet - // work without a separate MkdirAll here. - if err := agent.WriteSessionFile(g, session, session.NativeData, 0o600); err != nil { - return fmt.Errorf("write transcript: %w", err) - } - - return nil -} - -// FormatResumeCommand returns the command to resume a Gemini CLI session. -func (g *GeminiCLIAgent) FormatResumeCommand(sessionID string) string { - return "gemini --resume " + sessionID -} - -// GetProjectHash generates a unique hash for a project based on its root path. -// This matches Gemini CLI's getProjectHash() which uses SHA256 of the project root. -func GetProjectHash(projectRoot string) string { - hash := sha256.Sum256([]byte(projectRoot)) - return hex.EncodeToString(hash[:]) -} - -// TranscriptAnalyzer interface implementation - -// GetTranscriptPosition returns the current message count of a Gemini transcript. -// Gemini uses JSON format with a messages array, so position is the message count. -// Returns 0 if the file doesn't exist or is empty. -func (g *GeminiCLIAgent) GetTranscriptPosition(path string) (int, error) { - if path == "" { - return 0, nil - } - - data, err := os.ReadFile(path) //nolint:gosec // Reading from controlled transcript path - if err != nil { - if os.IsNotExist(err) { - return 0, nil - } - return 0, fmt.Errorf("failed to read transcript: %w", err) - } - - if len(data) == 0 { - return 0, nil - } - - transcript, err := ParseTranscript(data) - if err != nil { - return 0, fmt.Errorf("failed to parse transcript: %w", err) - } - - return len(transcript.Messages), nil -} - -// ExtractModifiedFilesFromOffset extracts files modified since a given message index. -// For Gemini (JSON format), offset is the starting message index. -// Returns: -// - files: list of file paths modified by Gemini (from Write/Edit tools) -// - currentPosition: total number of messages in the transcript -// - error: any error encountered during reading -func (g *GeminiCLIAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) { - if path == "" { - return nil, 0, nil - } - - data, readErr := os.ReadFile(path) //nolint:gosec // Reading from controlled transcript path - if readErr != nil { - if os.IsNotExist(readErr) { - return nil, 0, nil - } - return nil, 0, fmt.Errorf("failed to read transcript: %w", readErr) - } - - if len(data) == 0 { - return nil, 0, nil - } - - transcript, parseErr := ParseTranscript(data) - if parseErr != nil { - return nil, 0, parseErr - } - - totalMessages := len(transcript.Messages) - - // Extract files from messages starting at startOffset - fileSet := make(map[string]bool) - for i := startOffset; i < len(transcript.Messages); i++ { - msg := transcript.Messages[i] - // Only process gemini messages (assistant messages) - if msg.Type != MessageTypeGemini { - continue - } - - // Process tool calls in this message - for _, toolCall := range msg.ToolCalls { - // Check if it's a file modification tool - isModifyTool := false - for _, name := range FileModificationTools { - if toolCall.Name == name { - isModifyTool = true - break - } - } - - if !isModifyTool { - continue - } - - // Extract file path from args map - var file string - if fp, ok := toolCall.Args["file_path"].(string); ok && fp != "" { - file = fp - } else if p, ok := toolCall.Args["path"].(string); ok && p != "" { - file = p - } else if fn, ok := toolCall.Args["filename"].(string); ok && fn != "" { - file = fn - } - - if file != "" && !fileSet[file] { - fileSet[file] = true - files = append(files, file) - } - } - } - - return files, totalMessages, nil -} - -// ChunkTranscript splits a Gemini JSON transcript by distributing messages across chunks. -// Gemini uses JSON format with a {"messages": [...]} structure, so chunking splits -// the messages array while preserving the JSON structure in each chunk. -func (g *GeminiCLIAgent) ChunkTranscript(ctx context.Context, content []byte, maxSize int) ([][]byte, error) { - var transcript GeminiTranscript - if err := json.Unmarshal(content, &transcript); err != nil { - // Fall back to JSONL chunking if not valid Gemini JSON - chunks, chunkErr := agent.ChunkJSONL(content, maxSize) - if chunkErr != nil { - return nil, fmt.Errorf("failed to chunk as JSONL: %w", chunkErr) - } - return chunks, nil - } - - if len(transcript.Messages) == 0 { - return [][]byte{content}, nil - } - - var chunks [][]byte - var currentMessages []GeminiMessage - currentSize := len(`{"messages":[]}`) // Base JSON structure size - - for i, msg := range transcript.Messages { - // Marshal message to get its size - msgBytes, err := json.Marshal(msg) - if err != nil { - logging.Warn(ctx, "failed to marshal Gemini message during chunking", - slog.Int("message_index", i), - slog.String("error", err.Error()), - ) - continue - } - msgSize := len(msgBytes) + 1 // +1 for comma separator - - if currentSize+msgSize > maxSize && len(currentMessages) > 0 { - // Save current chunk - chunkData, err := json.Marshal(GeminiTranscript{Messages: currentMessages}) - if err != nil { - return nil, fmt.Errorf("failed to marshal chunk: %w", err) - } - chunks = append(chunks, chunkData) - - // Start new chunk - currentMessages = nil - currentSize = len(`{"messages":[]}`) - } - - currentMessages = append(currentMessages, msg) - currentSize += msgSize - } - - // Add the last chunk - if len(currentMessages) > 0 { - chunkData, err := json.Marshal(GeminiTranscript{Messages: currentMessages}) - if err != nil { - return nil, fmt.Errorf("failed to marshal final chunk: %w", err) - } - chunks = append(chunks, chunkData) - } - - // Ensure we created at least one chunk (could be empty if all messages failed to marshal) - if len(chunks) == 0 { - return nil, errors.New("failed to create any chunks: all messages failed to marshal") - } - - return chunks, nil -} - -// LaunchCmd builds an exec.Cmd for `gemini ""`. Stdio is wired -// to the caller's TTY so the agent runs foreground and the user interacts -// normally. The call site is expected to Run() and wait. Hooks inherit the -// parent environment. -func (g *GeminiCLIAgent) LaunchCmd(ctx context.Context, initialPrompt string) (*exec.Cmd, error) { - bin, err := exec.LookPath("gemini") - if err != nil { - return nil, fmt.Errorf("gemini binary not on PATH: %w", err) - } - cmd := exec.CommandContext(ctx, bin, initialPrompt) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = os.Environ() - return cmd, nil -} - -// ReassembleTranscript merges Gemini JSON chunks by combining their message arrays. -func (g *GeminiCLIAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { - var allMessages []GeminiMessage - - for _, chunk := range chunks { - var transcript GeminiTranscript - if err := json.Unmarshal(chunk, &transcript); err != nil { - return nil, fmt.Errorf("failed to unmarshal chunk: %w", err) - } - allMessages = append(allMessages, transcript.Messages...) - } - - result, err := json.Marshal(GeminiTranscript{Messages: allMessages}) - if err != nil { - return nil, fmt.Errorf("failed to marshal reassembled transcript: %w", err) - } - return result, nil -} diff --git a/cmd/entire/cli/agent/geminicli/gemini_test.go b/cmd/entire/cli/agent/geminicli/gemini_test.go deleted file mode 100644 index bcd303a36b..0000000000 --- a/cmd/entire/cli/agent/geminicli/gemini_test.go +++ /dev/null @@ -1,838 +0,0 @@ -package geminicli - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/entireio/cli/cmd/entire/cli/agent" -) - -func TestNewGeminiCLIAgent(t *testing.T) { - t.Parallel() - - ag := NewGeminiCLIAgent() - if ag == nil { - t.Fatal("NewGeminiCLIAgent() returned nil") - } - - gemini, ok := ag.(*GeminiCLIAgent) - if !ok { - t.Fatal("NewGeminiCLIAgent() didn't return *GeminiCLIAgent") - } - if gemini == nil { - t.Fatal("NewGeminiCLIAgent() returned nil agent") - } -} - -func TestName(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - if name := ag.Name(); name != agent.AgentNameGemini { - t.Errorf("Name() = %q, want %q", name, agent.AgentNameGemini) - } -} - -func TestDescription(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - desc := ag.Description() - if desc == "" { - t.Error("Description() returned empty string") - } -} - -func TestDetectPresence(t *testing.T) { - t.Run("no .gemini directory", func(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - ag := &GeminiCLIAgent{} - present, err := ag.DetectPresence(context.Background()) - if err != nil { - t.Fatalf("DetectPresence() error = %v", err) - } - if present { - t.Error("DetectPresence() = true, want false") - } - }) - - t.Run("with .gemini directory", func(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - // Create .gemini directory - if err := os.Mkdir(".gemini", 0o755); err != nil { - t.Fatalf("failed to create .gemini: %v", err) - } - - ag := &GeminiCLIAgent{} - present, err := ag.DetectPresence(context.Background()) - if err != nil { - t.Fatalf("DetectPresence() error = %v", err) - } - if !present { - t.Error("DetectPresence() = false, want true") - } - }) -} - -func TestGetSessionID(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - input := &agent.HookInput{SessionID: "test-session-123"} - - id := ag.GetSessionID(input) - if id != "test-session-123" { - t.Errorf("GetSessionID() = %q, want test-session-123", id) - } -} - -func TestResolveSessionFile(t *testing.T) { - t.Parallel() - - t.Run("finds existing Gemini-named file", func(t *testing.T) { - t.Parallel() - tmpDir := t.TempDir() - ag := &GeminiCLIAgent{} - - // Create a file with Gemini's naming convention - geminiFile := filepath.Join(tmpDir, "session-2026-02-10T09-19-0544a0f5.json") - if err := os.WriteFile(geminiFile, []byte("{}"), 0o644); err != nil { - t.Fatal(err) - } - - result := ag.ResolveSessionFile(tmpDir, "0544a0f5-46a6-41b3-a89c-e7804df731b8") - if result != geminiFile { - t.Errorf("ResolveSessionFile() = %q, want %q", result, geminiFile) - } - }) - - t.Run("falls back to Gemini-style filename when no match", func(t *testing.T) { - t.Parallel() - tmpDir := t.TempDir() - ag := &GeminiCLIAgent{} - - result := ag.ResolveSessionFile(tmpDir, "0544a0f5-46a6-41b3-a89c-e7804df731b8") - filename := filepath.Base(result) - if !strings.HasPrefix(filename, "session-") { - t.Errorf("fallback filename %q should start with 'session-'", filename) - } - if !strings.HasSuffix(filename, "-0544a0f5.json") { - t.Errorf("fallback filename %q should end with '-0544a0f5.json'", filename) - } - if filepath.Dir(result) != tmpDir { - t.Errorf("fallback dir = %q, want %q", filepath.Dir(result), tmpDir) - } - }) - - t.Run("handles short session ID", func(t *testing.T) { - t.Parallel() - tmpDir := t.TempDir() - ag := &GeminiCLIAgent{} - - // Short ID (less than 8 chars) should use entire ID in filename - result := ag.ResolveSessionFile(tmpDir, "abc123") - filename := filepath.Base(result) - if !strings.HasPrefix(filename, "session-") { - t.Errorf("fallback filename %q should start with 'session-'", filename) - } - if !strings.HasSuffix(filename, "-abc123.json") { - t.Errorf("fallback filename %q should end with '-abc123.json'", filename) - } - }) -} - -func TestProtectedDirs(t *testing.T) { - t.Parallel() - ag := &GeminiCLIAgent{} - dirs := ag.ProtectedDirs() - if len(dirs) != 1 || dirs[0] != ".gemini" { - t.Errorf("ProtectedDirs() = %v, want [.gemini]", dirs) - } -} - -func TestGetSessionDir(t *testing.T) { - ag := &GeminiCLIAgent{} - - // Test with override env var - t.Setenv("ENTIRE_TEST_GEMINI_PROJECT_DIR", "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/test/override") - - dir, err := ag.GetSessionDir("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/some/repo") - if err != nil { - t.Fatalf("GetSessionDir() error = %v", err) - } - if dir != "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/test/override" { - t.Errorf("GetSessionDir() = %q, want /test/override", dir) - } -} - -func TestGetSessionDir_DefaultPath(t *testing.T) { - ag := &GeminiCLIAgent{} - - // Make sure env var is not set - t.Setenv("ENTIRE_TEST_GEMINI_PROJECT_DIR", "") - - dir, err := ag.GetSessionDir("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/some/repo") - if err != nil { - t.Fatalf("GetSessionDir() error = %v", err) - } - - // Should contain .gemini/tmp and end with /chats - if !filepath.IsAbs(dir) { - t.Errorf("GetSessionDir() should return absolute path, got %q", dir) - } -} - -func TestFormatResumeCommand(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - - cmd := ag.FormatResumeCommand("abc123") - expected := "gemini --resume abc123" - if cmd != expected { - t.Errorf("FormatResumeCommand() = %q, want %q", cmd, expected) - } -} - -func TestReadSession(t *testing.T) { - t.Parallel() - - tempDir := t.TempDir() - - // Create a transcript file - transcriptPath := filepath.Join(tempDir, "transcript.json") - transcriptContent := `{"messages": [{"role": "user", "content": "hello"}]}` - if err := os.WriteFile(transcriptPath, []byte(transcriptContent), 0o644); err != nil { - t.Fatalf("failed to write transcript: %v", err) - } - - ag := &GeminiCLIAgent{} - input := &agent.HookInput{ - SessionID: "test-session", - SessionRef: transcriptPath, - } - - session, err := ag.ReadSession(input) - if err != nil { - t.Fatalf("ReadSession() error = %v", err) - } - - if session.SessionID != "test-session" { - t.Errorf("SessionID = %q, want test-session", session.SessionID) - } - if session.AgentName != agent.AgentNameGemini { - t.Errorf("AgentName = %q, want %q", session.AgentName, agent.AgentNameGemini) - } - if len(session.NativeData) == 0 { - t.Error("NativeData is empty") - } -} - -func TestReadSession_NoSessionRef(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - input := &agent.HookInput{SessionID: "test-session"} - - _, err := ag.ReadSession(input) - if err == nil { - t.Error("ReadSession() should error when SessionRef is empty") - } -} - -func TestWriteSession(t *testing.T) { - t.Parallel() - - tempDir := t.TempDir() - transcriptPath := filepath.Join(tempDir, "transcript.json") - - ag := &GeminiCLIAgent{} - session := &agent.AgentSession{ - SessionID: "test-session", - AgentName: agent.AgentNameGemini, - SessionRef: transcriptPath, - NativeData: []byte(`{"messages": []}`), - } - - err := ag.WriteSession(context.Background(), session) - if err != nil { - t.Fatalf("WriteSession() error = %v", err) - } - - // Verify file was written - data, err := os.ReadFile(transcriptPath) - if err != nil { - t.Fatalf("failed to read transcript: %v", err) - } - - if string(data) != `{"messages": []}` { - t.Errorf("transcript content = %q, want {\"messages\": []}", string(data)) - } -} - -func TestWriteSession_Nil(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - - err := ag.WriteSession(context.Background(), nil) - if err == nil { - t.Error("WriteSession(nil) should error") - } -} - -func TestWriteSession_WrongAgent(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - session := &agent.AgentSession{ - AgentName: "claude-code", - SessionRef: "/path/to/file", - NativeData: []byte("{}"), - } - - err := ag.WriteSession(context.Background(), session) - if err == nil { - t.Error("WriteSession() should error for wrong agent") - } -} - -func TestWriteSession_NoSessionRef(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - session := &agent.AgentSession{ - AgentName: agent.AgentNameGemini, - NativeData: []byte("{}"), - } - - err := ag.WriteSession(context.Background(), session) - if err == nil { - t.Error("WriteSession() should error when SessionRef is empty") - } -} - -func TestWriteSession_NoNativeData(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - session := &agent.AgentSession{ - AgentName: agent.AgentNameGemini, - SessionRef: "/path/to/file", - } - - err := ag.WriteSession(context.Background(), session) - if err == nil { - t.Error("WriteSession() should error when NativeData is empty") - } -} - -// TestGetProjectHash_KnownAnswers pins GetProjectHash to fixed vectors. -// -// The value is an interop contract, not an internal detail: it names the -// directory Gemini CLI itself creates (~/.gemini/tmp//chats), computed -// there by its own getProjectHash(). If our derivation drifts — a normalizing -// filepath.Clean, a trailing separator, a prefix, a different encoding — we -// read an empty directory and silently capture no Gemini sessions. Property -// checks (deterministic, 64 hex chars, collision-free) all survive such a -// change, so the only guard is a literal digest. -// -// Vectors are plain SHA-256 of the path bytes, hex-encoded; reproduce with: -// -// printf %s '/Users/test/project' | shasum -a 256 -// -// A failure here means either a real regression or a deliberate, verified -// change on Gemini CLI's side — never a value to paste over from the output. -func TestGetProjectHash_KnownAnswers(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - projectRoot string - want string - }{ - { - name: "posix path", - projectRoot: "/Users/test/project", - want: "0654434d556baf695ffd946d23555e4f45e1ae867116ed11c5adca8fd5998a60", - }, - { - name: "sibling posix path", - projectRoot: "/Users/test/other", - want: "4fd9d0d2d64802efa27aad63ba36371dc2435256f5c3973d797c705e215239f7", - }, - { - // Guards against a normalizing prefix/suffix being introduced: - // the empty string must hash to SHA-256 of no bytes at all. - name: "empty path", - projectRoot: "", - want: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - }, - { - // Guards against a trailing separator being appended. - name: "single segment, no trailing separator", - projectRoot: "/repo", - want: "816fc349d3faebf805d1bed70fce7e14754cad5251c77dda31c414ee961a0bdd", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - if got := GetProjectHash(tt.projectRoot); got != tt.want { - t.Errorf("GetProjectHash(%q) = %q, want %q — this is Gemini CLI's session-directory name; a mismatch means Entire looks in the wrong directory and captures nothing", - tt.projectRoot, got, tt.want) - } - }) - } -} - -func TestGetProjectHash(t *testing.T) { - t.Parallel() - - // GetProjectHash should return a consistent SHA256 hex string for a given path - hash1 := GetProjectHash("/Users/test/project") - hash2 := GetProjectHash("/Users/test/project") - if hash1 != hash2 { - t.Errorf("GetProjectHash should be deterministic: got %q and %q", hash1, hash2) - } - - // Should be a 64-char hex string (SHA256) - if len(hash1) != 64 { - t.Errorf("GetProjectHash should return 64-char hex string, got %d chars: %q", len(hash1), hash1) - } - - // Different paths should produce different hashes - hash3 := GetProjectHash("/Users/test/other") - if hash1 == hash3 { - t.Errorf("GetProjectHash should return different hashes for different paths") - } -} - -// Chunking tests - -func TestChunkTranscript_SmallContent(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - - content := []byte(`{"messages":[{"type":"user","content":"hello"},{"type":"gemini","content":"hi there"}]}`) - - chunks, err := ag.ChunkTranscript(context.Background(), content, agent.MaxChunkSize) - if err != nil { - t.Fatalf("ChunkTranscript() error = %v", err) - } - if len(chunks) != 1 { - t.Errorf("Expected 1 chunk, got %d", len(chunks)) - } -} - -func TestChunkTranscript_LargeContent(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - - // Create a transcript with many messages that exceeds maxSize - var messages []GeminiMessage - for i := range 100 { - messages = append(messages, GeminiMessage{ - Type: "user", - Content: fmt.Sprintf("message %d with some content to make it larger: %s", i, strings.Repeat("x", 500)), - }) - } - - transcript := GeminiTranscript{Messages: messages} - content, err := json.Marshal(transcript) - if err != nil { - t.Fatalf("Failed to marshal test transcript: %v", err) - } - - // Use a small maxSize to force chunking - maxSize := 5000 - chunks, err := ag.ChunkTranscript(context.Background(), content, maxSize) - if err != nil { - t.Fatalf("ChunkTranscript() error = %v", err) - } - - if len(chunks) < 2 { - t.Errorf("Expected at least 2 chunks for large content, got %d", len(chunks)) - } - - // Verify each chunk is valid JSON with messages array - for i, chunk := range chunks { - var parsed GeminiTranscript - if err := json.Unmarshal(chunk, &parsed); err != nil { - t.Errorf("Chunk %d is not valid Gemini JSON: %v", i, err) - } - if len(parsed.Messages) == 0 { - t.Errorf("Chunk %d has no messages", i) - } - } - - // Verify reassembly gives back all messages - reassembled, err := ag.ReassembleTranscript(chunks) - if err != nil { - t.Fatalf("ReassembleTranscript() error = %v", err) - } - - var result GeminiTranscript - if err := json.Unmarshal(reassembled, &result); err != nil { - t.Fatalf("Failed to unmarshal reassembled content: %v", err) - } - - if len(result.Messages) != len(messages) { - t.Errorf("Reassembled message count = %d, want %d", len(result.Messages), len(messages)) - } -} - -func TestChunkTranscript_EmptyMessages(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - - content := []byte(`{"messages":[]}`) - - chunks, err := ag.ChunkTranscript(context.Background(), content, agent.MaxChunkSize) - if err != nil { - t.Fatalf("ChunkTranscript() error = %v", err) - } - if len(chunks) != 1 { - t.Errorf("Expected 1 chunk for empty messages, got %d", len(chunks)) - } - if string(chunks[0]) != string(content) { - t.Errorf("Expected original content preserved, got %s", chunks[0]) - } -} - -func TestChunkTranscript_InvalidJSON_FallsBackToJSONL(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - - // Invalid JSON that looks like JSONL - content := []byte(`{"type":"user","content":"hello"} -{"type":"gemini","content":"hi"}`) - - chunks, err := ag.ChunkTranscript(context.Background(), content, agent.MaxChunkSize) - if err != nil { - t.Fatalf("ChunkTranscript() error = %v", err) - } - // Should fall back to JSONL chunking and return 1 chunk for small content - if len(chunks) != 1 { - t.Errorf("Expected 1 chunk (JSONL fallback), got %d", len(chunks)) - } -} - -func TestChunkTranscript_RoundTrip(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - - // Create a realistic transcript - original := GeminiTranscript{ - Messages: []GeminiMessage{ - {Type: "user", Content: "Write a hello world program"}, - {Type: "gemini", Content: "Sure, here's a hello world program:", ToolCalls: []GeminiToolCall{ - {ID: "1", Name: "write_file", Args: map[string]interface{}{"path": "main.go", "content": "package main\n\nfunc main() {\n\tprintln(\"Hello, World!\")\n}"}}, - }}, - {Type: "user", Content: "Now add a function"}, - {Type: "gemini", Content: "I'll add a greet function:", ToolCalls: []GeminiToolCall{ - {ID: "2", Name: "edit_file", Args: map[string]interface{}{"path": "main.go"}}, - }}, - }, - } - - content, err := json.Marshal(original) - if err != nil { - t.Fatalf("Failed to marshal original: %v", err) - } - - // Use small maxSize to force chunking - maxSize := 200 - chunks, err := ag.ChunkTranscript(context.Background(), content, maxSize) - if err != nil { - t.Fatalf("ChunkTranscript() error = %v", err) - } - - reassembled, err := ag.ReassembleTranscript(chunks) - if err != nil { - t.Fatalf("ReassembleTranscript() error = %v", err) - } - - var result GeminiTranscript - if err := json.Unmarshal(reassembled, &result); err != nil { - t.Fatalf("Failed to unmarshal reassembled: %v", err) - } - - if len(result.Messages) != len(original.Messages) { - t.Fatalf("Message count mismatch: got %d, want %d", len(result.Messages), len(original.Messages)) - } - - for i, msg := range result.Messages { - if msg.Type != original.Messages[i].Type { - t.Errorf("Message %d type = %q, want %q", i, msg.Type, original.Messages[i].Type) - } - if msg.Content != original.Messages[i].Content { - t.Errorf("Message %d content mismatch", i) - } - if len(msg.ToolCalls) != len(original.Messages[i].ToolCalls) { - t.Errorf("Message %d toolCalls count = %d, want %d", i, len(msg.ToolCalls), len(original.Messages[i].ToolCalls)) - } - } -} - -func TestReassembleTranscript_SingleChunk(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - - content := []byte(`{"messages":[{"type":"user","content":"hello"}]}`) - chunks := [][]byte{content} - - result, err := ag.ReassembleTranscript(chunks) - if err != nil { - t.Fatalf("ReassembleTranscript() error = %v", err) - } - - var parsed GeminiTranscript - if err := json.Unmarshal(result, &parsed); err != nil { - t.Fatalf("Failed to unmarshal result: %v", err) - } - - if len(parsed.Messages) != 1 { - t.Errorf("Expected 1 message, got %d", len(parsed.Messages)) - } -} - -func TestReassembleTranscript_MultipleChunks(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - - chunk1 := []byte(`{"messages":[{"type":"user","content":"hello"}]}`) - chunk2 := []byte(`{"messages":[{"type":"gemini","content":"hi"}]}`) - chunks := [][]byte{chunk1, chunk2} - - result, err := ag.ReassembleTranscript(chunks) - if err != nil { - t.Fatalf("ReassembleTranscript() error = %v", err) - } - - var parsed GeminiTranscript - if err := json.Unmarshal(result, &parsed); err != nil { - t.Fatalf("Failed to unmarshal result: %v", err) - } - - if len(parsed.Messages) != 2 { - t.Errorf("Expected 2 messages, got %d", len(parsed.Messages)) - } - if parsed.Messages[0].Content != "hello" { - t.Errorf("First message content = %q, want hello", parsed.Messages[0].Content) - } - if parsed.Messages[1].Content != "hi" { - t.Errorf("Second message content = %q, want hi", parsed.Messages[1].Content) - } -} - -func TestReassembleTranscript_InvalidChunk(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - - chunk1 := []byte(`{"messages":[{"type":"user","content":"hello"}]}`) - chunk2 := []byte(`not valid json`) - chunks := [][]byte{chunk1, chunk2} - - _, err := ag.ReassembleTranscript(chunks) - if err == nil { - t.Error("ReassembleTranscript() should error on invalid JSON chunk") - } -} - -func TestReassembleTranscript_EmptyChunks(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - - result, err := ag.ReassembleTranscript([][]byte{}) - if err != nil { - t.Fatalf("ReassembleTranscript() error = %v", err) - } - - // Should return valid JSON with empty messages array - var parsed GeminiTranscript - if err := json.Unmarshal(result, &parsed); err != nil { - t.Fatalf("Failed to unmarshal result: %v", err) - } - - if len(parsed.Messages) != 0 { - t.Errorf("Expected 0 messages for empty chunks, got %d", len(parsed.Messages)) - } -} - -func TestChunkTranscript_SingleOversizedMessage(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - - // Create a single message that exceeds maxSize - largeContent := strings.Repeat("x", 1000) - transcript := GeminiTranscript{ - Messages: []GeminiMessage{ - {Type: "user", Content: largeContent}, - }, - } - - content, err := json.Marshal(transcript) - if err != nil { - t.Fatalf("Failed to marshal: %v", err) - } - - // maxSize smaller than the single message - maxSize := 100 - chunks, err := ag.ChunkTranscript(context.Background(), content, maxSize) - if err != nil { - t.Fatalf("ChunkTranscript() error = %v", err) - } - - // Should still produce a chunk (can't split a single message) - if len(chunks) != 1 { - t.Errorf("Expected 1 chunk for single oversized message, got %d", len(chunks)) - } - - // Verify it's valid and contains the message - var parsed GeminiTranscript - if err := json.Unmarshal(chunks[0], &parsed); err != nil { - t.Fatalf("Chunk is not valid JSON: %v", err) - } - if len(parsed.Messages) != 1 { - t.Errorf("Expected 1 message in chunk, got %d", len(parsed.Messages)) - } -} - -func TestChunkTranscript_ChunkBoundary(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - - // Create messages where the boundary matters - messages := []GeminiMessage{ - {Type: "user", Content: "msg1"}, - {Type: "gemini", Content: "msg2"}, - {Type: "user", Content: "msg3"}, - {Type: "gemini", Content: "msg4"}, - } - - transcript := GeminiTranscript{Messages: messages} - content, err := json.Marshal(transcript) - if err != nil { - t.Fatalf("Failed to marshal: %v", err) - } - - // Calculate size to get exactly 2 chunks with 2 messages each - // The base structure is {"messages":[]} = 15 chars - // Each message is roughly 25-30 chars including comma - maxSize := 100 - - chunks, err := ag.ChunkTranscript(context.Background(), content, maxSize) - if err != nil { - t.Fatalf("ChunkTranscript() error = %v", err) - } - - // Verify all messages are preserved across chunks - totalMessages := 0 - for _, chunk := range chunks { - var parsed GeminiTranscript - if err := json.Unmarshal(chunk, &parsed); err != nil { - t.Fatalf("Chunk is not valid JSON: %v", err) - } - totalMessages += len(parsed.Messages) - } - - if totalMessages != len(messages) { - t.Errorf("Total messages across chunks = %d, want %d", totalMessages, len(messages)) - } -} - -func TestChunkTranscript_PreservesMessageOrder(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - - // Create messages with numbered content to verify order - var messages []GeminiMessage - for i := range 20 { - messages = append(messages, GeminiMessage{ - Type: "user", - Content: fmt.Sprintf("message-%03d", i), - }) - } - - transcript := GeminiTranscript{Messages: messages} - content, err := json.Marshal(transcript) - if err != nil { - t.Fatalf("Failed to marshal: %v", err) - } - - // Small maxSize to force multiple chunks - chunks, err := ag.ChunkTranscript(context.Background(), content, 200) - if err != nil { - t.Fatalf("ChunkTranscript() error = %v", err) - } - - reassembled, err := ag.ReassembleTranscript(chunks) - if err != nil { - t.Fatalf("ReassembleTranscript() error = %v", err) - } - - var result GeminiTranscript - if err := json.Unmarshal(reassembled, &result); err != nil { - t.Fatalf("Failed to unmarshal: %v", err) - } - - // Verify message order is preserved - for i, msg := range result.Messages { - expected := fmt.Sprintf("message-%03d", i) - if msg.Content != expected { - t.Errorf("Message %d content = %q, want %q", i, msg.Content, expected) - } - } -} - -func TestGeminiCLIAgent_LaunchCmd(t *testing.T) { - t.Parallel() - a := NewGeminiCLIAgent() - launcher, ok := a.(agent.Launcher) - if !ok { - t.Fatal("GeminiCLIAgent does not implement agent.Launcher") - } - // Binary may not be on PATH in CI; ErrNotFound is acceptable for this test. - cmd, err := launcher.LaunchCmd(context.Background(), "hello world") - if err != nil { - if errors.Is(err, exec.ErrNotFound) { - t.Skip("gemini binary not on PATH; skipping cmd shape check") - } - t.Fatalf("LaunchCmd: %v", err) - } - if cmd == nil { - t.Fatal("nil cmd") - } - if cmd.Path == "" { - t.Error("cmd.Path empty") - } - joined := strings.Join(cmd.Args, " ") - if !strings.Contains(joined, "hello world") { - t.Errorf("args missing prompt: %v", cmd.Args) - } -} diff --git a/cmd/entire/cli/agent/geminicli/generate.go b/cmd/entire/cli/agent/geminicli/generate.go deleted file mode 100644 index 08b41e0089..0000000000 --- a/cmd/entire/cli/agent/geminicli/generate.go +++ /dev/null @@ -1,31 +0,0 @@ -package geminicli - -import ( - "context" - "fmt" - - "github.com/entireio/cli/cmd/entire/cli/agent" -) - -// GenerateText sends a prompt to the Gemini CLI and returns the raw text response. -// -// The prompt is piped to the Gemini CLI via stdin rather than embedded in argv. -// Per gemini --help, the -p/--prompt flag is appended to any input read from -// stdin; we pass a single-space placeholder to trigger headless (non-interactive) -// mode and let stdin carry the actual content, avoiding argv size limits. -func (g *GeminiCLIAgent) GenerateText(ctx context.Context, prompt string, model string) (string, error) { - args := []string{"-p", " "} - if model != "" { - args = append(args, "--model", model) - } - - result, capturedStderr, stdoutBytes, err := agent.RunIsolatedTextGeneratorCLI(ctx, g.CommandRunner, "gemini", "gemini", args, prompt) - if err != nil { - return "", &agent.TextGenerationError{ - Err: fmt.Errorf("gemini text generation failed: %w", err), - Stderr: capturedStderr, - StdoutBytes: stdoutBytes, - } - } - return result, nil -} diff --git a/cmd/entire/cli/agent/geminicli/hooks.go b/cmd/entire/cli/agent/geminicli/hooks.go deleted file mode 100644 index 87ccd91b84..0000000000 --- a/cmd/entire/cli/agent/geminicli/hooks.go +++ /dev/null @@ -1,550 +0,0 @@ -package geminicli - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "os" - "slices" - - "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/entireio/cli/cmd/entire/cli/jsonutil" - "github.com/entireio/cli/cmd/entire/cli/logging" - "github.com/entireio/cli/cmd/entire/cli/paths" -) - -// Ensure GeminiCLIAgent implements HookSupport -var ( - _ agent.HookSupport = (*GeminiCLIAgent)(nil) - _ agent.HookConfigLocator = (*GeminiCLIAgent)(nil) -) - -// Gemini CLI hook names - these become subcommands under `entire hooks gemini` -const ( - HookNameSessionStart = "session-start" - HookNameSessionEnd = "session-end" - HookNameBeforeAgent = "before-agent" - HookNameAfterAgent = "after-agent" - HookNameBeforeModel = "before-model" - HookNameAfterModel = "after-model" - HookNameBeforeToolSelection = "before-tool-selection" - HookNameBeforeTool = "before-tool" - HookNameAfterTool = "after-tool" - HookNamePreCompress = "pre-compress" - HookNameNotification = "notification" -) - -// GeminiSettingsFileName is the settings file used by Gemini CLI. -const GeminiSettingsFileName = "settings.json" - -// geminiHookConfig returns .gemini/settings.json for the current worktree, -// opened through the worktree's root. That directory lives in the working tree, -// which arrives by clone, so a checked-in symlink at `.gemini` must not be -// something Entire creates directories under and writes through. See -// agent.HookConfigFile. -func geminiHookConfig(ctx context.Context) (*agent.HookConfigFile, error) { - // Repo root rather than CWD, so hooks land correctly when run from a - // subdirectory. - repoRoot, err := paths.WorktreeRoot(ctx) - if err != nil { - // Not a repository (tests, and `enable` before `git init`): the process - // directory is the only candidate, and it is one the caller chose rather - // than one derived from anything read off disk. - repoRoot, err = os.Getwd() //nolint:forbidigo // Intentional fallback when WorktreeRoot() fails - if err != nil { - return nil, fmt.Errorf("failed to get current directory: %w", err) - } - } - return agent.OpenHookConfig(repoRoot, (&GeminiCLIAgent{}).HookConfigRelPath()) //nolint:wrapcheck // agent.HookConfigFile already names the file in its error -} - -// InstallHooks installs Gemini CLI hooks in .gemini/settings.json. -// If force is true, removes existing Entire hooks before installing. -// Returns the number of hooks installed. -func (g *GeminiCLIAgent) InstallHooks(ctx context.Context, force bool) (int, error) { - cfg, err := geminiHookConfig(ctx) - if err != nil { - return 0, err - } - - // Read existing settings if they exist - var rawSettings map[string]json.RawMessage - - // rawHooks preserves unknown hook types - var rawHooks map[string]json.RawMessage - - var hooksConfig GeminiHooksConfig - - existingData, readErr := cfg.Read() - if readErr == nil { - if err := json.Unmarshal(existingData, &rawSettings); err != nil { - return 0, fmt.Errorf("failed to parse existing settings.json: %w", err) - } - if hooksRaw, ok := rawSettings["hooks"]; ok { - if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { - return 0, fmt.Errorf("failed to parse hooks in settings.json: %w", err) - } - } - if hooksConfigRaw, ok := rawSettings["hooksConfig"]; ok { - if err := json.Unmarshal(hooksConfigRaw, &hooksConfig); err != nil { - return 0, fmt.Errorf("failed to parse hooksConfig in settings.json: %w", err) - } - } - } else { - rawSettings = make(map[string]json.RawMessage) - } - - if rawHooks == nil { - rawHooks = make(map[string]json.RawMessage) - } - - // Strip non-array values from hooks (removes legacy fields like "enabled": true - // that old Entire versions wrote directly into hooks, which Gemini CLI 0.33+ - // rejects because hooks.additionalProperties requires arrays). - cleanupDone := stripNonArrayHookFields(ctx, rawHooks) - - // Enable hooks via hooksConfig - // hooksConfig.Enabled must be true for Gemini CLI to execute hooks - hooksConfig.Enabled = true - - // Define hook commands up front: the idempotency check below needs the full - // expected set, not just session-start, to tell "already installed" from - // "some hook is still on an older command". - const cmdPrefix = "entire hooks gemini " - sessionStartCmd := agent.WrapProductionJSONWarningHookCommand(cmdPrefix+"session-start", agent.WarningFormatSingleLine) - sessionEndCmd := agent.WrapProductionSilentHookCommand(cmdPrefix + "session-end") - beforeAgentCmd := agent.WrapProductionSilentHookCommand(cmdPrefix + "before-agent") - afterAgentCmd := agent.WrapProductionSilentHookCommand(cmdPrefix + "after-agent") - beforeModelCmd := agent.WrapProductionSilentHookCommand(cmdPrefix + "before-model") - afterModelCmd := agent.WrapProductionSilentHookCommand(cmdPrefix + "after-model") - beforeToolSelectionCmd := agent.WrapProductionSilentHookCommand(cmdPrefix + "before-tool-selection") - beforeToolCmd := agent.WrapProductionSilentHookCommand(cmdPrefix + "before-tool") - afterToolCmd := agent.WrapProductionSilentHookCommand(cmdPrefix + "after-tool") - preCompressCmd := agent.WrapProductionSilentHookCommand(cmdPrefix + "pre-compress") - notificationCmd := agent.WrapProductionSilentHookCommand(cmdPrefix + "notification") - wantCommands := []string{ - sessionStartCmd, sessionEndCmd, beforeAgentCmd, afterAgentCmd, - beforeModelCmd, afterModelCmd, beforeToolSelectionCmd, beforeToolCmd, - afterToolCmd, preCompressCmd, notificationCmd, - } - - // Parse only the hook types we need to modify - var sessionStart, sessionEnd, beforeAgent, afterAgent []GeminiHookMatcher - var beforeModel, afterModel, beforeToolSelection []GeminiHookMatcher - var beforeTool, afterTool, preCompress, notification []GeminiHookMatcher - parseGeminiHookType(rawHooks, "SessionStart", &sessionStart) - parseGeminiHookType(rawHooks, "SessionEnd", &sessionEnd) - parseGeminiHookType(rawHooks, "BeforeAgent", &beforeAgent) - parseGeminiHookType(rawHooks, "AfterAgent", &afterAgent) - parseGeminiHookType(rawHooks, "BeforeModel", &beforeModel) - parseGeminiHookType(rawHooks, "AfterModel", &afterModel) - parseGeminiHookType(rawHooks, "BeforeToolSelection", &beforeToolSelection) - parseGeminiHookType(rawHooks, "BeforeTool", &beforeTool) - parseGeminiHookType(rawHooks, "AfterTool", &afterTool) - parseGeminiHookType(rawHooks, "PreCompress", &preCompress) - parseGeminiHookType(rawHooks, "Notification", ¬ification) - - // Check for idempotency BEFORE removing hooks. - // If the exact same hook command already exists, hooks are already installed. - // When cleanupDone, we still need to write the file to persist the cleanup, - // but we return 0 (not 12) so callers know no hooks were added. - // - // Sampling session-start alone is not enough: a stale Entire hook on any - // other type (notably one left by the removed local-dev mode, which ran a - // script inside the working tree) would then survive every non-force install - // because this returns before the remove+add cycle below. - allHookLists := [][]GeminiHookMatcher{ - sessionStart, sessionEnd, beforeAgent, afterAgent, beforeModel, afterModel, - beforeToolSelection, beforeTool, afterTool, preCompress, notification, - } - if !force { - existingCmd := getFirstEntireHookCommand(sessionStart) - if existingCmd == sessionStartCmd && !hasStaleEntireHook(allHookLists, wantCommands) { - if !cleanupDone { - return 0, nil // Already installed with this exact command, nothing to write - } - // Cleanup needed but hooks already installed — write cleaned rawHooks - // without running the full remove+add cycle. - return 0, writeGeminiSettingsFile(rawSettings, rawHooks, hooksConfig, cfg) - } - } - - // Remove existing Entire hooks first. Besides clean installs, this is what - // replaces hooks left by older versions — including local-dev hooks that - // pointed at a script inside the working tree (see - // agent.LegacyLocalDevHookScript, still matched by entireHookPrefixes). - sessionStart = removeEntireHooks(sessionStart) - sessionEnd = removeEntireHooks(sessionEnd) - beforeAgent = removeEntireHooks(beforeAgent) - afterAgent = removeEntireHooks(afterAgent) - beforeModel = removeEntireHooks(beforeModel) - afterModel = removeEntireHooks(afterModel) - beforeToolSelection = removeEntireHooks(beforeToolSelection) - beforeTool = removeEntireHooks(beforeTool) - afterTool = removeEntireHooks(afterTool) - preCompress = removeEntireHooks(preCompress) - notification = removeEntireHooks(notification) - - // Install all hooks - // Session lifecycle hooks - sessionStart = addGeminiHook(sessionStart, "", "entire-session-start", sessionStartCmd) - // SessionEnd fires on both "exit" and "logout" - install hooks for both matchers - sessionEnd = addGeminiHook(sessionEnd, "exit", "entire-session-end-exit", sessionEndCmd) - sessionEnd = addGeminiHook(sessionEnd, "logout", "entire-session-end-logout", sessionEndCmd) - - // Agent hooks (user prompt and response) - beforeAgent = addGeminiHook(beforeAgent, "", "entire-before-agent", beforeAgentCmd) - afterAgent = addGeminiHook(afterAgent, "", "entire-after-agent", afterAgentCmd) - - // Model hooks (LLM request/response - fires on every LLM call) - beforeModel = addGeminiHook(beforeModel, "", "entire-before-model", beforeModelCmd) - afterModel = addGeminiHook(afterModel, "", "entire-after-model", afterModelCmd) - - // Tool selection hook (before planner selects tools) - beforeToolSelection = addGeminiHook(beforeToolSelection, "", "entire-before-tool-selection", beforeToolSelectionCmd) - - // Tool hooks (before/after tool execution) - beforeTool = addGeminiHook(beforeTool, "*", "entire-before-tool", beforeToolCmd) - afterTool = addGeminiHook(afterTool, "*", "entire-after-tool", afterToolCmd) - - // Compression hook (before chat history compression) - preCompress = addGeminiHook(preCompress, "", "entire-pre-compress", preCompressCmd) - - // Notification hook (errors, warnings, info) - notification = addGeminiHook(notification, "", "entire-notification", notificationCmd) - - // 12 hooks total: - // - session-start (1) - // - session-end exit + logout (2) - // - before-agent, after-agent (2) - // - before-model, after-model (2) - // - before-tool-selection (1) - // - before-tool, after-tool (2) - // - pre-compress (1) - // - notification (1) - count := 12 - - // Marshal modified hook types back to rawHooks - marshalGeminiHookType(rawHooks, "SessionStart", sessionStart) - marshalGeminiHookType(rawHooks, "SessionEnd", sessionEnd) - marshalGeminiHookType(rawHooks, "BeforeAgent", beforeAgent) - marshalGeminiHookType(rawHooks, "AfterAgent", afterAgent) - marshalGeminiHookType(rawHooks, "BeforeModel", beforeModel) - marshalGeminiHookType(rawHooks, "AfterModel", afterModel) - marshalGeminiHookType(rawHooks, "BeforeToolSelection", beforeToolSelection) - marshalGeminiHookType(rawHooks, "BeforeTool", beforeTool) - marshalGeminiHookType(rawHooks, "AfterTool", afterTool) - marshalGeminiHookType(rawHooks, "PreCompress", preCompress) - marshalGeminiHookType(rawHooks, "Notification", notification) - - if err := writeGeminiSettingsFile(rawSettings, rawHooks, hooksConfig, cfg); err != nil { - return 0, err - } - return count, nil -} - -// stripNonArrayHookFields removes non-array values from rawHooks (e.g., legacy -// "enabled": true that old Entire versions wrote directly into hooks, which -// Gemini CLI 0.33+ rejects because hooks.additionalProperties requires arrays). -// Returns true if any fields were removed. -func stripNonArrayHookFields(ctx context.Context, rawHooks map[string]json.RawMessage) bool { - var cleaned bool - for key, val := range rawHooks { - trimmed := bytes.TrimSpace(val) - if len(trimmed) == 0 || trimmed[0] != '[' { - delete(rawHooks, key) - logging.Debug(ctx, "removed non-array field from hooks", slog.String("key", key)) - cleaned = true - } - } - return cleaned -} - -// writeGeminiSettingsFile marshals rawHooks and hooksConfig back into rawSettings and writes to disk. -func writeGeminiSettingsFile(rawSettings map[string]json.RawMessage, rawHooks map[string]json.RawMessage, hooksConfig GeminiHooksConfig, cfg *agent.HookConfigFile) error { - hooksConfigJSON, err := jsonutil.MarshalWithNoHTMLEscape(hooksConfig) - if err != nil { - return fmt.Errorf("failed to marshal hooksConfig: %w", err) - } - rawSettings["hooksConfig"] = hooksConfigJSON - - hooksJSON, err := jsonutil.MarshalWithNoHTMLEscape(rawHooks) - if err != nil { - return fmt.Errorf("failed to marshal hooks: %w", err) - } - rawSettings["hooks"] = hooksJSON - - output, err := jsonutil.MarshalIndentWithNewline(rawSettings, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal settings: %w", err) - } - - // Write creates .gemini with MkdirAllNoSymlink. - return cfg.Write(output, 0o600) //nolint:wrapcheck // agent.HookConfigFile already names the file in its error -} - -// parseGeminiHookType parses a specific hook type from rawHooks into the target slice. -// Silently ignores parse errors (leaves target unchanged). -func parseGeminiHookType(rawHooks map[string]json.RawMessage, hookType string, target *[]GeminiHookMatcher) { - if data, ok := rawHooks[hookType]; ok { - //nolint:errcheck,gosec // Intentionally ignoring parse errors - leave target as nil/empty - json.Unmarshal(data, target) - } -} - -// marshalGeminiHookType marshals a hook type back to rawHooks. -// If the slice is empty, removes the key from rawHooks. -func marshalGeminiHookType(rawHooks map[string]json.RawMessage, hookType string, matchers []GeminiHookMatcher) { - if len(matchers) == 0 { - delete(rawHooks, hookType) - return - } - data, err := jsonutil.MarshalWithNoHTMLEscape(matchers) - if err != nil { - return // Silently ignore marshal errors (shouldn't happen) - } - rawHooks[hookType] = data -} - -// UninstallHooks removes Entire hooks from Gemini CLI settings. -func (g *GeminiCLIAgent) UninstallHooks(ctx context.Context) error { - // Use repo root to find .gemini directory when run from a subdirectory - cfg, err := geminiHookConfig(ctx) - if err != nil { - return err - } - data, err := cfg.Read() - if err != nil { - // An absent file means nothing to uninstall; an unreadable one does not. - // Collapsing both leaves hooks on disk while reporting success. - if errors.Is(err, os.ErrNotExist) { - return nil - } - return fmt.Errorf("read %s: %w", cfg.Path(), err) - } - - var rawSettings map[string]json.RawMessage - if err := json.Unmarshal(data, &rawSettings); err != nil { - return fmt.Errorf("failed to parse settings.json: %w", err) - } - - // rawHooks preserves unknown hook types - var rawHooks map[string]json.RawMessage - if hooksRaw, ok := rawSettings["hooks"]; ok { - if err := json.Unmarshal(hooksRaw, &rawHooks); err != nil { - return fmt.Errorf("failed to parse hooks: %w", err) - } - } - if rawHooks == nil { - rawHooks = make(map[string]json.RawMessage) - } - - // Strip non-array values from hooks (same migration as InstallHooks) - stripNonArrayHookFields(ctx, rawHooks) - - // Parse only the hook types we need to modify - var sessionStart, sessionEnd, beforeAgent, afterAgent []GeminiHookMatcher - var beforeModel, afterModel, beforeToolSelection []GeminiHookMatcher - var beforeTool, afterTool, preCompress, notification []GeminiHookMatcher - parseGeminiHookType(rawHooks, "SessionStart", &sessionStart) - parseGeminiHookType(rawHooks, "SessionEnd", &sessionEnd) - parseGeminiHookType(rawHooks, "BeforeAgent", &beforeAgent) - parseGeminiHookType(rawHooks, "AfterAgent", &afterAgent) - parseGeminiHookType(rawHooks, "BeforeModel", &beforeModel) - parseGeminiHookType(rawHooks, "AfterModel", &afterModel) - parseGeminiHookType(rawHooks, "BeforeToolSelection", &beforeToolSelection) - parseGeminiHookType(rawHooks, "BeforeTool", &beforeTool) - parseGeminiHookType(rawHooks, "AfterTool", &afterTool) - parseGeminiHookType(rawHooks, "PreCompress", &preCompress) - parseGeminiHookType(rawHooks, "Notification", ¬ification) - - // Remove Entire hooks from all hook types - sessionStart = removeEntireHooks(sessionStart) - sessionEnd = removeEntireHooks(sessionEnd) - beforeAgent = removeEntireHooks(beforeAgent) - afterAgent = removeEntireHooks(afterAgent) - beforeModel = removeEntireHooks(beforeModel) - afterModel = removeEntireHooks(afterModel) - beforeToolSelection = removeEntireHooks(beforeToolSelection) - beforeTool = removeEntireHooks(beforeTool) - afterTool = removeEntireHooks(afterTool) - preCompress = removeEntireHooks(preCompress) - notification = removeEntireHooks(notification) - - // Marshal modified hook types back to rawHooks - marshalGeminiHookType(rawHooks, "SessionStart", sessionStart) - marshalGeminiHookType(rawHooks, "SessionEnd", sessionEnd) - marshalGeminiHookType(rawHooks, "BeforeAgent", beforeAgent) - marshalGeminiHookType(rawHooks, "AfterAgent", afterAgent) - marshalGeminiHookType(rawHooks, "BeforeModel", beforeModel) - marshalGeminiHookType(rawHooks, "AfterModel", afterModel) - marshalGeminiHookType(rawHooks, "BeforeToolSelection", beforeToolSelection) - marshalGeminiHookType(rawHooks, "BeforeTool", beforeTool) - marshalGeminiHookType(rawHooks, "AfterTool", afterTool) - marshalGeminiHookType(rawHooks, "PreCompress", preCompress) - marshalGeminiHookType(rawHooks, "Notification", notification) - - // Marshal hooks back (preserving unknown hook types) - if len(rawHooks) > 0 { - hooksJSON, err := jsonutil.MarshalWithNoHTMLEscape(rawHooks) - if err != nil { - return fmt.Errorf("failed to marshal hooks: %w", err) - } - rawSettings["hooks"] = hooksJSON - } else { - delete(rawSettings, "hooks") - } - - // Write back - output, err := jsonutil.MarshalIndentWithNewline(rawSettings, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal settings: %w", err) - } - - if err := cfg.Write(output, 0o600); err != nil { - return err //nolint:wrapcheck // agent.HookConfigFile already names the file in its error - } - return nil -} - -// AreHooksInstalled checks if Entire hooks are installed. -// -// A missing config file is an answer, not a failure: that file is where the -// state lives, so its absence means no hooks. Anything that stops us reading the -// answer — an unreadable file, malformed config — is returned as an error, since -// "we could not tell" and "there are none" are different things to a caller -// deciding whether hooks can be left alone. -func (g *GeminiCLIAgent) AreHooksInstalled(ctx context.Context) (bool, error) { - // Use repo root to find .gemini directory when run from a subdirectory - cfg, err := geminiHookConfig(ctx) - if err != nil { - return false, err - } - data, err := cfg.Read() - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - if err != nil { - logging.Warn(ctx, "gemini: failed to read settings file", "path", cfg.Path(), "err", err) - return false, fmt.Errorf("read %s: %w", cfg.Path(), err) - } - - var settings GeminiSettings - if err := json.Unmarshal(data, &settings); err != nil { - logging.Warn(ctx, "gemini: failed to parse settings file", "path", cfg.Path(), "err", err) - return false, fmt.Errorf("parse hook config: %w", err) - } - - // Check for at least one of our hooks using isEntireHook (matches legacy hook shapes too) - return hasEntireHook(settings.Hooks.SessionStart) || - hasEntireHook(settings.Hooks.SessionEnd) || - hasEntireHook(settings.Hooks.BeforeAgent) || - hasEntireHook(settings.Hooks.AfterAgent) || - hasEntireHook(settings.Hooks.BeforeModel) || - hasEntireHook(settings.Hooks.AfterModel) || - hasEntireHook(settings.Hooks.BeforeToolSelection) || - hasEntireHook(settings.Hooks.BeforeTool) || - hasEntireHook(settings.Hooks.AfterTool) || - hasEntireHook(settings.Hooks.PreCompress) || - hasEntireHook(settings.Hooks.Notification), nil -} - -// Helper functions for hook management - -// addGeminiHook adds a hook entry to matchers. -// Unlike Claude Code, Gemini hooks require a "name" field. -func addGeminiHook(matchers []GeminiHookMatcher, matcherName, hookName, command string) []GeminiHookMatcher { - entry := GeminiHookEntry{ - Name: hookName, - Type: "command", - Command: command, - } - - // Find or create matcher - for i, matcher := range matchers { - if matcher.Matcher == matcherName { - matchers[i].Hooks = append(matchers[i].Hooks, entry) - return matchers - } - } - - // Create new matcher - newMatcher := GeminiHookMatcher{ - Hooks: []GeminiHookEntry{entry}, - } - if matcherName != "" { - newMatcher.Matcher = matcherName - } - return append(matchers, newMatcher) -} - -// isEntireHook checks if a command is an Entire hook -func isEntireHook(command string) bool { - return agent.IsManagedHookCommand(command) -} - -// hasEntireHook checks if any hook in the matchers is an Entire hook -func hasEntireHook(matchers []GeminiHookMatcher) bool { - for _, matcher := range matchers { - for _, hook := range matcher.Hooks { - if isEntireHook(hook.Command) { - return true - } - } - } - return false -} - -// getFirstEntireHookCommand returns the command of the first Entire hook found, or empty string -func getFirstEntireHookCommand(matchers []GeminiHookMatcher) string { - for _, matcher := range matchers { - for _, hook := range matcher.Hooks { - if isEntireHook(hook.Command) { - return hook.Command - } - } - } - return "" -} - -// hasStaleEntireHook reports whether any list holds an Entire-owned hook whose -// command is not in want — i.e. a hook this version would not write. Foreign -// hooks are ignored; only commands recognized by entireHookPrefixes count, which -// includes the shapes older versions wrote (see agent.LegacyLocalDevHookScript). -func hasStaleEntireHook(lists [][]GeminiHookMatcher, want []string) bool { - for _, list := range lists { - for _, matcher := range list { - for _, hook := range matcher.Hooks { - if isEntireHook(hook.Command) && !slices.Contains(want, hook.Command) { - return true - } - } - } - } - return false -} - -// removeEntireHooks removes all Entire hooks from a list of matchers. -func removeEntireHooks(matchers []GeminiHookMatcher) []GeminiHookMatcher { - result := make([]GeminiHookMatcher, 0, len(matchers)) - for _, matcher := range matchers { - filteredHooks := make([]GeminiHookEntry, 0, len(matcher.Hooks)) - for _, hook := range matcher.Hooks { - if !isEntireHook(hook.Command) { - filteredHooks = append(filteredHooks, hook) - } - } - // Only keep the matcher if it has hooks remaining - if len(filteredHooks) > 0 { - matcher.Hooks = filteredHooks - result = append(result, matcher) - } - } - return result -} - -// HookConfigRelPath implements agent.HookConfigLocator. -func (g *GeminiCLIAgent) HookConfigRelPath() string { return ".gemini/" + GeminiSettingsFileName } diff --git a/cmd/entire/cli/agent/geminicli/hooks_test.go b/cmd/entire/cli/agent/geminicli/hooks_test.go deleted file mode 100644 index 4a1811456d..0000000000 --- a/cmd/entire/cli/agent/geminicli/hooks_test.go +++ /dev/null @@ -1,773 +0,0 @@ -package geminicli - -import ( - "context" - "encoding/json" - "fmt" - agentpkg "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/entireio/cli/cmd/entire/cli/agent/testutil" - "os" - "path/filepath" - "testing" -) - -const testMatcherStartup = "startup" -const testHookNameMyHook = "my-hook" - -func TestInstallHooks_FreshInstall(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - agent := &GeminiCLIAgent{} - count, err := agent.InstallHooks(context.Background(), false) - if err != nil { - t.Fatalf("InstallHooks() error = %v", err) - } - - // 12 hooks: SessionStart, SessionEnd (exit+logout), BeforeAgent, AfterAgent, - // BeforeModel, AfterModel, BeforeToolSelection, BeforeTool, AfterTool, PreCompress, Notification - if count != 12 { - t.Errorf("InstallHooks() count = %d, want 12", count) - } - - // Verify settings.json was created with hooks - settings := readGeminiSettings(t, tempDir) - - // Verify HooksConfig.Enabled is true - if !settings.HooksConfig.Enabled { - t.Error("hooksConfig.enabled should be true") - } - - // Verify all hooks are present - if len(settings.Hooks.SessionStart) != 1 { - t.Errorf("SessionStart hooks = %d, want 1", len(settings.Hooks.SessionStart)) - } - // SessionEnd has 2 matchers: exit and logout - if len(settings.Hooks.SessionEnd) != 2 { - t.Errorf("SessionEnd hooks = %d, want 2 (exit + logout)", len(settings.Hooks.SessionEnd)) - } - if len(settings.Hooks.BeforeAgent) != 1 { - t.Errorf("BeforeAgent hooks = %d, want 1", len(settings.Hooks.BeforeAgent)) - } - if len(settings.Hooks.AfterAgent) != 1 { - t.Errorf("AfterAgent hooks = %d, want 1", len(settings.Hooks.AfterAgent)) - } - if len(settings.Hooks.BeforeTool) != 1 { - t.Errorf("BeforeTool hooks = %d, want 1", len(settings.Hooks.BeforeTool)) - } - if len(settings.Hooks.AfterTool) != 1 { - t.Errorf("AfterTool hooks = %d, want 1", len(settings.Hooks.AfterTool)) - } - if len(settings.Hooks.BeforeModel) != 1 { - t.Errorf("BeforeModel hooks = %d, want 1", len(settings.Hooks.BeforeModel)) - } - if len(settings.Hooks.AfterModel) != 1 { - t.Errorf("AfterModel hooks = %d, want 1", len(settings.Hooks.AfterModel)) - } - if len(settings.Hooks.BeforeToolSelection) != 1 { - t.Errorf("BeforeToolSelection hooks = %d, want 1", len(settings.Hooks.BeforeToolSelection)) - } - if len(settings.Hooks.PreCompress) != 1 { - t.Errorf("PreCompress hooks = %d, want 1", len(settings.Hooks.PreCompress)) - } - if len(settings.Hooks.Notification) != 1 { - t.Errorf("Notification hooks = %d, want 1", len(settings.Hooks.Notification)) - } - - // Verify hook commands invoke the entire binary - verifyHookCommand(t, settings.Hooks.SessionStart, "", agentpkg.WrapProductionJSONWarningHookCommand("entire hooks gemini session-start", agentpkg.WarningFormatSingleLine)) - verifyHookCommand(t, settings.Hooks.SessionEnd, "exit", agentpkg.WrapProductionSilentHookCommand("entire hooks gemini session-end")) - verifyHookCommand(t, settings.Hooks.SessionEnd, "logout", agentpkg.WrapProductionSilentHookCommand("entire hooks gemini session-end")) - verifyHookCommand(t, settings.Hooks.BeforeAgent, "", agentpkg.WrapProductionSilentHookCommand("entire hooks gemini before-agent")) - verifyHookCommand(t, settings.Hooks.AfterAgent, "", agentpkg.WrapProductionSilentHookCommand("entire hooks gemini after-agent")) - verifyHookCommand(t, settings.Hooks.BeforeModel, "", agentpkg.WrapProductionSilentHookCommand("entire hooks gemini before-model")) - verifyHookCommand(t, settings.Hooks.AfterModel, "", agentpkg.WrapProductionSilentHookCommand("entire hooks gemini after-model")) - verifyHookCommand(t, settings.Hooks.BeforeToolSelection, "", agentpkg.WrapProductionSilentHookCommand("entire hooks gemini before-tool-selection")) - verifyHookCommand(t, settings.Hooks.BeforeTool, "*", agentpkg.WrapProductionSilentHookCommand("entire hooks gemini before-tool")) - verifyHookCommand(t, settings.Hooks.AfterTool, "*", agentpkg.WrapProductionSilentHookCommand("entire hooks gemini after-tool")) - verifyHookCommand(t, settings.Hooks.PreCompress, "", agentpkg.WrapProductionSilentHookCommand("entire hooks gemini pre-compress")) - verifyHookCommand(t, settings.Hooks.Notification, "", agentpkg.WrapProductionSilentHookCommand("entire hooks gemini notification")) -} - -func TestInstallHooks_ReplacesLegacyLocalDevHook(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - ctx := context.Background() - ag := &GeminiCLIAgent{} - - testutil.AssertLegacyHookReplaced(t, - filepath.Join(tempDir, ".gemini", "settings.json"), - agentpkg.WrapProductionSilentHookCommand("entire hooks gemini after-agent"), - testutil.LegacyLocalDevCommand("hooks gemini after-agent"), - func() { - if _, err := ag.InstallHooks(ctx, false); err != nil { - t.Fatalf("InstallHooks() error = %v", err) - } - }) -} - -func TestInstallHooks_Idempotent(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - agent := &GeminiCLIAgent{} - - // First install - count1, err := agent.InstallHooks(context.Background(), false) - if err != nil { - t.Fatalf("first InstallHooks() error = %v", err) - } - if count1 != 12 { - t.Errorf("first InstallHooks() count = %d, want 12", count1) - } - - // Second install should add 0 hooks - count2, err := agent.InstallHooks(context.Background(), false) - if err != nil { - t.Fatalf("second InstallHooks() error = %v", err) - } - if count2 != 0 { - t.Errorf("second InstallHooks() count = %d, want 0 (idempotent)", count2) - } - - // Verify still only 1 hook per type (except SessionEnd which has 2 matchers) - settings := readGeminiSettings(t, tempDir) - if len(settings.Hooks.SessionStart) != 1 { - t.Errorf("SessionStart hooks = %d after double install, want 1", len(settings.Hooks.SessionStart)) - } - if len(settings.Hooks.SessionEnd) != 2 { - t.Errorf("SessionEnd hooks = %d after double install, want 2", len(settings.Hooks.SessionEnd)) - } -} - -func TestInstallHooks_Force(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - agent := &GeminiCLIAgent{} - - // First install - _, err := agent.InstallHooks(context.Background(), false) - if err != nil { - t.Fatalf("first InstallHooks() error = %v", err) - } - - // Force reinstall should replace hooks - count, err := agent.InstallHooks(context.Background(), true) - if err != nil { - t.Fatalf("force InstallHooks() error = %v", err) - } - if count != 12 { - t.Errorf("force InstallHooks() count = %d, want 12", count) - } -} - -func TestInstallHooks_PreservesUserHooks(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - // Create settings.json with existing user hooks - writeGeminiSettings(t, tempDir, `{ - "hooks": { - "SessionStart": [ - { - "matcher": "startup", - "hooks": [{"name": "my-hook", "type": "command", "command": "echo hello"}] - } - ] - } -}`) - - agent := &GeminiCLIAgent{} - _, err := agent.InstallHooks(context.Background(), false) - if err != nil { - t.Fatalf("InstallHooks() error = %v", err) - } - - settings := readGeminiSettings(t, tempDir) - - // Verify user hooks are preserved - if len(settings.Hooks.SessionStart) != 2 { - t.Errorf("SessionStart hooks = %d, want 2 (user + entire)", len(settings.Hooks.SessionStart)) - } - - // Verify user hook is still there - foundUserHook := false - for _, matcher := range settings.Hooks.SessionStart { - if matcher.Matcher == testMatcherStartup { - for _, hook := range matcher.Hooks { - if hook.Name == testHookNameMyHook { - foundUserHook = true - } - } - } - } - if !foundUserHook { - t.Error("user hook 'my-hook' was not preserved") - } -} - -func TestInstallHooks_PreservesUnknownHookTypes(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - // Create settings with hook types we don't handle (hypothetical future Gemini hook types) - writeGeminiSettings(t, tempDir, `{ - "hooks": { - "FutureHook": [ - { - "matcher": "", - "hooks": [{"name": "future-hook", "type": "command", "command": "echo future"}] - } - ], - "AnotherNewHook": [ - { - "matcher": "pattern", - "hooks": [{"name": "another-hook", "type": "command", "command": "echo another"}] - } - ] - } -}`) - - agent := &GeminiCLIAgent{} - _, err := agent.InstallHooks(context.Background(), false) - if err != nil { - t.Fatalf("InstallHooks() error = %v", err) - } - - // Read raw hooks to verify unknown hook types are preserved - rawHooks := testutil.ReadRawHooks(t, tempDir, ".gemini") - - // Verify FutureHook is preserved - if _, ok := rawHooks["FutureHook"]; !ok { - t.Errorf("FutureHook type was not preserved, got keys: %v", testutil.GetKeys(rawHooks)) - } - - // Verify AnotherNewHook is preserved - if _, ok := rawHooks["AnotherNewHook"]; !ok { - t.Errorf("AnotherNewHook type was not preserved, got keys: %v", testutil.GetKeys(rawHooks)) - } - - // Verify the FutureHook content is intact - var futureMatchers []GeminiHookMatcher - if err := json.Unmarshal(rawHooks["FutureHook"], &futureMatchers); err != nil { - t.Fatalf("failed to parse FutureHook: %v", err) - } - if len(futureMatchers) != 1 { - t.Errorf("FutureHook matchers = %d, want 1", len(futureMatchers)) - } - if len(futureMatchers) > 0 && len(futureMatchers[0].Hooks) > 0 { - if futureMatchers[0].Hooks[0].Command != "echo future" { - t.Errorf("FutureHook command = %q, want %q", - futureMatchers[0].Hooks[0].Command, "echo future") - } - } - - // Verify AnotherNewHook content including matcher - var anotherMatchers []GeminiHookMatcher - if err := json.Unmarshal(rawHooks["AnotherNewHook"], &anotherMatchers); err != nil { - t.Fatalf("failed to parse AnotherNewHook: %v", err) - } - if len(anotherMatchers) > 0 { - if anotherMatchers[0].Matcher != "pattern" { - t.Errorf("AnotherNewHook matcher = %q, want %q", anotherMatchers[0].Matcher, "pattern") - } - } - - // Verify our hooks were also installed - if _, ok := rawHooks["SessionStart"]; !ok { - t.Errorf("SessionStart hook should have been installed") - } -} - -func TestUninstallHooks_PreservesUnknownHookTypes(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - // Create settings with Entire hooks AND unknown hook types - writeGeminiSettings(t, tempDir, `{ - "hooks": { - "SessionStart": [ - { - "hooks": [{"name": "entire-session-start", "type": "command", "command": "sh -c 'if ! command -v entire >/dev/null 2>&1; then echo \"Entire CLI is enabled but not installed or not on PATH. Installation guide: https://docs.entire.io/cli/installation#installation-methods\" >&2; exit 0; fi; exec entire hooks gemini session-start'"}] - } - ], - "FutureHook": [ - { - "matcher": "", - "hooks": [{"name": "future-hook", "type": "command", "command": "echo future"}] - } - ] - } -}`) - - agent := &GeminiCLIAgent{} - err := agent.UninstallHooks(context.Background()) - if err != nil { - t.Fatalf("UninstallHooks() error = %v", err) - } - - // Read raw hooks to verify unknown hook types are preserved - rawHooks := testutil.ReadRawHooks(t, tempDir, ".gemini") - - // Verify FutureHook is preserved - if _, ok := rawHooks["FutureHook"]; !ok { - t.Errorf("FutureHook type was not preserved, got keys: %v", testutil.GetKeys(rawHooks)) - } - - // Verify our hooks were removed (SessionStart should be empty/removed) - if sessionStartRaw, ok := rawHooks["SessionStart"]; ok { - var matchers []GeminiHookMatcher - if err := json.Unmarshal(sessionStartRaw, &matchers); err == nil && len(matchers) > 0 { - t.Errorf("SessionStart hook should have been removed") - } - } -} - -func TestInstallHooks_PreservesUnknownFields(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - // Create settings.json with unknown fields - writeGeminiSettings(t, tempDir, `{ - "someOtherField": "value", - "customConfig": {"nested": true} -}`) - - agent := &GeminiCLIAgent{} - _, err := agent.InstallHooks(context.Background(), false) - if err != nil { - t.Fatalf("InstallHooks() error = %v", err) - } - - // Read raw settings to verify unknown fields are preserved - settingsPath := filepath.Join(tempDir, ".gemini", "settings.json") - data, err := os.ReadFile(settingsPath) - if err != nil { - t.Fatalf("failed to read settings.json: %v", err) - } - - var rawSettings map[string]json.RawMessage - if err := json.Unmarshal(data, &rawSettings); err != nil { - t.Fatalf("failed to parse settings.json: %v", err) - } - - if _, ok := rawSettings["someOtherField"]; !ok { - t.Error("someOtherField was not preserved") - } - if _, ok := rawSettings["customConfig"]; !ok { - t.Error("customConfig was not preserved") - } -} - -func TestUninstallHooks(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - agent := &GeminiCLIAgent{} - - // First install - _, err := agent.InstallHooks(context.Background(), false) - if err != nil { - t.Fatalf("InstallHooks() error = %v", err) - } - - // Verify hooks are installed - if !hooksInstalledNow(t, agent) { - t.Error("hooks should be installed before uninstall") - } - - // Uninstall - err = agent.UninstallHooks(context.Background()) - if err != nil { - t.Fatalf("UninstallHooks() error = %v", err) - } - - // Verify hooks are removed - if hooksInstalledNow(t, agent) { - t.Error("hooks should not be installed after uninstall") - } -} - -func TestUninstallHooks_NoSettingsFile(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - agent := &GeminiCLIAgent{} - - // Should not error when no settings file exists - err := agent.UninstallHooks(context.Background()) - if err != nil { - t.Fatalf("UninstallHooks() should not error when no settings file: %v", err) - } -} - -// TestUninstallHooks_UnreadableSettingsErrors pins the absent-vs-unreadable -// split: an absent settings file means nothing to uninstall, but a read error -// must surface instead of reporting success with hooks still on disk. The -// settings path is created as a directory so os.ReadFile fails with a -// non-ErrNotExist error on every platform. -func TestUninstallHooks_UnreadableSettingsErrors(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - if err := os.MkdirAll(filepath.Join(tempDir, ".gemini", GeminiSettingsFileName), 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - - if err := (&GeminiCLIAgent{}).UninstallHooks(context.Background()); err == nil { - t.Fatal("UninstallHooks() = nil for unreadable settings, want error") - } -} - -func TestUninstallHooks_PreservesUserHooks(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - // Create settings with both user and entire hooks - writeGeminiSettings(t, tempDir, `{ - "hooks": { - "SessionStart": [ - { - "matcher": "startup", - "hooks": [{"name": "my-hook", "type": "command", "command": "echo hello"}] - }, - { - "hooks": [{"name": "entire-session-start", "type": "command", "command": "sh -c 'if ! command -v entire >/dev/null 2>&1; then echo \"Entire CLI is enabled but not installed or not on PATH. Installation guide: https://docs.entire.io/cli/installation#installation-methods\" >&2; exit 0; fi; exec entire hooks gemini session-start'"}] - } - ] - } -}`) - - agent := &GeminiCLIAgent{} - err := agent.UninstallHooks(context.Background()) - if err != nil { - t.Fatalf("UninstallHooks() error = %v", err) - } - - settings := readGeminiSettings(t, tempDir) - - // Verify only user hooks remain - if len(settings.Hooks.SessionStart) != 1 { - t.Errorf("SessionStart hooks = %d after uninstall, want 1 (user only)", len(settings.Hooks.SessionStart)) - } - - // Verify it's the user hook - if settings.Hooks.SessionStart[0].Matcher != testMatcherStartup { - t.Error("user hook was removed during uninstall") - } -} - -func TestAreHooksInstalled(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - agent := &GeminiCLIAgent{} - - // Should be false when no settings file - if hooksInstalledNow(t, agent) { - t.Error("AreHooksInstalled() should be false when no settings file") - } - - // Install hooks - _, err := agent.InstallHooks(context.Background(), false) - if err != nil { - t.Fatalf("InstallHooks() error = %v", err) - } - - // Should be true after installation - if !hooksInstalledNow(t, agent) { - t.Error("AreHooksInstalled() should be true after installation") - } -} - -func TestHookNames(t *testing.T) { - agent := &GeminiCLIAgent{} - names := agent.HookNames() - - expected := []string{ - HookNameSessionStart, - HookNameSessionEnd, - HookNameBeforeAgent, - HookNameAfterAgent, - HookNameBeforeModel, - HookNameAfterModel, - HookNameBeforeToolSelection, - HookNameBeforeTool, - HookNameAfterTool, - HookNamePreCompress, - HookNameNotification, - } - - if len(names) != len(expected) { - t.Errorf("HookNames() returned %d names, want %d", len(names), len(expected)) - } - - for i, name := range expected { - if names[i] != name { - t.Errorf("HookNames()[%d] = %q, want %q", i, names[i], name) - } - } -} - -func TestInstallHooks_RemovesLegacyEnabledField(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - // Simulate settings.json written by old Entire that put "enabled": true inside hooks - writeGeminiSettings(t, tempDir, `{ - "hooks": { - "enabled": true, - "SessionStart": [ - { - "matcher": "startup", - "hooks": [{"name": "my-hook", "type": "command", "command": "echo user-startup-hook"}] - } - ] - } -}`) - - agent := &GeminiCLIAgent{} - _, err := agent.InstallHooks(context.Background(), false) - if err != nil { - t.Fatalf("InstallHooks() error = %v", err) - } - - // Verify "enabled" boolean is gone from hooks - rawHooks := testutil.ReadRawHooks(t, tempDir, ".gemini") - if _, ok := rawHooks["enabled"]; ok { - t.Error("legacy hooks.enabled field should have been removed") - } - - // Verify the user hook in SessionStart is still present - settings := readGeminiSettings(t, tempDir) - foundUserHook := false - for _, matcher := range settings.Hooks.SessionStart { - if matcher.Matcher == testMatcherStartup { - for _, hook := range matcher.Hooks { - if hook.Name == testHookNameMyHook { - foundUserHook = true - } - } - } - } - if !foundUserHook { - t.Error("user hook 'my-hook' should be preserved after legacy cleanup") - } -} - -func TestInstallHooks_RemovesLegacyEnabledField_WhenAlreadyInstalled(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - // Hooks already installed but legacy "enabled": true is also present - writeGeminiSettings(t, tempDir, fmt.Sprintf(`{ - "hooks": { - "enabled": true, - "SessionStart": [ - { - "hooks": [{"name": "entire-session-start", "type": "command", "command": %q}] - } - ] - } -}`, agentpkg.WrapProductionJSONWarningHookCommand("entire hooks gemini session-start", agentpkg.WarningFormatSingleLine))) - - agent := &GeminiCLIAgent{} - n, err := agent.InstallHooks(context.Background(), false) - if err != nil { - t.Fatalf("InstallHooks() error = %v", err) - } - - // Hooks were already installed — cleanup-only run should return 0, not 12. - if n != 0 { - t.Errorf("InstallHooks() count = %d, want 0 (hooks already installed, only cleanup occurred)", n) - } - - // Verify "enabled" boolean is gone even though idempotency would have fired - rawHooks := testutil.ReadRawHooks(t, tempDir, ".gemini") - if _, ok := rawHooks["enabled"]; ok { - t.Error("legacy hooks.enabled field should have been removed even when hooks were already installed") - } -} - -func TestInstallHooks_RemovesMultipleLegacyFields(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - // Multiple non-array legacy fields in hooks - writeGeminiSettings(t, tempDir, `{ - "hooks": { - "enabled": true, - "version": "1.0", - "debug": false, - "SessionStart": [ - { - "matcher": "startup", - "hooks": [{"name": "my-hook", "type": "command", "command": "echo user-startup-hook"}] - } - ] - } -}`) - - agent := &GeminiCLIAgent{} - _, err := agent.InstallHooks(context.Background(), false) - if err != nil { - t.Fatalf("InstallHooks() error = %v", err) - } - - rawHooks := testutil.ReadRawHooks(t, tempDir, ".gemini") - for _, key := range []string{"enabled", "version", "debug"} { - if _, ok := rawHooks[key]; ok { - t.Errorf("legacy field %q should have been removed", key) - } - } - - // Verify user hook survived - settings := readGeminiSettings(t, tempDir) - foundUserHook := false - for _, matcher := range settings.Hooks.SessionStart { - if matcher.Matcher == testMatcherStartup { - for _, hook := range matcher.Hooks { - if hook.Name == testHookNameMyHook { - foundUserHook = true - } - } - } - } - if !foundUserHook { - t.Error("user hook 'my-hook' should be preserved after legacy cleanup") - } -} - -func TestInstallHooks_ForceWithLegacyFields(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - // Legacy field present with existing hooks, force reinstall - writeGeminiSettings(t, tempDir, `{ - "hooks": { - "enabled": true, - "SessionStart": [ - { - "hooks": [{"name": "entire-session-start", "type": "command", "command": "sh -c 'if ! command -v entire >/dev/null 2>&1; then echo \"Entire CLI is enabled but not installed or not on PATH. Installation guide: https://docs.entire.io/cli/installation#installation-methods\" >&2; exit 0; fi; exec entire hooks gemini session-start'"}] - } - ] - } -}`) - - agent := &GeminiCLIAgent{} - count, err := agent.InstallHooks(context.Background(), true) - if err != nil { - t.Fatalf("InstallHooks() error = %v", err) - } - - // Force should reinstall all 12 hooks - if count != 12 { - t.Errorf("InstallHooks() count = %d, want 12 (force reinstall)", count) - } - - // Legacy field should be gone - rawHooks := testutil.ReadRawHooks(t, tempDir, ".gemini") - if _, ok := rawHooks["enabled"]; ok { - t.Error("legacy hooks.enabled field should have been removed on force reinstall") - } -} - -func TestUninstallHooks_RemovesLegacyEnabledField(t *testing.T) { - tempDir := t.TempDir() - t.Chdir(tempDir) - - // Simulate legacy settings with "enabled": true inside hooks plus an Entire hook - writeGeminiSettings(t, tempDir, `{ - "hooks": { - "enabled": true, - "SessionStart": [ - { - "hooks": [{"name": "entire-session-start", "type": "command", "command": "sh -c 'if ! command -v entire >/dev/null 2>&1; then echo \"Entire CLI is enabled but not installed or not on PATH. Installation guide: https://docs.entire.io/cli/installation#installation-methods\" >&2; exit 0; fi; exec entire hooks gemini session-start'"}] - } - ] - } -}`) - - agent := &GeminiCLIAgent{} - err := agent.UninstallHooks(context.Background()) - if err != nil { - t.Fatalf("UninstallHooks() error = %v", err) - } - - // Verify "enabled" boolean is gone from hooks - rawHooks := testutil.ReadRawHooks(t, tempDir, ".gemini") - if _, ok := rawHooks["enabled"]; ok { - t.Error("legacy hooks.enabled field should have been removed by UninstallHooks") - } -} - -// Helper functions - -func readGeminiSettings(t *testing.T, tempDir string) GeminiSettings { - t.Helper() - settingsPath := filepath.Join(tempDir, ".gemini", "settings.json") - data, err := os.ReadFile(settingsPath) - if err != nil { - t.Fatalf("failed to read settings.json: %v", err) - } - - var settings GeminiSettings - if err := json.Unmarshal(data, &settings); err != nil { - t.Fatalf("failed to parse settings.json: %v", err) - } - return settings -} - -func writeGeminiSettings(t *testing.T, tempDir, content string) { - t.Helper() - geminiDir := filepath.Join(tempDir, ".gemini") - if err := os.MkdirAll(geminiDir, 0o755); err != nil { - t.Fatalf("failed to create .gemini dir: %v", err) - } - settingsPath := filepath.Join(geminiDir, "settings.json") - if err := os.WriteFile(settingsPath, []byte(content), 0o644); err != nil { - t.Fatalf("failed to write settings.json: %v", err) - } -} - -func verifyHookCommand(t *testing.T, matchers []GeminiHookMatcher, expectedMatcher, expectedCommand string) { - t.Helper() - for _, matcher := range matchers { - if matcher.Matcher == expectedMatcher { - for _, hook := range matcher.Hooks { - if hook.Command == expectedCommand { - return // Found - } - } - } - } - t.Errorf("hook with matcher=%q command=%q not found", expectedMatcher, expectedCommand) -} - -// TestCommittedDogfoodSettingsIsCurrent guards this repo's own committed agent config against drifting from what -// InstallHooks writes. A stale committed config is how the pi extension ended up -// invoking a launcher script that had been deleted. -func TestCommittedDogfoodSettingsIsCurrent(t *testing.T) { - testutil.AssertCommittedDogfoodConfigStable(t, ".gemini/settings.json", func(t *testing.T, dir string) (int, error) { - t.Helper() - t.Chdir(dir) - return (&GeminiCLIAgent{}).InstallHooks(context.Background(), false) - }) -} - -// hooksInstalledNow reports whether the agent's hooks are installed, failing the -// test if it could not tell. Built-in agents read a local config file where -// absent means absent, so an error here is a bug, not a state to tolerate. -func hooksInstalledNow(t *testing.T, ag interface { - AreHooksInstalled(ctx context.Context) (bool, error) -}, -) bool { - t.Helper() - - installed, err := ag.AreHooksInstalled(context.Background()) - if err != nil { - t.Fatalf("AreHooksInstalled() error = %v", err) - } - return installed -} diff --git a/cmd/entire/cli/agent/geminicli/lifecycle.go b/cmd/entire/cli/agent/geminicli/lifecycle.go deleted file mode 100644 index 420d8c91c1..0000000000 --- a/cmd/entire/cli/agent/geminicli/lifecycle.go +++ /dev/null @@ -1,205 +0,0 @@ -package geminicli - -import ( - "context" - "encoding/json" - "fmt" - "io" - "os" - "time" - - "github.com/entireio/cli/cmd/entire/cli/agent" -) - -// Compile-time interface assertions for new interfaces. -var ( - _ agent.TranscriptAnalyzer = (*GeminiCLIAgent)(nil) - _ agent.TokenCalculator = (*GeminiCLIAgent)(nil) - _ agent.HookResponseWriter = (*GeminiCLIAgent)(nil) - _ agent.ContextInjector = (*GeminiCLIAgent)(nil) -) - -// WriteHookResponse outputs a hook response message as plain text to stdout. -// -// Why plain text and not JSON? Gemini CLI (as of v0.40.0) double-displays -// systemMessage when it arrives in JSON form: once via emitHookSystemMessage -// (rendered with the [hookName] source tag) and again via the SessionStart -// path's direct historyManager.addItem (rendered without a tag). With plain -// text, gemini's convertPlainTextToHookOutput synthesizes a systemMessage -// internally, the JSON-only emitHookSystemMessage event doesn't fire, and -// the user sees the banner exactly once. -func (g *GeminiCLIAgent) WriteHookResponse(message string) error { - if message == "" { - return nil - } - if _, err := fmt.Fprintln(os.Stdout, message); err != nil { - return fmt.Errorf("failed to write hook response: %w", err) - } - return nil -} - -// InjectionEvent reports that Gemini injects model context at TurnStart (its -// BeforeAgent hook). Gemini CLI's hook runner merges -// hookSpecificOutput.additionalContext into the model context (the plain-text -// path in WriteHookResponse is only a systemMessage double-display workaround, -// which does not apply to additionalContext). -func (g *GeminiCLIAgent) InjectionEvent() agent.EventType { return agent.TurnStart } - -// RenderContextInjection renders the BeforeAgent additionalContext payload -// Gemini injects into the model context. -func (g *GeminiCLIAgent) RenderContextInjection(inj agent.ContextInjection) ([]byte, error) { - out, err := agent.RenderAdditionalContextHookOutput("BeforeAgent", inj.Text) - if err != nil { - return nil, fmt.Errorf("render gemini context injection: %w", err) - } - return out, nil -} - -// HookNames returns the hook verbs Gemini CLI supports. -// These become subcommands: entire hooks gemini -func (g *GeminiCLIAgent) HookNames() []string { - return []string{ - HookNameSessionStart, - HookNameSessionEnd, - HookNameBeforeAgent, - HookNameAfterAgent, - HookNameBeforeModel, - HookNameAfterModel, - HookNameBeforeToolSelection, - HookNameBeforeTool, - HookNameAfterTool, - HookNamePreCompress, - HookNameNotification, - } -} - -// ParseHookEvent translates a Gemini CLI hook into a normalized lifecycle Event. -// Returns nil if the hook has no lifecycle significance (e.g., pass-through hooks). -func (g *GeminiCLIAgent) ParseHookEvent(_ context.Context, hookName string, stdin io.Reader) (*agent.Event, error) { - switch hookName { - case HookNameSessionStart: - return g.parseSessionInfoEvent(stdin, agent.SessionStart) - case HookNameBeforeAgent: - return g.parseTurnStart(stdin) - case HookNameAfterAgent: - return g.parseTurnEnd(stdin) - case HookNameSessionEnd: - return g.parseSessionInfoEvent(stdin, agent.SessionEnd) - case HookNamePreCompress: - return g.parseSessionInfoEvent(stdin, agent.Compaction) - case HookNameBeforeModel: - return g.parseBeforeModel(stdin) - case HookNameBeforeTool, HookNameAfterTool, - HookNameAfterModel, HookNameBeforeToolSelection, HookNameNotification: - // Acknowledged hooks with no lifecycle action - return nil, nil //nolint:nilnil // nil event = no lifecycle action - default: - return nil, nil //nolint:nilnil // Unknown hooks have no lifecycle action - } -} - -// ReadTranscript reads the raw JSON transcript bytes for a session. -func (g *GeminiCLIAgent) ReadTranscript(sessionRef string) ([]byte, error) { - data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input - if err != nil { - return nil, fmt.Errorf("failed to read transcript: %w", err) - } - return data, nil -} - -// CalculateTokenUsage computes token usage from the transcript starting at the given message offset. -func (g *GeminiCLIAgent) CalculateTokenUsage(transcriptData []byte, fromOffset int) (*agent.TokenUsage, error) { - var transcript struct { - Messages []geminiMessageWithTokens `json:"messages"` - } - - if err := json.Unmarshal(transcriptData, &transcript); err != nil { - return &agent.TokenUsage{}, fmt.Errorf("failed to parse transcript for token usage: %w", err) - } - - usage := &agent.TokenUsage{} - - for i, msg := range transcript.Messages { - // Skip messages before startMessageIndex - if i < fromOffset { - continue - } - - // Only count tokens from gemini (assistant) messages - if msg.Type != MessageTypeGemini { - continue - } - - if msg.Tokens == nil { - continue - } - - usage.APICallCount++ - usage.InputTokens += msg.Tokens.Input - usage.OutputTokens += msg.Tokens.Output - usage.CacheReadTokens += msg.Tokens.Cached - } - - return usage, nil -} - -// --- Internal hook parsing functions --- - -// parseSessionInfoEvent parses the hooks whose payload is sessionInfoRaw — -// SessionStart, SessionEnd, and PreCompress differ only in the event type. -func (g *GeminiCLIAgent) parseSessionInfoEvent(stdin io.Reader, eventType agent.EventType) (*agent.Event, error) { - raw, err := agent.ReadAndParseHookInput[sessionInfoRaw](stdin) - if err != nil { - return nil, err - } - return &agent.Event{ - Type: eventType, - SessionID: raw.SessionID, - SessionRef: raw.TranscriptPath, - Timestamp: time.Now(), - }, nil -} - -func (g *GeminiCLIAgent) parseTurnStart(stdin io.Reader) (*agent.Event, error) { - raw, err := agent.ReadAndParseHookInput[agentHookInputRaw](stdin) - if err != nil { - return nil, err - } - return &agent.Event{ - Type: agent.TurnStart, - SessionID: raw.SessionID, - SessionRef: raw.TranscriptPath, - Prompt: raw.Prompt, - Timestamp: time.Now(), - }, nil -} - -func (g *GeminiCLIAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) { - raw, err := agent.ReadAndParseHookInput[agentHookInputRaw](stdin) - if err != nil { - return nil, err - } - return &agent.Event{ - Type: agent.TurnEnd, - SessionID: raw.SessionID, - SessionRef: raw.TranscriptPath, - Timestamp: time.Now(), - }, nil -} - -func (g *GeminiCLIAgent) parseBeforeModel(stdin io.Reader) (*agent.Event, error) { - raw, err := agent.ReadAndParseHookInput[beforeModelRaw](stdin) - if err != nil { - return nil, err - } - model := raw.LLMRequest.Model - if model == "" { - return nil, nil //nolint:nilnil // no model info → no lifecycle action - } - return &agent.Event{ - Type: agent.ModelUpdate, - SessionID: raw.SessionID, - Model: model, - Timestamp: time.Now(), - }, nil -} diff --git a/cmd/entire/cli/agent/geminicli/lifecycle_test.go b/cmd/entire/cli/agent/geminicli/lifecycle_test.go deleted file mode 100644 index 70013f930a..0000000000 --- a/cmd/entire/cli/agent/geminicli/lifecycle_test.go +++ /dev/null @@ -1,533 +0,0 @@ -package geminicli - -import ( - "context" - "io" - "os" - "strings" - "testing" - - "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/stretchr/testify/require" -) - -func TestParseHookEvent_SessionStart(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - input := `{"session_id": "gemini-session-123", "transcript_path": "/tmp/gemini.json"}` - - event, err := ag.ParseHookEvent(context.Background(), HookNameSessionStart, strings.NewReader(input)) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - require.NotNil(t, event, "expected event, got nil") - if event.Type != agent.SessionStart { - t.Errorf("expected event type %v, got %v", agent.SessionStart, event.Type) - } - if event.SessionID != "gemini-session-123" { - t.Errorf("expected session_id 'gemini-session-123', got %q", event.SessionID) - } - if event.SessionRef != "/tmp/gemini.json" { - t.Errorf("expected session_ref '/tmp/gemini.json', got %q", event.SessionRef) - } - if event.Timestamp.IsZero() { - t.Error("expected non-zero timestamp") - } -} - -func TestParseHookEvent_TurnStart(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - input := `{ - "session_id": "sess-456", - "transcript_path": "/tmp/t.json", - "cwd": "/home/user", - "hook_event_name": "before-agent", - "timestamp": "2024-01-15T10:00:00Z", - "prompt": "Hello Gemini" - }` - - event, err := ag.ParseHookEvent(context.Background(), HookNameBeforeAgent, strings.NewReader(input)) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - require.NotNil(t, event, "expected event, got nil") - if event.Type != agent.TurnStart { - t.Errorf("expected event type %v, got %v", agent.TurnStart, event.Type) - } - if event.SessionID != "sess-456" { - t.Errorf("expected session_id 'sess-456', got %q", event.SessionID) - } - if event.Prompt != "Hello Gemini" { - t.Errorf("expected prompt 'Hello Gemini', got %q", event.Prompt) - } -} - -func TestParseHookEvent_TurnEnd(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - input := `{ - "session_id": "sess-789", - "transcript_path": "/tmp/after.json", - "cwd": "/home/user", - "hook_event_name": "after-agent", - "timestamp": "2024-01-15T10:05:00Z" - }` - - event, err := ag.ParseHookEvent(context.Background(), HookNameAfterAgent, strings.NewReader(input)) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - require.NotNil(t, event, "expected event, got nil") - if event.Type != agent.TurnEnd { - t.Errorf("expected event type %v, got %v", agent.TurnEnd, event.Type) - } - if event.SessionID != "sess-789" { - t.Errorf("expected session_id 'sess-789', got %q", event.SessionID) - } -} - -func TestParseHookEvent_SessionEnd(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - input := `{ - "session_id": "ending-session", - "transcript_path": "/tmp/end.json", - "reason": "exit" - }` - - event, err := ag.ParseHookEvent(context.Background(), HookNameSessionEnd, strings.NewReader(input)) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - require.NotNil(t, event, "expected event, got nil") - if event.Type != agent.SessionEnd { - t.Errorf("expected event type %v, got %v", agent.SessionEnd, event.Type) - } - if event.SessionID != "ending-session" { - t.Errorf("expected session_id 'ending-session', got %q", event.SessionID) - } -} - -func TestParseHookEvent_Compaction(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - input := `{ - "session_id": "compress-session", - "transcript_path": "/tmp/compress.json", - "hook_event_name": "pre-compress" - }` - - event, err := ag.ParseHookEvent(context.Background(), HookNamePreCompress, strings.NewReader(input)) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - require.NotNil(t, event, "expected event, got nil") - if event.Type != agent.Compaction { - t.Errorf("expected event type %v, got %v", agent.Compaction, event.Type) - } - if event.SessionID != "compress-session" { - t.Errorf("expected session_id 'compress-session', got %q", event.SessionID) - } -} - -func TestParseHookEvent_BeforeModel_ReturnsModelUpdate(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - input := `{ - "session_id": "model-sess", - "transcript_path": "/tmp/t.json", - "llm_request": {"model": "gemini-2.5-pro"} - }` - - event, err := ag.ParseHookEvent(context.Background(), HookNameBeforeModel, strings.NewReader(input)) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - require.NotNil(t, event, "expected event, got nil") - if event.Type != agent.ModelUpdate { - t.Errorf("expected ModelUpdate, got %v", event.Type) - } - if event.SessionID != "model-sess" { - t.Errorf("expected session_id 'model-sess', got %q", event.SessionID) - } - if event.Model != "gemini-2.5-pro" { - t.Errorf("expected model 'gemini-2.5-pro', got %q", event.Model) - } -} - -func TestParseHookEvent_BeforeModel_EmptyModel_ReturnsNil(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - input := `{ - "session_id": "no-model-sess", - "transcript_path": "/tmp/t.json", - "llm_request": {"model": ""} - }` - - event, err := ag.ParseHookEvent(context.Background(), HookNameBeforeModel, strings.NewReader(input)) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if event != nil { - t.Errorf("expected nil event for empty model, got %+v", event) - } -} - -func TestParseHookEvent_PassThroughHooks_ReturnNil(t *testing.T) { - t.Parallel() - - passThroughHooks := []string{ - HookNameBeforeTool, - HookNameAfterTool, - HookNameAfterModel, - HookNameBeforeToolSelection, - HookNameNotification, - } - - ag := &GeminiCLIAgent{} - input := `{"session_id": "test", "transcript_path": "/t"}` - - for _, hookName := range passThroughHooks { - t.Run(hookName, func(t *testing.T) { - t.Parallel() - - event, err := ag.ParseHookEvent(context.Background(), hookName, strings.NewReader(input)) - - if err != nil { - t.Fatalf("unexpected error for %s: %v", hookName, err) - } - if event != nil { - t.Errorf("expected nil event for %s, got %+v", hookName, event) - } - }) - } -} - -func TestParseHookEvent_UnknownHook_ReturnsNil(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - input := `{"session_id": "unknown", "transcript_path": "/tmp/unknown.json"}` - - event, err := ag.ParseHookEvent(context.Background(), "unknown-hook-name", strings.NewReader(input)) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if event != nil { - t.Errorf("expected nil event for unknown hook, got %+v", event) - } -} - -func TestParseHookEvent_EmptyInput(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - - _, err := ag.ParseHookEvent(context.Background(), HookNameSessionStart, strings.NewReader("")) - - if err == nil { - t.Fatal("expected error for empty input, got nil") - } - if !strings.Contains(err.Error(), "empty hook input") { - t.Errorf("expected 'empty hook input' error, got: %v", err) - } -} - -func TestParseHookEvent_MalformedJSON(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - input := `{"session_id": "test", "transcript_path": INVALID}` - - _, err := ag.ParseHookEvent(context.Background(), HookNameSessionStart, strings.NewReader(input)) - - if err == nil { - t.Fatal("expected error for malformed JSON, got nil") - } - if !strings.Contains(err.Error(), "failed to parse hook input") { - t.Errorf("expected 'failed to parse hook input' error, got: %v", err) - } -} - -func TestParseHookEvent_AllLifecycleHooks(t *testing.T) { - t.Parallel() - - testCases := []struct { - hookName string - expectedType agent.EventType - expectNil bool - inputTemplate string - }{ - { - hookName: HookNameSessionStart, - expectedType: agent.SessionStart, - inputTemplate: `{"session_id": "s1", "transcript_path": "/t"}`, - }, - { - hookName: HookNameBeforeAgent, - expectedType: agent.TurnStart, - inputTemplate: `{"session_id": "s2", "transcript_path": "/t", "prompt": "hi"}`, - }, - { - hookName: HookNameAfterAgent, - expectedType: agent.TurnEnd, - inputTemplate: `{"session_id": "s3", "transcript_path": "/t"}`, - }, - { - hookName: HookNameSessionEnd, - expectedType: agent.SessionEnd, - inputTemplate: `{"session_id": "s4", "transcript_path": "/t"}`, - }, - { - hookName: HookNamePreCompress, - expectedType: agent.Compaction, - inputTemplate: `{"session_id": "s5", "transcript_path": "/t"}`, - }, - { - hookName: HookNameBeforeTool, - expectNil: true, - inputTemplate: `{"session_id": "s6", "transcript_path": "/t"}`, - }, - { - hookName: HookNameAfterTool, - expectNil: true, - inputTemplate: `{"session_id": "s7", "transcript_path": "/t"}`, - }, - { - hookName: HookNameBeforeModel, - expectedType: agent.ModelUpdate, - inputTemplate: `{"session_id": "s8", "transcript_path": "/t", "llm_request": {"model": "gemini-2.5-pro"}}`, - }, - { - hookName: HookNameAfterModel, - expectNil: true, - inputTemplate: `{"session_id": "s9", "transcript_path": "/t"}`, - }, - { - hookName: HookNameBeforeToolSelection, - expectNil: true, - inputTemplate: `{"session_id": "s10", "transcript_path": "/t"}`, - }, - { - hookName: HookNameNotification, - expectNil: true, - inputTemplate: `{"session_id": "s11", "transcript_path": "/t"}`, - }, - } - - for _, tc := range testCases { - t.Run(tc.hookName, func(t *testing.T) { - t.Parallel() - - ag := &GeminiCLIAgent{} - event, err := ag.ParseHookEvent(context.Background(), tc.hookName, strings.NewReader(tc.inputTemplate)) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if tc.expectNil { - if event != nil { - t.Errorf("expected nil event, got %+v", event) - } - return - } - - require.NotNil(t, event, "expected event, got nil") - if event.Type != tc.expectedType { - t.Errorf("expected event type %v, got %v", tc.expectedType, event.Type) - } - }) - } -} - -func TestReadAndParse_ValidInput(t *testing.T) { - t.Parallel() - - input := `{ - "session_id": "test-123", - "transcript_path": "/path/to/transcript", - "cwd": "/home/user", - "hook_event_name": "session-start", - "timestamp": "2024-01-15T10:00:00Z" - }` - - result, err := agent.ReadAndParseHookInput[sessionInfoRaw](strings.NewReader(input)) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - require.NotNil(t, result, "expected result, got nil") - if result.SessionID != "test-123" { - t.Errorf("expected session_id 'test-123', got %q", result.SessionID) - } - if result.TranscriptPath != "/path/to/transcript" { - t.Errorf("expected transcript_path '/path/to/transcript', got %q", result.TranscriptPath) - } - if result.Cwd != "/home/user" { - t.Errorf("expected cwd '/home/user', got %q", result.Cwd) - } -} - -func TestReadAndParse_EmptyInput(t *testing.T) { - t.Parallel() - - _, err := agent.ReadAndParseHookInput[sessionInfoRaw](strings.NewReader("")) - - if err == nil { - t.Fatal("expected error for empty input") - } - if !strings.Contains(err.Error(), "empty hook input") { - t.Errorf("expected 'empty hook input' error, got: %v", err) - } -} - -func TestReadAndParse_InvalidJSON(t *testing.T) { - t.Parallel() - - _, err := agent.ReadAndParseHookInput[sessionInfoRaw](strings.NewReader("not valid json")) - - if err == nil { - t.Fatal("expected error for invalid JSON") - } - if !strings.Contains(err.Error(), "failed to parse hook input") { - t.Errorf("expected 'failed to parse hook input' error, got: %v", err) - } -} - -func TestReadAndParse_PartialJSON(t *testing.T) { - t.Parallel() - - // JSON with only some fields - should still parse (missing fields are zero values) - input := `{"session_id": "partial-only"}` - - result, err := agent.ReadAndParseHookInput[sessionInfoRaw](strings.NewReader(input)) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.SessionID != "partial-only" { - t.Errorf("expected session_id 'partial-only', got %q", result.SessionID) - } - if result.TranscriptPath != "" { - t.Errorf("expected empty transcript_path, got %q", result.TranscriptPath) - } -} - -func TestReadAndParse_ExtraFields(t *testing.T) { - t.Parallel() - - // JSON with extra fields - should ignore them - input := `{"session_id": "test", "transcript_path": "/t", "extra_field": "ignored", "another": 123}` - - result, err := agent.ReadAndParseHookInput[sessionInfoRaw](strings.NewReader(input)) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.SessionID != "test" { - t.Errorf("expected session_id 'test', got %q", result.SessionID) - } -} - -func TestReadAndParse_AgentHookInput(t *testing.T) { - t.Parallel() - - input := `{ - "session_id": "agent-session", - "transcript_path": "/path/to/agent.json", - "cwd": "/work", - "hook_event_name": "before-agent", - "timestamp": "2024-01-15T12:00:00Z", - "prompt": "User's question here" - }` - - result, err := agent.ReadAndParseHookInput[agentHookInputRaw](strings.NewReader(input)) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result.SessionID != "agent-session" { - t.Errorf("expected session_id 'agent-session', got %q", result.SessionID) - } - if result.Prompt != "User's question here" { - t.Errorf("expected prompt 'User's question here', got %q", result.Prompt) - } - if result.HookEventName != "before-agent" { - t.Errorf("expected hook_event_name 'before-agent', got %q", result.HookEventName) - } -} - -// captureStdout swaps os.Stdout for a pipe, runs fn, and returns what was -// written. Sequential (no t.Parallel) because os.Stdout is process-global. -func captureStdout(t *testing.T, fn func()) string { - t.Helper() - r, w, err := os.Pipe() - require.NoError(t, err) - - original := os.Stdout - os.Stdout = w - - done := make(chan []byte, 1) - go func() { - data, _ := io.ReadAll(r) //nolint:errcheck // best-effort drain - done <- data - }() - - fn() - require.NoError(t, w.Close()) - os.Stdout = original - got := <-done - require.NoError(t, r.Close()) - return string(got) -} - -// TestWriteHookResponse_PlainText_NoJSON verifies the response is emitted as -// plain text (not JSON). Gemini CLI v0.40.0 double-displays JSON systemMessage -// (once with the [hookName] tag, once without) — plain text takes only the -// non-tagged path so the user sees the banner once. -func TestWriteHookResponse_PlainText_NoJSON(t *testing.T) { - ag := &GeminiCLIAgent{} - out := captureStdout(t, func() { - require.NoError(t, ag.WriteHookResponse("hello banner")) - }) - - require.Equal(t, "hello banner\n", out, "expected exact plain-text output (no JSON envelope)") - require.False(t, strings.HasPrefix(strings.TrimSpace(out), "{"), - "output must not start with '{' — gemini's JSON parser would route it through the duplicate-display path") -} - -func TestWriteHookResponse_EmptyMessage_WritesNothing(t *testing.T) { - ag := &GeminiCLIAgent{} - out := captureStdout(t, func() { - require.NoError(t, ag.WriteHookResponse("")) - }) - require.Empty(t, out, "empty message should produce no output") -} - -func TestGeminiCLIAgent_ContextInjector(t *testing.T) { - t.Parallel() - g := &GeminiCLIAgent{} - require.Equal(t, agent.TurnStart, g.InjectionEvent()) - out, err := g.RenderContextInjection(agent.ContextInjection{Text: "use entire trail"}) - require.NoError(t, err) - // Gemini's BeforeAgent hook is its prompt-submit equivalent. - require.Contains(t, string(out), `"hookEventName":"BeforeAgent"`) - require.Contains(t, string(out), `"additionalContext":"use entire trail"`) -} diff --git a/cmd/entire/cli/agent/geminicli/reviewer.go b/cmd/entire/cli/agent/geminicli/reviewer.go deleted file mode 100644 index ce3c9a8e70..0000000000 --- a/cmd/entire/cli/agent/geminicli/reviewer.go +++ /dev/null @@ -1,76 +0,0 @@ -package geminicli - -import ( - "bufio" - "context" - "fmt" - "io" - "os" - "os/exec" - "strings" - - "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/entireio/cli/cmd/entire/cli/review" - reviewtypes "github.com/entireio/cli/cmd/entire/cli/review/types" -) - -// NewReviewer returns the AgentReviewer for gemini. -// -// Argv shape: gemini -p " " (space placeholder to trigger headless mode). -// Prompt is piped via stdin; per gemini --help the -p flag appends to stdin -// content, so passing a single space lets stdin carry the actual prompt. -// Stdout in this mode is the assistant text directly — parsed line-by-line. -func NewReviewer() *reviewtypes.ReviewerTemplate { - return &reviewtypes.ReviewerTemplate{ - AgentName: string(agent.AgentNameGemini), - BuildCmd: buildGeminiReviewCmd, - Parser: parseGeminiOutput, - } -} - -// buildGeminiReviewCmd builds the exec.Cmd for a gemini review run. -// Exposed at package level for test inspection of argv, stdin, and env. -func buildGeminiReviewCmd(ctx context.Context, cfg reviewtypes.RunConfig) *exec.Cmd { - prompt := review.ComposeReviewPrompt(cfg) - // Per the existing GenerateText implementation: pass "-p " " " as the - // argv placeholder to trigger headless (non-interactive) mode, and pipe - // the actual prompt via stdin to avoid argv size limits. - args := []string{"-p", " "} - args = review.AppendModelFlag(args, cfg.Model) - cmd := exec.CommandContext(ctx, "gemini", args...) - cmd.Stdin = strings.NewReader(prompt) - // Agent name must equal string(ag.Name()) — adoptReviewEnv compares - // ENTIRE_REVIEW_AGENT against it; any drift silently skips adoption. - cmd.Env = review.AppendReviewEnv(os.Environ(), string(agent.AgentNameGemini), cfg, prompt) - return cmd -} - -// parseGeminiOutput converts gemini's -p mode stdout into a stream of Events. -// Gemini emits clean assistant output with no chrome — the parser emits Started -// once, then one AssistantText per non-empty line, then Finished{Success: true} -// on clean EOF or RunError + Finished{Success: false} on a torn stream. -// -// Exposed for golden-file contract testing. -func parseGeminiOutput(r io.Reader) <-chan reviewtypes.Event { - out := make(chan reviewtypes.Event, 32) - go func() { - defer close(out) - out <- reviewtypes.Started{} - scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, 1024*1024), 16*1024*1024) - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - out <- reviewtypes.AssistantText{Text: line} - } - if err := scanner.Err(); err != nil { - out <- reviewtypes.RunError{Err: fmt.Errorf("read stdout: %w", err)} - out <- reviewtypes.Finished{Success: false} - return - } - out <- reviewtypes.Finished{Success: true} - }() - return out -} diff --git a/cmd/entire/cli/agent/geminicli/reviewer_test.go b/cmd/entire/cli/agent/geminicli/reviewer_test.go deleted file mode 100644 index c7df18c8be..0000000000 --- a/cmd/entire/cli/agent/geminicli/reviewer_test.go +++ /dev/null @@ -1,259 +0,0 @@ -package geminicli - -import ( - "context" - "errors" - "io" - "os" - "os/exec" - "strings" - "testing" - - "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/entireio/cli/cmd/entire/cli/review" - reviewtypes "github.com/entireio/cli/cmd/entire/cli/review/types" -) - -// Compile-time interface check: ReviewerTemplate implements AgentReviewer. -var _ reviewtypes.AgentReviewer = (*reviewtypes.ReviewerTemplate)(nil) - -const wantGeminiAgentName = "gemini" - -// TestGeminiReviewer_NameMatchesRegistryKey locks the reviewer's name to the -// agent registry's stable key. adoptReviewEnv compares ENTIRE_REVIEW_AGENT -// against string(ag.Name()); drift here silently breaks review-session -// tagging for this agent. -func TestGeminiReviewer_NameMatchesRegistryKey(t *testing.T) { - t.Parallel() - if wantGeminiAgentName != string(agent.AgentNameGemini) { - t.Fatalf("wantGeminiAgentName = %q, agent.AgentNameGemini = %q — keep these aligned", - wantGeminiAgentName, string(agent.AgentNameGemini)) - } -} - -func TestGeminiReviewer_Name(t *testing.T) { - t.Parallel() - r := NewReviewer() - if got := r.Name(); got != wantGeminiAgentName { - t.Errorf("Name() = %q, want %q", got, wantGeminiAgentName) - } -} - -func TestGeminiReviewer_EnvVarsSet(t *testing.T) { - t.Parallel() - cfg := reviewtypes.RunConfig{ - Skills: []string{"/gemini:review", "/security-review"}, - AlwaysPrompt: "Always check for security vulnerabilities.", - PerRunPrompt: "Focus on the API layer.", - StartingSHA: "cafebabe0000", - } - cmd := buildGeminiReviewCmd(context.Background(), cfg) - - wantKeys := []string{ - review.EnvSession, - review.EnvAgent, - review.EnvSkills, - review.EnvPrompt, - review.EnvStartingSHA, - } - envMap := geminiEnvToMap(cmd.Env) - - for _, key := range wantKeys { - if _, ok := envMap[key]; !ok { - t.Errorf("env var %s not set on cmd", key) - } - } - - if envMap[review.EnvSession] != "1" { - t.Errorf("%s = %q, want %q", review.EnvSession, envMap[review.EnvSession], "1") - } - if envMap[review.EnvAgent] != wantGeminiAgentName { - t.Errorf("%s = %q, want %q", review.EnvAgent, envMap[review.EnvAgent], wantGeminiAgentName) - } - if envMap[review.EnvStartingSHA] != "cafebabe0000" { - t.Errorf("%s = %q, want %q", review.EnvStartingSHA, envMap[review.EnvStartingSHA], "cafebabe0000") - } - if !strings.HasPrefix(envMap[review.EnvSkills], "[") { - t.Errorf("%s = %q, want JSON array", review.EnvSkills, envMap[review.EnvSkills]) - } -} - -func TestGeminiReviewer_ArgvShape(t *testing.T) { - t.Parallel() - cfg := reviewtypes.RunConfig{Skills: []string{"/skill"}} - cmd := buildGeminiReviewCmd(context.Background(), cfg) - - // Expect: gemini -p " " - if len(cmd.Args) != 3 { - t.Fatalf("len(Args) = %d, want 3: %v", len(cmd.Args), cmd.Args) - } - if cmd.Args[0] != "gemini" { - t.Errorf("Args[0] = %q, want %q", cmd.Args[0], "gemini") - } - if cmd.Args[1] != "-p" { - t.Errorf("Args[1] = %q, want %q", cmd.Args[1], "-p") - } - if cmd.Args[2] != " " { - t.Errorf("Args[2] = %q, want %q (space placeholder)", cmd.Args[2], " ") - } - // Stdin must be non-nil — gemini receives prompt via stdin. - if cmd.Stdin == nil { - t.Error("cmd.Stdin is nil; gemini requires prompt via stdin") - } -} - -func TestGeminiReviewer_NoBinaryRequiredAtConstruction(t *testing.T) { - // No t.Parallel — uses t.Setenv. - t.Setenv("PATH", "") - - r := NewReviewer() - cfg := reviewtypes.RunConfig{ - Skills: []string{"/test"}, - StartingSHA: "abc123", - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Construction (NewReviewer) MUST NOT touch PATH. Start may or may - // not error depending on whether the OS-level cmd.Start tries to - // resolve before fork — that's fine. The contract is just "no panic - // and no upfront LookPath call". - proc, err := r.Start(ctx, cfg) - // Either Start succeeded (deferred lookup; binary error surfaces in Wait) - // or Start failed with exec.ErrNotFound (immediate lookup at Cmd.Start). - // Both satisfy the deferred-lookup contract — what we explicitly DON'T - // want is a panic or error from NewReviewer itself. - if err != nil && !errors.Is(err, exec.ErrNotFound) { - // Tolerate "no such file" wrapping variations - if !strings.Contains(err.Error(), "executable file not found") && - !strings.Contains(err.Error(), "no such file") { - t.Errorf("unexpected error type: %v", err) - } - } - if proc != nil { - // Drain events to let parser goroutine exit cleanly. - drainGeminiEvents(proc.Events()) - _ = proc.Wait() //nolint:errcheck // best-effort cleanup in test - } -} - -func TestParseGeminiOutput_ReportsScannerError(t *testing.T) { - t.Parallel() - // Trigger bufio.Scanner's "token too long" error: produce a "line" - // that exceeds the 16MB max buffer without containing a newline. - r, w := io.Pipe() - go func() { - defer w.Close() - // 17MB of contiguous bytes without a newline - buf := make([]byte, 1024*1024) - for range 17 { - _, _ = w.Write(buf) //nolint:errcheck // best-effort write in test goroutine - } - }() - - events := collectGeminiEvents(parseGeminiOutput(r)) - - if len(events) < 2 { - t.Fatalf("expected at least Started + Finished, got %d events", len(events)) - } - last := events[len(events)-1] - fin, ok := last.(reviewtypes.Finished) - if !ok { - t.Fatalf("last event must be Finished, got %T", last) - } - if fin.Success { - t.Error("Finished.Success must be false on scanner error") - } - // Also assert at least one RunError event was emitted before Finished. - sawRunError := false - for _, ev := range events { - if _, ok := ev.(reviewtypes.RunError); ok { - sawRunError = true - break - } - } - if !sawRunError { - t.Error("expected RunError event before Finished{Success: false}") - } -} - -func TestGeminiReviewer_EventStream(t *testing.T) { - t.Parallel() - - data, err := os.ReadFile("testdata/canned_session.txt") - if err != nil { - t.Fatalf("read fixture: %v", err) - } - - events := collectGeminiEvents(parseGeminiOutput(strings.NewReader(string(data)))) - - if len(events) < 3 { - t.Fatalf("expected at least 3 events (Started + AssistantText + Finished), got %d", len(events)) - } - - // First event must be Started. - if _, ok := events[0].(reviewtypes.Started); !ok { - t.Errorf("events[0] = %T, want Started", events[0]) - } - - // Last event must be Finished{Success: true}. - last := events[len(events)-1] - fin, ok := last.(reviewtypes.Finished) - if !ok { - t.Errorf("last event = %T, want Finished", last) - } else if !fin.Success { - t.Errorf("Finished.Success = false, want true") - } - - // All middle events must be AssistantText with non-empty text. - for i := 1; i < len(events)-1; i++ { - at, ok := events[i].(reviewtypes.AssistantText) - if !ok { - t.Errorf("events[%d] = %T, want AssistantText", i, events[i]) - continue - } - if at.Text == "" { - t.Errorf("events[%d].Text is empty (empty lines must be skipped)", i) - } - } - - // Verify fixture content appears in text events. - var combined strings.Builder - for _, ev := range events { - if at, ok := ev.(reviewtypes.AssistantText); ok { - combined.WriteString(at.Text) - combined.WriteString("\n") - } - } - if !strings.Contains(combined.String(), "AgentReviewer") { - t.Error("expected fixture content mentioning 'AgentReviewer' to appear in AssistantText events") - } -} - -func collectGeminiEvents(ch <-chan reviewtypes.Event) []reviewtypes.Event { - var events []reviewtypes.Event - for ev := range ch { - events = append(events, ev) - } - return events -} - -// drainGeminiEvents consumes all events from ch without recording them. -func drainGeminiEvents(ch <-chan reviewtypes.Event) { - for ev := range ch { - _ = ev - } -} - -func geminiEnvToMap(env []string) map[string]string { - m := make(map[string]string, len(env)) - for _, e := range env { - idx := strings.IndexByte(e, '=') - if idx < 0 { - continue - } - m[e[:idx]] = e[idx+1:] - } - return m -} diff --git a/cmd/entire/cli/agent/geminicli/spawner.go b/cmd/entire/cli/agent/geminicli/spawner.go deleted file mode 100644 index ff8399af22..0000000000 --- a/cmd/entire/cli/agent/geminicli/spawner.go +++ /dev/null @@ -1,26 +0,0 @@ -package geminicli - -import ( - "context" - "os/exec" - "strings" - - "github.com/entireio/cli/cmd/entire/cli/agent/spawn" -) - -// geminiSpawner produces argv: gemini -p " "; prompt via stdin. -// The " " argv placeholder triggers headless mode; the prompt goes via stdin -// because gemini's -p flag appends to stdin content. -type geminiSpawner struct{} - -// NewSpawner returns a Spawner for gemini-cli's non-interactive review/investigate mode. -func NewSpawner() spawn.Spawner { return geminiSpawner{} } - -func (geminiSpawner) Name() string { return "gemini-cli" } - -func (geminiSpawner) BuildCmd(ctx context.Context, env []string, prompt string) *exec.Cmd { - cmd := exec.CommandContext(ctx, "gemini", "-p", " ") - cmd.Stdin = strings.NewReader(prompt) - cmd.Env = env - return cmd -} diff --git a/cmd/entire/cli/agent/geminicli/spawner_test.go b/cmd/entire/cli/agent/geminicli/spawner_test.go deleted file mode 100644 index 510516965c..0000000000 --- a/cmd/entire/cli/agent/geminicli/spawner_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package geminicli - -import ( - "context" - "io" - "reflect" - "testing" -) - -// TestGeminiCLISpawner_Name asserts the spawner reports the stable registry name. -func TestGeminiCLISpawner_Name(t *testing.T) { - t.Parallel() - if got := NewSpawner().Name(); got != "gemini-cli" { - t.Errorf("Name() = %q, want %q", got, "gemini-cli") - } -} - -// TestGeminiCLISpawner_Argv pins the argv + stdin contract: -// gemini -p " " (space placeholder triggers headless mode), prompt via stdin. -func TestGeminiCLISpawner_Argv(t *testing.T) { - t.Parallel() - env := []string{"FOO=bar", "BAZ=qux"} - cmd := NewSpawner().BuildCmd(context.Background(), env, "the-prompt") - - wantArgs := []string{"gemini", "-p", " "} - if !reflect.DeepEqual(cmd.Args, wantArgs) { - t.Errorf("Args = %v, want %v", cmd.Args, wantArgs) - } - - if !reflect.DeepEqual(cmd.Env, env) { - t.Errorf("Env = %v, want %v", cmd.Env, env) - } - - if cmd.Stdin == nil { - t.Fatal("Stdin = nil, want a reader carrying the prompt") - } - got, err := io.ReadAll(cmd.Stdin) - if err != nil { - t.Fatalf("read stdin: %v", err) - } - if string(got) != "the-prompt" { - t.Errorf("stdin = %q, want %q", string(got), "the-prompt") - } -} diff --git a/cmd/entire/cli/agent/geminicli/testdata/canned_session.txt b/cmd/entire/cli/agent/geminicli/testdata/canned_session.txt deleted file mode 100644 index 577bcd5432..0000000000 --- a/cmd/entire/cli/agent/geminicli/testdata/canned_session.txt +++ /dev/null @@ -1,11 +0,0 @@ -I've reviewed the changes on this branch. - -The `AgentReviewer` interface and `Process` type are well-designed abstractions. The sealed `Event` sum type prevents external packages from extending the event taxonomy — a sound design choice for a stable contract. - -**Summary of findings:** - -- Interface segregation is clean: `AgentReviewer` owns construction, `Process` owns the runtime lifecycle. -- The buffered channel (capacity 32) is appropriate for backpressure without blocking agent output parsing. -- `RunConfig` fields are all optional; zero value is safe to use. - -Overall the implementation looks correct. Ready to proceed to CU3 implementations. diff --git a/cmd/entire/cli/agent/geminicli/transcript.go b/cmd/entire/cli/agent/geminicli/transcript.go deleted file mode 100644 index 5c44881e3b..0000000000 --- a/cmd/entire/cli/agent/geminicli/transcript.go +++ /dev/null @@ -1,290 +0,0 @@ -package geminicli - -import ( - "encoding/json" - "fmt" - "strings" -) - -// Transcript parsing types - Gemini CLI uses JSON format for session storage -// Based on transcript_path format: ~/.gemini/tmp//chats/session--.json - -// Message type constants for Gemini transcripts -const ( - MessageTypeUser = "user" - MessageTypeGemini = "gemini" -) - -// GeminiTranscript represents the top-level structure of a Gemini session file -type GeminiTranscript struct { - Messages []GeminiMessage `json:"messages"` -} - -// GeminiMessage represents a single message in the transcript -type GeminiMessage struct { - ID string `json:"id,omitempty"` // UUID for the message - Type string `json:"type"` // MessageTypeUser or MessageTypeGemini - Content string `json:"content,omitempty"` - ToolCalls []GeminiToolCall `json:"toolCalls,omitempty"` -} - -// UnmarshalJSON handles both string and array content formats in Gemini transcripts. -// User messages use: "content": [{"text": "..."}] (array of objects) -// Gemini messages use: "content": "response text" (string) -func (m *GeminiMessage) UnmarshalJSON(data []byte) error { - // Use an alias to avoid infinite recursion - type Alias GeminiMessage - aux := &struct { - *Alias - - Content json.RawMessage `json:"content,omitempty"` - }{ - Alias: (*Alias)(m), - } - - if err := json.Unmarshal(data, aux); err != nil { - return fmt.Errorf("failed to unmarshal message: %w", err) - } - - if len(aux.Content) == 0 || string(aux.Content) == "null" { - m.Content = "" - return nil - } - - // Try string first (most common for gemini messages) - var strContent string - if err := json.Unmarshal(aux.Content, &strContent); err == nil { - m.Content = strContent - return nil - } - - // Try array of objects with "text" fields (user messages) - var parts []struct { - Text string `json:"text"` - } - if err := json.Unmarshal(aux.Content, &parts); err == nil { - var texts []string - for _, p := range parts { - if p.Text != "" { - texts = append(texts, p.Text) - } - } - m.Content = strings.Join(texts, "\n") - return nil - } - - // Unknown format - leave content empty - return nil -} - -// GeminiToolCall represents a tool call in a gemini message -type GeminiToolCall struct { - ID string `json:"id"` - Name string `json:"name"` - Args map[string]interface{} `json:"args"` - Status string `json:"status,omitempty"` -} - -// ParseTranscript parses raw JSON content into a transcript structure -func ParseTranscript(data []byte) (*GeminiTranscript, error) { - var transcript GeminiTranscript - if err := json.Unmarshal(data, &transcript); err != nil { - return nil, fmt.Errorf("failed to parse transcript: %w", err) - } - return &transcript, nil -} - -// ExtractModifiedFiles extracts files modified by tool calls from transcript data -func ExtractModifiedFiles(data []byte) ([]string, error) { - transcript, err := ParseTranscript(data) - if err != nil { - return nil, err - } - - return ExtractModifiedFilesFromTranscript(transcript), nil -} - -// ExtractModifiedFilesFromTranscript extracts files from a parsed transcript -func ExtractModifiedFilesFromTranscript(transcript *GeminiTranscript) []string { - fileSet := make(map[string]bool) - var files []string - - for _, msg := range transcript.Messages { - // Only process gemini messages (assistant messages) - if msg.Type != MessageTypeGemini { - continue - } - - // Process tool calls in this message - for _, toolCall := range msg.ToolCalls { - // Check if it's a file modification tool - isModifyTool := false - for _, name := range FileModificationTools { - if toolCall.Name == name { - isModifyTool = true - break - } - } - - if !isModifyTool { - continue - } - - // Extract file path from args map - var file string - if fp, ok := toolCall.Args["file_path"].(string); ok && fp != "" { - file = fp - } else if p, ok := toolCall.Args["path"].(string); ok && p != "" { - file = p - } else if fn, ok := toolCall.Args["filename"].(string); ok && fn != "" { - file = fn - } - - if file != "" && !fileSet[file] { - fileSet[file] = true - files = append(files, file) - } - } - } - - return files -} - -// ExtractAllUserPrompts extracts all user messages from transcript data -func ExtractAllUserPrompts(data []byte) ([]string, error) { - transcript, err := ParseTranscript(data) - if err != nil { - return nil, err - } - - return ExtractAllUserPromptsFromTranscript(transcript), nil -} - -// ExtractAllUserPromptsFromTranscript extracts all user prompts from a parsed transcript -func ExtractAllUserPromptsFromTranscript(transcript *GeminiTranscript) []string { - var prompts []string - for _, msg := range transcript.Messages { - if msg.Type == MessageTypeUser && msg.Content != "" { - prompts = append(prompts, msg.Content) - } - } - return prompts -} - -// NormalizeTranscript normalizes user message content fields in-place from -// [{"text":"..."}] arrays to plain strings, preserving all other transcript fields -// (timestamps, thoughts, tokens, model, toolCalls, etc.). -// -// This operates on raw JSON rather than using ParseTranscript + re-marshal because -// GeminiMessage only captures a subset of fields (id, type, content, toolCalls). -// Round-tripping through the struct would silently drop fields like timestamp, model, -// and tokens that are present in real Gemini transcripts. The raw approach rewrites -// only the content values while leaving all other fields untouched. -func NormalizeTranscript(data []byte) ([]byte, error) { - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("failed to parse transcript: %w", err) - } - - messagesRaw, ok := raw["messages"] - if !ok { - return data, nil - } - - var messages []json.RawMessage - if err := json.Unmarshal(messagesRaw, &messages); err != nil { - return nil, fmt.Errorf("failed to parse messages: %w", err) - } - - changed := false - for i, msgRaw := range messages { - var msg map[string]json.RawMessage - if err := json.Unmarshal(msgRaw, &msg); err != nil { - continue - } - - contentRaw, hasContent := msg["content"] - if !hasContent || len(contentRaw) == 0 { - continue - } - - // Skip if already a string - var strContent string - if json.Unmarshal(contentRaw, &strContent) == nil { - continue - } - - // Try to convert array of {"text":"..."} to a plain string - var parts []struct { - Text string `json:"text"` - } - if json.Unmarshal(contentRaw, &parts) != nil { - continue - } - - var texts []string - for _, p := range parts { - if p.Text != "" { - texts = append(texts, p.Text) - } - } - joined := strings.Join(texts, "\n") - strBytes, err := json.Marshal(joined) - if err != nil { - continue - } - msg["content"] = strBytes - rewritten, err := json.Marshal(msg) - if err != nil { - continue - } - messages[i] = rewritten - changed = true - } - - if !changed { - return data, nil - } - - rewrittenMessages, err := json.Marshal(messages) - if err != nil { - return nil, fmt.Errorf("failed to re-serialize messages: %w", err) - } - raw["messages"] = rewrittenMessages - - result, err := json.MarshalIndent(raw, "", " ") - if err != nil { - return nil, fmt.Errorf("failed to re-serialize transcript: %w", err) - } - return result, nil -} - -// SliceFromMessage returns a Gemini transcript scoped to messages starting from -// startMessageIndex. This is the Gemini equivalent of transcript.SliceFromLine — -// for Gemini's single JSON blob, scoping is done by message index rather than line offset. -// Returns the original data if startMessageIndex <= 0. -// Returns nil, nil if startMessageIndex exceeds the number of messages. -func SliceFromMessage(data []byte, startMessageIndex int) ([]byte, error) { - if len(data) == 0 || startMessageIndex <= 0 { - return data, nil - } - - t, err := ParseTranscript(data) - if err != nil { - return nil, fmt.Errorf("failed to parse transcript for slicing: %w", err) - } - - if startMessageIndex >= len(t.Messages) { - return nil, nil - } - - scoped := &GeminiTranscript{ - Messages: t.Messages[startMessageIndex:], - } - - out, err := json.Marshal(scoped) - if err != nil { - return nil, fmt.Errorf("failed to marshal scoped transcript: %w", err) - } - return out, nil -} diff --git a/cmd/entire/cli/agent/geminicli/transcript_test.go b/cmd/entire/cli/agent/geminicli/transcript_test.go deleted file mode 100644 index 244dadfc3f..0000000000 --- a/cmd/entire/cli/agent/geminicli/transcript_test.go +++ /dev/null @@ -1,626 +0,0 @@ -package geminicli - -import ( - "encoding/json" - "os" - "testing" -) - -func TestNormalizeTranscript(t *testing.T) { - t.Parallel() - - // Raw Gemini format has content as array of objects for user messages, - // plus extra fields (timestamp, model, tokens) that must be preserved. - raw := []byte(`{"sessionId":"abc","messages":[{"id":"m1","type":"user","timestamp":"2026-01-01T10:00:00Z","content":[{"text":"fix the bug"}]},{"id":"m2","type":"gemini","content":"ok","model":"gemini-3-flash","tokens":{"input":100}}]}`) - normalized, err := NormalizeTranscript(raw) - if err != nil { - t.Fatalf("NormalizeTranscript() error: %v", err) - } - - // After normalization, content should be a plain string - var result struct { - SessionID string `json:"sessionId"` - Messages []struct { - ID string `json:"id"` - Type string `json:"type"` - Content string `json:"content"` - Timestamp string `json:"timestamp"` - Model string `json:"model"` - } `json:"messages"` - } - if err := json.Unmarshal(normalized, &result); err != nil { - t.Fatalf("failed to parse normalized transcript: %v", err) - } - if result.SessionID != "abc" { - t.Errorf("sessionId = %q, want %q", result.SessionID, "abc") - } - if len(result.Messages) != 2 { - t.Fatalf("expected 2 messages, got %d", len(result.Messages)) - } - if result.Messages[0].Content != "fix the bug" { - t.Errorf("user content = %q, want %q", result.Messages[0].Content, "fix the bug") - } - if result.Messages[0].Timestamp != "2026-01-01T10:00:00Z" { - t.Errorf("user timestamp = %q, want preserved", result.Messages[0].Timestamp) - } - if result.Messages[1].Content != "ok" { - t.Errorf("gemini content = %q, want %q", result.Messages[1].Content, "ok") - } - if result.Messages[1].Model != "gemini-3-flash" { - t.Errorf("model = %q, want preserved", result.Messages[1].Model) - } -} - -func TestParseTranscript(t *testing.T) { - t.Parallel() - - // GeminiMessage uses "type" field with values "user" or "gemini" - data := []byte(`{ - "messages": [ - {"type": "user", "content": "hello"}, - {"type": "gemini", "content": "hi there"} - ] -}`) - - transcript, err := ParseTranscript(data) - if err != nil { - t.Fatalf("ParseTranscript() error = %v", err) - } - - if len(transcript.Messages) != 2 { - t.Errorf("ParseTranscript() got %d messages, want 2", len(transcript.Messages)) - } - - if transcript.Messages[0].Type != "user" { - t.Errorf("First message type = %q, want user", transcript.Messages[0].Type) - } - if transcript.Messages[1].Type != "gemini" { - t.Errorf("Second message type = %q, want gemini", transcript.Messages[1].Type) - } -} - -func TestParseTranscript_Empty(t *testing.T) { - t.Parallel() - - data := []byte(`{"messages": []}`) - transcript, err := ParseTranscript(data) - if err != nil { - t.Fatalf("ParseTranscript() error = %v", err) - } - - if len(transcript.Messages) != 0 { - t.Errorf("ParseTranscript() got %d messages, want 0", len(transcript.Messages)) - } -} - -func TestParseTranscript_Invalid(t *testing.T) { - t.Parallel() - - data := []byte(`not valid json`) - _, err := ParseTranscript(data) - if err == nil { - t.Error("ParseTranscript() should error on invalid JSON") - } -} - -func TestExtractModifiedFiles(t *testing.T) { - t.Parallel() - - // Gemini transcript with tool calls in ToolCalls array - data := []byte(`{ - "messages": [ - {"type": "user", "content": "create a file"}, - {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"file_path": "foo.go"}}]}, - {"type": "gemini", "content": "", "toolCalls": [{"name": "edit_file", "args": {"file_path": "bar.go"}}]}, - {"type": "gemini", "content": "", "toolCalls": [{"name": "read_file", "args": {"file_path": "other.go"}}]}, - {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"file_path": "foo.go"}}]} - ] -}`) - - files, err := ExtractModifiedFiles(data) - if err != nil { - t.Fatalf("ExtractModifiedFiles() error = %v", err) - } - - // Should have foo.go and bar.go (deduplicated, read_file not included) - if len(files) != 2 { - t.Errorf("ExtractModifiedFiles() got %d files, want 2", len(files)) - } - - hasFile := func(name string) bool { - for _, f := range files { - if f == name { - return true - } - } - return false - } - - if !hasFile("foo.go") { - t.Error("ExtractModifiedFiles() missing foo.go") - } - if !hasFile("bar.go") { - t.Error("ExtractModifiedFiles() missing bar.go") - } -} - -func TestExtractModifiedFiles_AlternativeFieldNames(t *testing.T) { - t.Parallel() - - // Test different field names for file path (path, filename) - data := []byte(`{ - "messages": [ - {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"path": "via_path.go"}}]}, - {"type": "gemini", "content": "", "toolCalls": [{"name": "save_file", "args": {"filename": "via_filename.go"}}]} - ] -}`) - - files, err := ExtractModifiedFiles(data) - if err != nil { - t.Fatalf("ExtractModifiedFiles() error = %v", err) - } - - if len(files) != 2 { - t.Errorf("ExtractModifiedFiles() got %d files, want 2", len(files)) - } - - hasFile := func(name string) bool { - for _, f := range files { - if f == name { - return true - } - } - return false - } - - if !hasFile("via_path.go") { - t.Error("ExtractModifiedFiles() missing via_path.go") - } - if !hasFile("via_filename.go") { - t.Error("ExtractModifiedFiles() missing via_filename.go") - } -} - -func TestExtractModifiedFiles_NoToolUses(t *testing.T) { - t.Parallel() - - data := []byte(`{ - "messages": [ - {"type": "user", "content": "hello"}, - {"type": "gemini", "content": "just text response"} - ] -}`) - - files, err := ExtractModifiedFiles(data) - if err != nil { - t.Fatalf("ExtractModifiedFiles() error = %v", err) - } - - if len(files) != 0 { - t.Errorf("ExtractModifiedFiles() got %d files, want 0", len(files)) - } -} - -func TestExtractModifiedFiles_ReplaceTool(t *testing.T) { - t.Parallel() - - // Test the "replace" tool which is used by Gemini CLI for file edits - data := []byte(`{ - "messages": [ - {"type": "user", "content": "make the output uppercase"}, - {"type": "gemini", "content": "", "toolCalls": [{"name": "read_file", "args": {"file_path": "random_letter.rb"}}]}, - {"type": "gemini", "content": "", "toolCalls": [{"name": "replace", "args": {"file_path": "/path/to/random_letter.rb", "old_string": "sample", "new_string": "sample.upcase"}}]}, - {"type": "gemini", "content": "Done!"} - ] -}`) - - files, err := ExtractModifiedFiles(data) - if err != nil { - t.Fatalf("ExtractModifiedFiles() error = %v", err) - } - - // Should have random_letter.rb (read_file not included) - if len(files) != 1 { - t.Errorf("ExtractModifiedFiles() got %d files, want 1", len(files)) - } - - if len(files) > 0 && files[0] != "/path/to/random_letter.rb" { - t.Errorf("ExtractModifiedFiles() got file %q, want /path/to/random_letter.rb", files[0]) - } -} - -func TestParseTranscript_ArrayContent(t *testing.T) { - t.Parallel() - - // Real Gemini CLI format: user messages have array content, gemini messages have string content - data := []byte(`{ - "messages": [ - {"type": "user", "content": [{"text": "hello world"}]}, - {"type": "gemini", "content": "hi there"}, - {"type": "user", "content": [{"text": "do something"}]}, - {"type": "gemini", "content": "sure thing"} - ] -}`) - - transcript, err := ParseTranscript(data) - if err != nil { - t.Fatalf("ParseTranscript() error = %v", err) - } - - if len(transcript.Messages) != 4 { - t.Fatalf("ParseTranscript() got %d messages, want 4", len(transcript.Messages)) - } - - // User messages should have extracted text from array - if transcript.Messages[0].Content != "hello world" { - t.Errorf("Message 0 content = %q, want %q", transcript.Messages[0].Content, "hello world") - } - if transcript.Messages[2].Content != "do something" { - t.Errorf("Message 2 content = %q, want %q", transcript.Messages[2].Content, "do something") - } - - // Gemini messages should have string content as-is - if transcript.Messages[1].Content != "hi there" { - t.Errorf("Message 1 content = %q, want %q", transcript.Messages[1].Content, "hi there") - } - if transcript.Messages[3].Content != "sure thing" { - t.Errorf("Message 3 content = %q, want %q", transcript.Messages[3].Content, "sure thing") - } -} - -func TestParseTranscript_ArrayContentMultipleParts(t *testing.T) { - t.Parallel() - - // Array content with multiple text parts should be joined with newlines - data := []byte(`{ - "messages": [ - {"type": "user", "content": [{"text": "part one"}, {"text": "part two"}]} - ] -}`) - - transcript, err := ParseTranscript(data) - if err != nil { - t.Fatalf("ParseTranscript() error = %v", err) - } - - if len(transcript.Messages) != 1 { - t.Fatalf("ParseTranscript() got %d messages, want 1", len(transcript.Messages)) - } - - want := "part one\npart two" - if transcript.Messages[0].Content != want { - t.Errorf("Content = %q, want %q", transcript.Messages[0].Content, want) - } -} - -func TestParseTranscript_NullContent(t *testing.T) { - t.Parallel() - - data := []byte(`{ - "messages": [ - {"type": "user", "content": null}, - {"type": "gemini", "content": "response"} - ] -}`) - - transcript, err := ParseTranscript(data) - if err != nil { - t.Fatalf("ParseTranscript() error = %v", err) - } - - if transcript.Messages[0].Content != "" { - t.Errorf("Content = %q, want empty string", transcript.Messages[0].Content) - } -} - -func TestExtractAllUserPrompts_ArrayContent(t *testing.T) { - t.Parallel() - - // Real Gemini format with array content for user messages - data := []byte(`{ - "messages": [ - {"type": "user", "content": [{"text": "first prompt"}]}, - {"type": "gemini", "content": "response 1"}, - {"type": "user", "content": [{"text": "second prompt"}]}, - {"type": "gemini", "content": "response 2"} - ] -}`) - - prompts, err := ExtractAllUserPrompts(data) - if err != nil { - t.Fatalf("ExtractAllUserPrompts() error = %v", err) - } - - if len(prompts) != 2 { - t.Fatalf("ExtractAllUserPrompts() got %d prompts, want 2", len(prompts)) - } - - if prompts[0] != "first prompt" { - t.Errorf("prompts[0] = %q, want %q", prompts[0], "first prompt") - } - if prompts[1] != "second prompt" { - t.Errorf("prompts[1] = %q, want %q", prompts[1], "second prompt") - } -} - -func TestExtractModifiedFiles_ArrayContent(t *testing.T) { - t.Parallel() - - // Real Gemini transcript format: user messages have array content - data := []byte(`{ - "messages": [ - {"type": "user", "content": [{"text": "create a file"}]}, - {"type": "gemini", "content": "", "toolCalls": [{"name": "write_file", "args": {"file_path": "foo.go"}}]}, - {"type": "user", "content": [{"text": "edit the file"}]}, - {"type": "gemini", "content": "", "toolCalls": [{"name": "edit_file", "args": {"file_path": "bar.go"}}]} - ] -}`) - - files, err := ExtractModifiedFiles(data) - if err != nil { - t.Fatalf("ExtractModifiedFiles() error = %v", err) - } - - if len(files) != 2 { - t.Errorf("ExtractModifiedFiles() got %d files, want 2", len(files)) - } -} - -func TestExtractModifiedFilesFromTranscript(t *testing.T) { - t.Parallel() - - transcript := &GeminiTranscript{ - Messages: []GeminiMessage{ - {Type: "user", Content: "hello"}, - {Type: "gemini", Content: "", ToolCalls: []GeminiToolCall{ - {Name: "write_file", Args: map[string]interface{}{"file_path": "test.go"}}, - }}, - }, - } - - files := ExtractModifiedFilesFromTranscript(transcript) - - if len(files) != 1 { - t.Errorf("got %d files, want 1", len(files)) - } - if len(files) > 0 && files[0] != "test.go" { - t.Errorf("got file %q, want test.go", files[0]) - } -} - -func TestCalculateTokenUsage_BasicMessages(t *testing.T) { - t.Parallel() - - // Gemini transcript with token usage in messages - data := []byte(`{ - "messages": [ - {"id": "1", "type": "user", "content": "hello"}, - {"id": "2", "type": "gemini", "content": "hi there", "tokens": {"input": 10, "output": 20, "cached": 5, "thoughts": 0, "tool": 0, "total": 35}}, - {"id": "3", "type": "user", "content": "how are you?"}, - {"id": "4", "type": "gemini", "content": "I'm doing well", "tokens": {"input": 15, "output": 25, "cached": 3, "thoughts": 0, "tool": 0, "total": 43}} - ] -}`) - - ag := &GeminiCLIAgent{} - usage, err := ag.CalculateTokenUsage(data, 0) - if err != nil { - t.Fatalf("CalculateTokenUsage error: %v", err) - } - - // Should have 2 API calls (2 gemini messages) - if usage.APICallCount != 2 { - t.Errorf("APICallCount = %d, want 2", usage.APICallCount) - } - - // Input tokens: 10 + 15 = 25 - if usage.InputTokens != 25 { - t.Errorf("InputTokens = %d, want 25", usage.InputTokens) - } - - // Output tokens: 20 + 25 = 45 - if usage.OutputTokens != 45 { - t.Errorf("OutputTokens = %d, want 45", usage.OutputTokens) - } - - // Cache read tokens: 5 + 3 = 8 - if usage.CacheReadTokens != 8 { - t.Errorf("CacheReadTokens = %d, want 8", usage.CacheReadTokens) - } -} - -func TestCalculateTokenUsage_StartIndex(t *testing.T) { - t.Parallel() - - // Gemini transcript with 4 messages - test starting from index 2 - data := []byte(`{ - "messages": [ - {"id": "1", "type": "user", "content": "hello"}, - {"id": "2", "type": "gemini", "content": "hi", "tokens": {"input": 10, "output": 20, "cached": 0, "total": 30}}, - {"id": "3", "type": "user", "content": "how are you?"}, - {"id": "4", "type": "gemini", "content": "great", "tokens": {"input": 15, "output": 25, "cached": 5, "total": 45}} - ] -}`) - - // Start from index 2 - should only count the last gemini message - ag := &GeminiCLIAgent{} - usage, err := ag.CalculateTokenUsage(data, 2) - if err != nil { - t.Fatalf("CalculateTokenUsage error: %v", err) - } - - // Should have 1 API call (only the gemini message at index 3) - if usage.APICallCount != 1 { - t.Errorf("APICallCount = %d, want 1", usage.APICallCount) - } - - // Only tokens from message at index 3 - if usage.InputTokens != 15 { - t.Errorf("InputTokens = %d, want 15", usage.InputTokens) - } - - if usage.OutputTokens != 25 { - t.Errorf("OutputTokens = %d, want 25", usage.OutputTokens) - } - - if usage.CacheReadTokens != 5 { - t.Errorf("CacheReadTokens = %d, want 5", usage.CacheReadTokens) - } -} - -func TestCalculateTokenUsage_IgnoresUserMessages(t *testing.T) { - t.Parallel() - - // Even if user messages have tokens (they shouldn't), they should be ignored - data := []byte(`{ - "messages": [ - {"id": "1", "type": "user", "content": "hello", "tokens": {"input": 100, "output": 100, "cached": 100, "total": 300}}, - {"id": "2", "type": "gemini", "content": "hi", "tokens": {"input": 10, "output": 20, "cached": 5, "total": 35}} - ] -}`) - - ag := &GeminiCLIAgent{} - usage, err := ag.CalculateTokenUsage(data, 0) - if err != nil { - t.Fatalf("CalculateTokenUsage error: %v", err) - } - - // Should only count gemini message tokens - if usage.APICallCount != 1 { - t.Errorf("APICallCount = %d, want 1", usage.APICallCount) - } - - if usage.InputTokens != 10 { - t.Errorf("InputTokens = %d, want 10", usage.InputTokens) - } - - if usage.OutputTokens != 20 { - t.Errorf("OutputTokens = %d, want 20", usage.OutputTokens) - } -} - -func TestCalculateTokenUsage_EmptyTranscript(t *testing.T) { - t.Parallel() - - data := []byte(`{"messages": []}`) - ag := &GeminiCLIAgent{} - usage, err := ag.CalculateTokenUsage(data, 0) - if err != nil { - t.Fatalf("CalculateTokenUsage error: %v", err) - } - - if usage.APICallCount != 0 { - t.Errorf("APICallCount = %d, want 0", usage.APICallCount) - } - if usage.InputTokens != 0 { - t.Errorf("InputTokens = %d, want 0", usage.InputTokens) - } - if usage.OutputTokens != 0 { - t.Errorf("OutputTokens = %d, want 0", usage.OutputTokens) - } - if usage.CacheReadTokens != 0 { - t.Errorf("CacheReadTokens = %d, want 0", usage.CacheReadTokens) - } -} - -func TestCalculateTokenUsage_InvalidJSON(t *testing.T) { - t.Parallel() - - data := []byte(`not valid json`) - ag := &GeminiCLIAgent{} - usage, err := ag.CalculateTokenUsage(data, 0) - if err == nil { - t.Fatal("expected error for invalid JSON, got nil") - } - - // Should return empty usage on parse error - if usage.APICallCount != 0 { - t.Errorf("APICallCount = %d, want 0", usage.APICallCount) - } -} - -func TestCalculateTokenUsage_MissingTokensField(t *testing.T) { - t.Parallel() - - // Gemini message without tokens field - data := []byte(`{ - "messages": [ - {"id": "1", "type": "user", "content": "hello"}, - {"id": "2", "type": "gemini", "content": "hi there"} - ] -}`) - - ag := &GeminiCLIAgent{} - usage, err := ag.CalculateTokenUsage(data, 0) - if err != nil { - t.Fatalf("CalculateTokenUsage error: %v", err) - } - - // No tokens to count - if usage.APICallCount != 0 { - t.Errorf("APICallCount = %d, want 0", usage.APICallCount) - } -} - -func TestGeminiCLIAgent_GetTranscriptPosition(t *testing.T) { - t.Parallel() - - // Create a temp file with transcript data - tmpFile := t.TempDir() + "/transcript.json" - - data := []byte(`{ - "messages": [ - {"type": "user", "content": "hello"}, - {"type": "gemini", "content": "hi"}, - {"type": "user", "content": "bye"} - ] -}`) - - if err := writeTestFile(t, tmpFile, data); err != nil { - t.Fatalf("failed to write test file: %v", err) - } - - agent := &GeminiCLIAgent{} - messageCount, err := agent.GetTranscriptPosition(tmpFile) - if err != nil { - t.Fatalf("GetTranscriptPosition() error = %v", err) - } - - if messageCount != 3 { - t.Errorf("GetTranscriptPosition() = %d, want 3", messageCount) - } -} - -func TestGeminiCLIAgent_GetTranscriptPosition_EmptyPath(t *testing.T) { - t.Parallel() - - agent := &GeminiCLIAgent{} - messageCount, err := agent.GetTranscriptPosition("") - if err != nil { - t.Fatalf("GetTranscriptPosition() error = %v", err) - } - - if messageCount != 0 { - t.Errorf("GetTranscriptPosition() = %d, want 0", messageCount) - } -} - -func TestGeminiCLIAgent_GetTranscriptPosition_NonexistentFile(t *testing.T) { - t.Parallel() - - agent := &GeminiCLIAgent{} - messageCount, err := agent.GetTranscriptPosition("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/nonexistent/file.json") - if err != nil { - t.Fatalf("GetTranscriptPosition() error = %v", err) - } - - // Should return 0 for nonexistent file - if messageCount != 0 { - t.Errorf("GetTranscriptPosition() = %d, want 0", messageCount) - } -} - -// writeTestFile is a helper to write test data to a file -func writeTestFile(t *testing.T, path string, data []byte) error { - t.Helper() - return os.WriteFile(path, data, 0o644) -} diff --git a/cmd/entire/cli/agent/geminicli/types.go b/cmd/entire/cli/agent/geminicli/types.go deleted file mode 100644 index c919e21af9..0000000000 --- a/cmd/entire/cli/agent/geminicli/types.go +++ /dev/null @@ -1,112 +0,0 @@ -package geminicli - -// GeminiSettings represents the .gemini/settings.json structure -type GeminiSettings struct { - HooksConfig GeminiHooksConfig `json:"hooksConfig,omitempty"` - Hooks GeminiHooks `json:"hooks,omitempty"` -} - -// GeminiHooksConfig contains tool-related settings -type GeminiHooksConfig struct { - Enabled bool `json:"enabled,omitempty"` -} - -// GeminiHooks contains all hook configurations -type GeminiHooks struct { - // Hooks are only executed when hooksConfig.enabled is true in .gemini/settings.json. - SessionStart []GeminiHookMatcher `json:"SessionStart,omitempty"` - SessionEnd []GeminiHookMatcher `json:"SessionEnd,omitempty"` - BeforeAgent []GeminiHookMatcher `json:"BeforeAgent,omitempty"` - AfterAgent []GeminiHookMatcher `json:"AfterAgent,omitempty"` - BeforeModel []GeminiHookMatcher `json:"BeforeModel,omitempty"` - AfterModel []GeminiHookMatcher `json:"AfterModel,omitempty"` - BeforeToolSelection []GeminiHookMatcher `json:"BeforeToolSelection,omitempty"` - BeforeTool []GeminiHookMatcher `json:"BeforeTool,omitempty"` - AfterTool []GeminiHookMatcher `json:"AfterTool,omitempty"` - PreCompress []GeminiHookMatcher `json:"PreCompress,omitempty"` - Notification []GeminiHookMatcher `json:"Notification,omitempty"` -} - -// GeminiHookMatcher matches hooks to specific patterns -type GeminiHookMatcher struct { - Matcher string `json:"matcher,omitempty"` - Hooks []GeminiHookEntry `json:"hooks"` -} - -// GeminiHookEntry represents a single hook command. -// Unlike Claude Code, Gemini CLI requires a "name" field for each hook entry. -type GeminiHookEntry struct { - Name string `json:"name"` - Type string `json:"type"` - Command string `json:"command"` -} - -// sessionInfoRaw is the JSON structure from SessionStart/SessionEnd hooks -type sessionInfoRaw struct { - SessionID string `json:"session_id"` - TranscriptPath string `json:"transcript_path"` - Cwd string `json:"cwd"` - HookEventName string `json:"hook_event_name"` - Timestamp string `json:"timestamp"` - Source string `json:"source,omitempty"` // For SessionStart: startup, resume, clear - Reason string `json:"reason,omitempty"` // For SessionEnd: exit, logout -} - -// agentHookInputRaw is the JSON structure from BeforeAgent/AfterAgent hooks. -// BeforeAgent includes the user's prompt, similar to Claude's UserPromptSubmit. -type agentHookInputRaw struct { - SessionID string `json:"session_id"` - TranscriptPath string `json:"transcript_path"` - Cwd string `json:"cwd"` - HookEventName string `json:"hook_event_name"` - Timestamp string `json:"timestamp"` - Prompt string `json:"prompt,omitempty"` // User's prompt (BeforeAgent only) -} - -// beforeModelRaw is the JSON structure from BeforeModel hooks. -// Contains the LLM model being used for the current request. -type beforeModelRaw struct { - SessionID string `json:"session_id"` - TranscriptPath string `json:"transcript_path"` - LLMRequest struct { - Model string `json:"model"` - } `json:"llm_request"` -} - -// Tool names used in Gemini CLI that modify files -// Note: Gemini CLI uses different names in different contexts: -// - Internal/transcript names: write_file, replace -// - Display names: WriteFile, Edit -const ( - ToolWriteFile = "write_file" - ToolEditFile = "edit_file" - ToolSaveFile = "save_file" - ToolReplace = "replace" -) - -// FileModificationTools lists tools that create or modify files in Gemini CLI -var FileModificationTools = []string{ - ToolWriteFile, - ToolEditFile, - ToolSaveFile, - ToolReplace, -} - -// geminiMessageTokens represents token usage from a Gemini API response. -// This is specific to Gemini's API format where each message has a tokens object. -type geminiMessageTokens struct { - Input int `json:"input"` - Output int `json:"output"` - Cached int `json:"cached"` - Thoughts int `json:"thoughts"` - Tool int `json:"tool"` - Total int `json:"total"` -} - -// geminiMessageWithTokens represents a Gemini message with token usage data. -// Used for extracting token counts from Gemini transcripts. -type geminiMessageWithTokens struct { - ID string `json:"id"` - Type string `json:"type"` - Tokens *geminiMessageTokens `json:"tokens,omitempty"` -} diff --git a/cmd/entire/cli/agent/generate_external_test.go b/cmd/entire/cli/agent/generate_external_test.go index 7c41701562..d262cffc7d 100644 --- a/cmd/entire/cli/agent/generate_external_test.go +++ b/cmd/entire/cli/agent/generate_external_test.go @@ -11,7 +11,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent/codex" "github.com/entireio/cli/cmd/entire/cli/agent/copilotcli" "github.com/entireio/cli/cmd/entire/cli/agent/cursor" - "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" ) // catRunner returns a TextCommandRunner that invokes `cat`, which echoes @@ -58,18 +57,9 @@ func TestGenerateText_PromptViaStdin(t *testing.T) { agent: &cursor.CursorAgent{}, requiredFlags: []string{"--print", "--force", "--trust", "--workspace"}, }, - { - name: "gemini", - agent: &geminicli.GeminiCLIAgent{}, - requiredFlags: []string{"-p"}, - extraCheck: func(t *testing.T, args []string) { - t.Helper() - pIdx := slices.Index(args, "-p") - if pIdx < 0 || pIdx+1 >= len(args) || args[pIdx+1] != " " { - t.Fatalf("expected -p followed by space placeholder, got %v", args) - } - }, - }, + // antigravity is deliberately absent: agy 1.2.x ignores stdin in print + // mode, so its prompt travels in argv. That contract is pinned in the + // antigravity package (TestGenerateText_PassesPromptInArgv). } for _, tt := range tests { @@ -102,7 +92,7 @@ func TestGenerateText_PromptViaStdin(t *testing.T) { } } -// setRunner injects a test CommandRunner into any of the 4 supported agent +// setRunner injects a test CommandRunner into any of the 3 supported agent // types. This is the external-test equivalent of the package-level var // mutation the old per-package tests used. func setRunner(tg agent.TextGenerator, runner agent.TextCommandRunner) { @@ -113,8 +103,6 @@ func setRunner(tg agent.TextGenerator, runner agent.TextCommandRunner) { a.CommandRunner = runner case *cursor.CursorAgent: a.CommandRunner = runner - case *geminicli.GeminiCLIAgent: - a.CommandRunner = runner } } diff --git a/cmd/entire/cli/agent/hook_command.go b/cmd/entire/cli/agent/hook_command.go index 505715e0bd..f89b4350f0 100644 --- a/cmd/entire/cli/agent/hook_command.go +++ b/cmd/entire/cli/agent/hook_command.go @@ -167,6 +167,24 @@ func WrapWindowsProductionSilentHookCommand(command string) string { ) } +// WrapWindowsProductionSilentHookCommandDirect is the silent wrapper for a host +// that already runs every hook command through cmd.exe /C on Windows (agy does; +// see HookHostIsWindows): the bare `where … & if errorlevel 1 (ver>nul) else +// ()` line, with no cmd.exe prefix and no quoted block. Handing such a +// host WrapWindowsProductionSilentHookCommand's nested form fails outright — +// cmd.exe /C takes the quoted block as one program name ('"where.exe entire +// >nul 2>nul & …"' is not recognized as an internal or external command) — and +// the sh wrapper fails too, because cmd.exe reads its `>/dev/null` as a redirect +// to a nonexistent path. Both were observed on Windows 11 with agy 1.2.7; only +// this shape produced a tracked session. Same distinction +// WrapWindowsProductionJSONWarningHookCommand draws for Codex. +func WrapWindowsProductionSilentHookCommandDirect(command string) string { + return fmt.Sprintf( + `where.exe entire >nul 2>nul & if errorlevel 1 (ver>nul) else (%s)`, + command, + ) +} + // WrapWindowsProductionJSONWarningHookCommand emits a JSON hook response with a // systemMessage field on stdout when the Entire CLI is missing from PATH. It // avoids sh so Codex hooks still work from native Windows shells. Codex already diff --git a/cmd/entire/cli/agent/hook_command_test.go b/cmd/entire/cli/agent/hook_command_test.go index bfc3c7a433..036fc786e0 100644 --- a/cmd/entire/cli/agent/hook_command_test.go +++ b/cmd/entire/cli/agent/hook_command_test.go @@ -369,3 +369,25 @@ func TestWrapProductionPlainTextWarningHookCommandForOS(t *testing.T) { t.Fatalf("windows wrapper not recognised as a managed hook command: %q", windows) } } + +func TestIsManagedHookCommand_RecognisesDirectWindowsSilentWrapper(t *testing.T) { + // No t.Parallel(): sibling tests in this file mutate the package-level OS seam. + cmd := WrapWindowsProductionSilentHookCommandDirect("entire hooks antigravity stop") + if !strings.HasPrefix(cmd, windowsProductionHookWrapperPrefix) { + t.Fatalf("direct wrapper must start with the bare Windows prefix, got %q", cmd) + } + if strings.HasPrefix(cmd, "cmd.exe") { + t.Fatalf("direct wrapper must not nest a cmd.exe invocation, got %q", cmd) + } + if !IsManagedHookCommand(cmd) { + t.Fatalf("IsManagedHookCommand must recognise the direct Windows wrapper: %q", cmd) + } + kept, dropped := DropStaleManagedHooks([]string{cmd}, func(s string) string { return s }, []string{cmd}) + if dropped || len(kept) != 1 { + t.Fatalf("a wanted direct-wrapper command must survive DropStaleManagedHooks, kept=%v dropped=%v", kept, dropped) + } + _, dropped = DropStaleManagedHooks([]string{cmd}, func(s string) string { return s }, nil) + if !dropped { + t.Fatal("an unwanted direct-wrapper command must be dropped as Entire's own") + } +} diff --git a/cmd/entire/cli/agent/hook_config_file.go b/cmd/entire/cli/agent/hook_config_file.go index 6b37c9d764..1ba7c207d8 100644 --- a/cmd/entire/cli/agent/hook_config_file.go +++ b/cmd/entire/cli/agent/hook_config_file.go @@ -12,7 +12,7 @@ import ( ) // HookConfigFile is an agent's hook-configuration file inside the worktree — -// .claude/settings.json, .cursor/hooks.json, .gemini/settings.json, +// .claude/settings.json, .cursor/hooks.json, // .github/hooks/entire.json, .factory/settings.json, .codex/hooks.json, // .opencode/plugins/entire.ts, .pi/extensions/entire/index.ts. // diff --git a/cmd/entire/cli/agent/inject.go b/cmd/entire/cli/agent/inject.go index ed07209cc0..73a8221bd1 100644 --- a/cmd/entire/cli/agent/inject.go +++ b/cmd/entire/cli/agent/inject.go @@ -56,8 +56,8 @@ func AsContextInjector(ag Agent) (ContextInjector, bool) { // // {"hookSpecificOutput":{"hookEventName":,"additionalContext":}} // -// Claude Code, Codex (which hosts Claude-compatible hooks) and Gemini CLI all -// consume this shape on their prompt-submit hook (UserPromptSubmit / BeforeAgent) +// Claude Code and Codex (which hosts Claude-compatible hooks) both consume +// this shape on their prompt-submit hook (UserPromptSubmit) // and merge additionalContext into the model context. Returns (nil, nil) for // empty text so callers can write nothing. func RenderAdditionalContextHookOutput(hookEventName, text string) ([]byte, error) { diff --git a/cmd/entire/cli/agent/opencode/opencode.go b/cmd/entire/cli/agent/opencode/opencode.go index fbc5b191a0..7dbf4c653d 100644 --- a/cmd/entire/cli/agent/opencode/opencode.go +++ b/cmd/entire/cli/agent/opencode/opencode.go @@ -274,7 +274,7 @@ func (a *OpenCodeAgent) FormatResumeCommand(sessionID string) string { var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]`) // SanitizePathForOpenCode converts a path to a safe directory name. -// Replaces any non-alphanumeric character with a dash (same approach as Claude/Gemini). +// Replaces any non-alphanumeric character with a dash (same approach as Claude). func SanitizePathForOpenCode(path string) string { return nonAlphanumericRegex.ReplaceAllString(path, "-") } diff --git a/cmd/entire/cli/agent/opencode/transcript.go b/cmd/entire/cli/agent/opencode/transcript.go index 3136ed11ae..c36ef074c7 100644 --- a/cmd/entire/cli/agent/opencode/transcript.go +++ b/cmd/entire/cli/agent/opencode/transcript.go @@ -1,6 +1,7 @@ package opencode import ( + "context" "encoding/json" "fmt" "os" @@ -91,7 +92,7 @@ func (a *OpenCodeAgent) GetTranscriptPosition(path string) (int, error) { } // ExtractModifiedFilesFromOffset extracts files modified by tool calls from the given message offset. -func (a *OpenCodeAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { +func (a *OpenCodeAgent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) ([]string, int, error) { session, err := parseExportSessionFromFile(path) if err != nil { if os.IsNotExist(err) { diff --git a/cmd/entire/cli/agent/opencode/transcript_test.go b/cmd/entire/cli/agent/opencode/transcript_test.go index ab009b0882..43fcd43c02 100644 --- a/cmd/entire/cli/agent/opencode/transcript_test.go +++ b/cmd/entire/cli/agent/opencode/transcript_test.go @@ -152,7 +152,7 @@ func TestExtractModifiedFilesFromOffset(t *testing.T) { path := writeTestTranscript(t, testExportJSON) // From offset 0 — should get both main.go and util.go - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 0) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -164,7 +164,7 @@ func TestExtractModifiedFilesFromOffset(t *testing.T) { } // From offset 2 — should only get util.go (messages 3 and 4) - files, pos, err = ag.ExtractModifiedFilesFromOffset(path, 2) + files, pos, err = ag.ExtractModifiedFilesFromOffset(context.Background(), path, 2) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -375,7 +375,7 @@ func TestExtractModifiedFilesFromOffset_ApplyPatch(t *testing.T) { path := writeTestTranscript(t, testApplyPatchExportJSON) // From offset 0 — should find layout.py and resize.py (deduplicated) - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 0) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -387,7 +387,7 @@ func TestExtractModifiedFilesFromOffset_ApplyPatch(t *testing.T) { } // From offset 2 — should find layout.py and resize.py from msg-4 - files, _, err = ag.ExtractModifiedFilesFromOffset(path, 2) + files, _, err = ag.ExtractModifiedFilesFromOffset(context.Background(), path, 2) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -457,7 +457,7 @@ func TestExtractModifiedFilesFromOffset_CamelCaseFilePath(t *testing.T) { ag := &OpenCodeAgent{} path := writeTestTranscript(t, testCamelCaseExportJSON) - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 0) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -473,7 +473,7 @@ func TestExtractModifiedFilesFromOffset_CamelCaseFilePath(t *testing.T) { } // From offset 2 — should still find the edit in msg-4 - files, _, err = ag.ExtractModifiedFilesFromOffset(path, 2) + files, _, err = ag.ExtractModifiedFilesFromOffset(context.Background(), path, 2) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/cmd/entire/cli/agent/pi/lifecycle.go b/cmd/entire/cli/agent/pi/lifecycle.go index 8c4493cf2f..166e901101 100644 --- a/cmd/entire/cli/agent/pi/lifecycle.go +++ b/cmd/entire/cli/agent/pi/lifecycle.go @@ -45,7 +45,7 @@ func (a *PiAgent) HookNames() []string { // - session_start → SessionStart // - before_agent_start → TurnStart // - agent_end → TurnEnd -// - session_shutdown → (cleanup-only, no lifecycle event — see ParseHookEvent) +// - session_shutdown → (no lifecycle event — see ParseHookEvent) func (a *PiAgent) GetSupportedHooks() []agent.HookType { return []agent.HookType{ agent.HookSessionStart, @@ -162,14 +162,14 @@ func (a *PiAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io. now := time.Now() - // A sessionless payload names no session we can track, and must not be resolved - // against the per-repo session-ID cache: doing so handed the caller whichever - // session happened to be cached — see docs/architecture/agent-guide.md for how a - // nested `pi --no-session` subagent thereby claimed its parent's session. + // A sessionless payload names no session we can track. It must not borrow a + // repo-global session identity — see docs/architecture/agent-guide.md for how + // a nested `pi --no-session` subagent thereby claimed its parent's session. // // session_shutdown is exempt: the extension sends it with no session identity at - // all, and it must still clear the cache. Same shape as Copilot CLI's - // subordinate-session guard (copilotcli/lifecycle.go). + // all, and the no-op handler below preserves the contract that it is not a + // SessionEnd event. Same shape as Copilot CLI's subordinate-session guard + // (copilotcli/lifecycle.go). if hookName != HookNameSessionShutdown && sessionID == "" { logging.Debug(ctx, "pi: skipping lifecycle event for sessionless (nested) Pi invocation", slog.String("hook", hookName)) @@ -178,7 +178,6 @@ func (a *PiAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io. switch hookName { case HookNameSessionStart: - cacheSessionID(ctx, sessionID) return &agent.Event{ Type: agent.SessionStart, SessionID: sessionID, @@ -186,7 +185,6 @@ func (a *PiAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io. }, nil case HookNameBeforeAgentStart: - cacheSessionID(ctx, sessionID) // Provide the live Pi session file as SessionRef so state.TranscriptPath // is populated before any mid-turn commits. Without this, the // post-commit hook cannot condense when no shadow branch exists yet. @@ -214,9 +212,6 @@ func (a *PiAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io. }, nil case HookNameSessionShutdown: - // Cleanup-only: clear the cached session ID. We intentionally do NOT - // emit SessionEnd here. - // // Pi fires session_shutdown and agent_end on session teardown, and the // TypeScript extension dispatches both via separate `entire hooks pi …` // child processes (execFile is non-blocking). Child-process startup @@ -230,8 +225,7 @@ func (a *PiAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io. // effectively "session over" for any single-turn `pi -p` invocation). // SessionEnd is left for the framework to derive from idle timeout or // the next SessionStart's stale-state cleanup. - clearCachedSessionID(ctx) - return nil, nil //nolint:nilnil // intentional: cleanup-only, no lifecycle event + return nil, nil //nolint:nilnil // intentional: session_shutdown is not a lifecycle event default: // Unknown / future hooks have no lifecycle significance. @@ -239,36 +233,14 @@ func (a *PiAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io. } } -// --- session ID cache --- -// -// This cached the active session ID so a later hook arriving without one could -// recover it. The sessionless guard in ParseHookEvent removed the only two reads: -// a payload with no resolvable session ID is now skipped rather than resolved -// against this file, because the file is a single per-repo slot and handing its -// contents to an unrelated caller is what let a nested subagent claim its parent's -// session. -// -// The recovery it promised was never reachable anyway — a payload with no session -// file also has no transcript, so captureTranscript returns "" and the event dies -// downstream regardless of the ID. -// -// What remains is write-only: session_start/before_agent_start write it, -// session_shutdown clears it, and only tests read it. Removing it outright is -// deliberately left as a follow-up: the write/clear pair and its test predate this -// change, and a single-slot per-repo identity store is separately unsound for two -// concurrent Pi sessions in one worktree, which deserves its own change. - -const activeSessionFile = "pi-active-session" - // piHookCacheSubdir is the subdirectory under .entire/tmp/ where hook -// flow caches the active-session ID file and the agent_end transcript -// snapshot. Agent-specific (not just .entire/tmp/) so other agents' +// flow caches the agent_end transcript snapshot. Agent-specific (not just +// .entire/tmp/) so other agents' // integration tests and tooling don't shadow each other under the cache // root. const piHookCacheSubdir = "pi" // resolveSessionDir returns the per-repo hook cache directory used by -// cacheSessionID / readCachedSessionID / clearCachedSessionID and // captureTranscript. // // This is intentionally distinct from PiAgent.GetSessionDir, which @@ -294,46 +266,6 @@ func resolveSessionDir(ctx context.Context) (worktreeRoot string, ok bool) { // sessionCacheDir is .entire/tmp/pi relative to the .entire root. var sessionCacheDir = entiredir.MustName(paths.EntireTmpDir) + "/" + piHookCacheSubdir -// openSessionCache returns the shared .entire root for the repo this hook is -// running in, with the pi/ cache directory created under it when create is set. -// A repo that cannot be resolved yields ok=false and every caller degrades: the -// cache is an optimization, never the only copy of anything. -func openSessionCache(ctx context.Context, create bool) (root *os.Root, ok bool) { - worktreeRoot, ok := resolveSessionDir(ctx) - if !ok { - return nil, false - } - open := entiredir.OpenAtForRead - if create { - open = entiredir.OpenAt - } - root, err := open(worktreeRoot) - if err != nil { - return nil, false - } - if create { - if err := osroot.MkdirAllNoSymlink(root, sessionCacheDir, 0o750); err != nil { - logging.Debug(ctx, "pi: session cache mkdir", slog.String("err", err.Error())) - return nil, false - } - } - return root, true -} - -func cacheSessionID(ctx context.Context, id string) { - if id == "" { - return - } - root, ok := openSessionCache(ctx, true) - if !ok { - return - } - - if err := entiredir.WriteFile(root, sessionCacheDir+"/"+activeSessionFile, []byte(id), 0o600); err != nil { - logging.Debug(ctx, "pi: cache session id write", slog.String("err", err.Error())) - } -} - func extractModelFromPiSessionFile(path string) string { if path == "" { return "" @@ -349,26 +281,6 @@ func extractModelFromPiSessionFile(path string) string { return model } -func readCachedSessionID(ctx context.Context) string { - root, ok := openSessionCache(ctx, false) - if !ok { - return "" - } - data, err := entiredir.ReadFile(root, sessionCacheDir+"/"+activeSessionFile) - if err != nil { - return "" - } - return strings.TrimSpace(string(data)) -} - -func clearCachedSessionID(ctx context.Context) { - root, ok := openSessionCache(ctx, false) - if !ok { - return - } - _ = osroot.RemoveNoSymlinks(root, sessionCacheDir+"/"+activeSessionFile) //nolint:errcheck // best-effort cache clear; a stale id is re-resolved next hook -} - // captureTranscript copies the Pi JSONL session file to // /.entire/tmp/pi/.json so Entire has a stable transcript // reference. Returns the path to the cached file, or "" if either input is @@ -378,10 +290,10 @@ func captureTranscript(ctx context.Context, sessionID, piSessionFile string) str if sessionID == "" || piSessionFile == "" { return "" } - // sessionID comes from the hook payload (or the locally cached active - // session) and is used to build dst below, before the lifecycle dispatcher - // validates it. Validate here at the choke point so an unsafe ID cannot - // write the transcript outside the cache directory; "" signals no capture. + // sessionID comes from the hook payload and is used to build dst below, + // before the lifecycle dispatcher validates it. Validate here at the choke + // point so an unsafe ID cannot write the transcript outside the cache + // directory; "" signals no capture. if err := validation.ValidateSessionID(sessionID); err != nil { logging.Warn(ctx, "pi: refusing to capture transcript for unsafe session ID", slog.String("session_id", sessionID), slog.String("err", err.Error())) @@ -391,8 +303,12 @@ func captureTranscript(ctx context.Context, sessionID, piSessionFile string) str if !ok { return "" } - root, ok := openSessionCache(ctx, true) - if !ok { + root, err := entiredir.OpenAt(worktreeRoot) + if err != nil { + return "" + } + if err := osroot.MkdirAllNoSymlink(root, sessionCacheDir, 0o750); err != nil { + logging.Debug(ctx, "pi: session cache mkdir", slog.String("err", err.Error())) return "" } name := sessionCacheDir + "/" + sessionID + ".json" diff --git a/cmd/entire/cli/agent/pi/lifecycle_test.go b/cmd/entire/cli/agent/pi/lifecycle_test.go index 194ca135ea..ece20c976d 100644 --- a/cmd/entire/cli/agent/pi/lifecycle_test.go +++ b/cmd/entire/cli/agent/pi/lifecycle_test.go @@ -45,6 +45,43 @@ func TestParseHookEvent_BeforeAgentStart(t *testing.T) { } } +func TestParseHookEvent_DoesNotWriteActiveSessionCache(t *testing.T) { + tests := []struct { + name string + hookName string + payload string + }{ + { + name: "session start", + hookName: HookNameSessionStart, + payload: `{"type":"session_start","session_id":"abc-123"}`, + }, + { + name: "before agent start", + hookName: HookNameBeforeAgentStart, + payload: `{"type":"before_agent_start","session_id":"abc-123","prompt":"do thing"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + if _, err := (&PiAgent{}).ParseHookEvent( + context.Background(), tt.hookName, strings.NewReader(tt.payload), + ); err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + + cachePath := filepath.Join(dir, ".entire", "tmp", "pi", "pi-active-session") + if _, err := os.Stat(cachePath); !os.IsNotExist(err) { + t.Fatalf("active-session cache exists after %s; stat error = %v", tt.hookName, err) + } + }) + } +} + func TestParseHookEvent_BeforeAgentStart_WithSkillEvent(t *testing.T) { t.Parallel() a := &PiAgent{} @@ -127,43 +164,12 @@ func TestParseHookEvent_SessionShutdown_NoLifecycleEvent(t *testing.T) { if err != nil { t.Fatalf("ParseHookEvent: %v", err) } - // session_shutdown is cleanup-only — see ParseHookEvent for the rationale. + // session_shutdown is not a lifecycle event — see ParseHookEvent for the rationale. if ev != nil { t.Fatalf("expected nil event from session_shutdown, got %+v", ev) } } -func TestParseHookEvent_SessionShutdown_ClearsCache(t *testing.T) { - // session_shutdown's only side effect is clearing the cached session ID. - // Cannot use t.Parallel — t.Chdir. - dir := t.TempDir() - t.Chdir(dir) - - ctx := context.Background() - a := &PiAgent{} - - // Populate the cache via session_start. - if _, err := a.ParseHookEvent(ctx, HookNameSessionStart, strings.NewReader( - `{"type":"session_start","session_file":"/tmp/2026-05-09T12-00-00-000Z_cached-id.jsonl"}`)); err != nil { - t.Fatalf("session_start setup: %v", err) - } - if got := readCachedSessionID(ctx); got != "cached-id" { - t.Fatalf("cache pre-shutdown = %q, want cached-id", got) - } - - // session_shutdown clears the cache and emits no event. - ev, err := a.ParseHookEvent(ctx, HookNameSessionShutdown, strings.NewReader(`{"type":"session_shutdown"}`)) - if err != nil { - t.Fatalf("session_shutdown: %v", err) - } - if ev != nil { - t.Errorf("expected nil event, got %+v", ev) - } - if got := readCachedSessionID(ctx); got != "" { - t.Errorf("cache should be cleared after session_shutdown, got %q", got) - } -} - func TestParseHookEvent_EmptyStdin(t *testing.T) { t.Parallel() a := &PiAgent{} @@ -202,25 +208,6 @@ func TestExtractSessionIDFromPath(t *testing.T) { } } -func TestSessionIDCacheRoundtrip(t *testing.T) { - // Cannot use t.Parallel — t.Chdir mutates process state. - dir := t.TempDir() - t.Chdir(dir) - - ctx := context.Background() - if got := readCachedSessionID(ctx); got != "" { - t.Errorf("expected empty cache initially, got %q", got) - } - cacheSessionID(ctx, "abc-123") - if got := readCachedSessionID(ctx); got != "abc-123" { - t.Errorf("readCachedSessionID = %q, want abc-123", got) - } - clearCachedSessionID(ctx) - if got := readCachedSessionID(ctx); got != "" { - t.Errorf("after clear, got %q", got) - } -} - func TestCaptureTranscript(t *testing.T) { // Cannot use t.Parallel — t.Chdir. dir := t.TempDir() @@ -297,7 +284,7 @@ func TestCaptureTranscript_RejectsTraversalSessionID(t *testing.T) { func TestGetSupportedHooks(t *testing.T) { t.Parallel() got := (&PiAgent{}).GetSupportedHooks() - // Note: session_shutdown is cleanup-only, not a HookSessionEnd source — + // Note: session_shutdown is not a HookSessionEnd source — // see ParseHookEvent's session_shutdown case for why. want := []agent.HookType{ agent.HookSessionStart, @@ -359,28 +346,18 @@ func TestPiAgent_ContextInjector(t *testing.T) { } // TestParseHookEvent_SessionlessPayloadIsSkipped pins the guard that keeps a -// sessionless payload from being resolved against the per-repo session-ID cache. +// sessionless payload from borrowing another Pi process's identity. // // A Pi subagent is a nested `pi --no-session` process that auto-discovers the same // project-local extension, so it fires these hooks carrying no session identity. -// Resolving that against the single-slot cache handed the child its parent's ID, -// and the nested turn then overwrote the parent's prompt and turn window. +// The former single-slot fallback handed the child its parent's ID, and the nested +// turn then overwrote the parent's prompt and turn window. func TestParseHookEvent_SessionlessPayloadIsSkipped(t *testing.T) { - // Cannot use t.Parallel — t.Chdir. - t.Chdir(t.TempDir()) + t.Parallel() ctx := context.Background() a := &PiAgent{} - // A parent session populates the cache, so a leaked ID would be observable. - if _, err := a.ParseHookEvent(ctx, HookNameSessionStart, strings.NewReader( - `{"type":"session_start","session_file":"/tmp/2026-05-09T12-00-00-000Z_parent-id.jsonl"}`)); err != nil { - t.Fatalf("parent session_start: %v", err) - } - if got := readCachedSessionID(ctx); got != "parent-id" { - t.Fatalf("cache = %q, want parent-id", got) - } - tests := []struct { name string hook string @@ -404,9 +381,8 @@ func TestParseHookEvent_SessionlessPayloadIsSkipped(t *testing.T) { }, { // A session file whose basename carries no ID (trailing separator) - // also yields no session ID. Skipping is the safe reading: the - // alternative — requiring an empty session_file too — would let this - // payload fall through to the cache, which is the leak itself. + // also yields no session ID. Skipping is the safe reading because this + // payload cannot identify a session without a repo-global fallback. name: "session file with unparseable name", hook: HookNameBeforeAgentStart, stdin: `{"type":"before_agent_start","cwd":"/repo","session_file":"/tmp/x_.jsonl","prompt":"hi"}`, @@ -423,7 +399,7 @@ func TestParseHookEvent_SessionlessPayloadIsSkipped(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cacheBefore := readCachedSessionID(ctx) + t.Parallel() ev, err := a.ParseHookEvent(ctx, tt.hook, strings.NewReader(tt.stdin)) if err != nil { @@ -432,13 +408,9 @@ func TestParseHookEvent_SessionlessPayloadIsSkipped(t *testing.T) { if tt.wantSessionID == "" { if ev != nil { - t.Fatalf("emitted %s for session %q; want no event so the cached session is left alone", + t.Fatalf("emitted %s for session %q; want no event", ev.Type, ev.SessionID) } - // A skipped invocation must not disturb the cached session either. - if got := readCachedSessionID(ctx); got != cacheBefore { - t.Errorf("cache = %q after a skipped invocation, want %q", got, cacheBefore) - } return } diff --git a/cmd/entire/cli/agent/pi/models.go b/cmd/entire/cli/agent/pi/models.go index c2ffb20719..930382c7f4 100644 --- a/cmd/entire/cli/agent/pi/models.go +++ b/cmd/entire/cli/agent/pi/models.go @@ -12,7 +12,7 @@ import ( var _ agent.ModelLister = (*PiAgent)(nil) // ListModels returns Pi's live model catalog by shelling out to -// `pi --list-models`. Unlike the curated lists for claude-code/codex/gemini, +// `pi --list-models`. Unlike the curated lists for claude-code/codex, // Pi has a real enumeration command spanning every configured provider, so the // result reflects what this machine/account can actually use. func (a *PiAgent) ListModels(ctx context.Context) ([]agent.ModelInfo, error) { diff --git a/cmd/entire/cli/agent/pi/pi.go b/cmd/entire/cli/agent/pi/pi.go index bcb2ad807f..de30433b54 100644 --- a/cmd/entire/cli/agent/pi/pi.go +++ b/cmd/entire/cli/agent/pi/pi.go @@ -60,7 +60,7 @@ func (a *PiAgent) ProtectedFiles() []string { return nil } // DetectPresence reports whether pi is configured for *this repo*. We only // check repo-local config (.pi/) and intentionally ignore $PATH — in-tree -// agents follow the convention used by Claude/Gemini/OpenCode where +// agents follow the convention used by Claude/OpenCode where // detection means "this repo is set up for this agent", not "this agent is // installed somewhere on this machine". The external plugin uses the broader // $PATH check because it can't see repo state; we don't have that limitation. diff --git a/cmd/entire/cli/agent/pi/transcript.go b/cmd/entire/cli/agent/pi/transcript.go index f1c415e7f5..b02bc2db73 100644 --- a/cmd/entire/cli/agent/pi/transcript.go +++ b/cmd/entire/cli/agent/pi/transcript.go @@ -1,6 +1,7 @@ package pi import ( + "context" "encoding/json" "fmt" "os" @@ -79,7 +80,7 @@ func (a *PiAgent) GetTranscriptPosition(path string) (int, error) { // onward and returns file paths touched by file-modifying tools (`write`, // `edit`). Branch-aware: only counts entries on the active conversation // branch. -func (a *PiAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { +func (a *PiAgent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) ([]string, int, error) { if path == "" { return nil, 0, nil } diff --git a/cmd/entire/cli/agent/pi/transcript_test.go b/cmd/entire/cli/agent/pi/transcript_test.go index 6d8846ed78..897c25c001 100644 --- a/cmd/entire/cli/agent/pi/transcript_test.go +++ b/cmd/entire/cli/agent/pi/transcript_test.go @@ -96,7 +96,7 @@ func writeBranchingSession(t *testing.T) string { func TestExtractModifiedFiles(t *testing.T) { t.Parallel() path := writeTestSession(t) - files, pos, err := (&PiAgent{}).ExtractModifiedFilesFromOffset(path, 0) + files, pos, err := (&PiAgent{}).ExtractModifiedFilesFromOffset(context.Background(), path, 0) if err != nil { t.Fatal(err) } @@ -111,7 +111,7 @@ func TestExtractModifiedFiles(t *testing.T) { func TestExtractModifiedFiles_OffsetPastEnd(t *testing.T) { t.Parallel() path := writeTestSession(t) - files, _, err := (&PiAgent{}).ExtractModifiedFilesFromOffset(path, 100) + files, _, err := (&PiAgent{}).ExtractModifiedFilesFromOffset(context.Background(), path, 100) if err != nil { t.Fatal(err) } @@ -123,7 +123,7 @@ func TestExtractModifiedFiles_OffsetPastEnd(t *testing.T) { func TestExtractModifiedFiles_Branching(t *testing.T) { t.Parallel() path := writeBranchingSession(t) - files, _, err := (&PiAgent{}).ExtractModifiedFilesFromOffset(path, 0) + files, _, err := (&PiAgent{}).ExtractModifiedFilesFromOffset(context.Background(), path, 0) if err != nil { t.Fatal(err) } diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index c8e084e5f6..836b9812db 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -154,27 +154,32 @@ func pathHasDirPrefix(path, dir string) bool { // Agent name constants (registry keys) const ( + AgentNameAntigravity types.AgentName = "antigravity" AgentNameClaudeCode types.AgentName = "claude-code" AgentNameCodex types.AgentName = "codex" AgentNameCopilotCLI types.AgentName = "copilot-cli" AgentNameCursor types.AgentName = "cursor" AgentNameFactoryAIDroid types.AgentName = "factoryai-droid" - AgentNameGemini types.AgentName = "gemini" AgentNameOpenCode types.AgentName = "opencode" AgentNamePi types.AgentName = "pi" ) // Agent type constants (type identifiers stored in metadata/trailers) const ( + AgentTypeAntigravity types.AgentType = "Antigravity" AgentTypeClaudeCode types.AgentType = "Claude Code" AgentTypeCodex types.AgentType = "Codex" AgentTypeCopilotCLI types.AgentType = "Copilot CLI" AgentTypeCursor types.AgentType = "Cursor" AgentTypeFactoryAIDroid types.AgentType = "Factory AI Droid" - AgentTypeGemini types.AgentType = "Gemini CLI" AgentTypeOpenCode types.AgentType = "OpenCode" AgentTypePi types.AgentType = "Pi" AgentTypeUnknown types.AgentType = "Unknown" + + // AgentTypeGemini tags checkpoints recorded before Gemini CLI support was + // removed. No agent registers it; it survives so those checkpoints still + // read correctly (see transcript/geminilegacy). + AgentTypeGemini types.AgentType = "Gemini CLI" ) // DefaultAgentName is the registry key for the default agent. diff --git a/cmd/entire/cli/agent/registry_test.go b/cmd/entire/cli/agent/registry_test.go index 0edfb0e572..c4e9b59ff7 100644 --- a/cmd/entire/cli/agent/registry_test.go +++ b/cmd/entire/cli/agent/registry_test.go @@ -203,8 +203,8 @@ func TestAgentNameConstants(t *testing.T) { if AgentNameClaudeCode != "claude-code" { t.Errorf("expected AgentNameClaudeCode %q, got %q", "claude-code", AgentNameClaudeCode) } - if AgentNameGemini != "gemini" { - t.Errorf("expected AgentNameGemini %q, got %q", "gemini", AgentNameGemini) + if AgentTypeGemini != "Gemini CLI" { + t.Errorf("expected AgentTypeGemini %q, got %q; stored checkpoints carry this value", "Gemini CLI", AgentTypeGemini) } } diff --git a/cmd/entire/cli/agent/resolve_session_file_guard_test.go b/cmd/entire/cli/agent/resolve_session_file_guard_test.go new file mode 100644 index 0000000000..8198302753 --- /dev/null +++ b/cmd/entire/cli/agent/resolve_session_file_guard_test.go @@ -0,0 +1,106 @@ +package agent_test + +import ( + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +// resolveSessionFilePattern matches a call to an agent's own +// ResolveSessionFile or ResolveRestoredSessionFile. The leading `\.` is what +// keeps a method DEFINITION (`) ResolveSessionFile(`) out: definitions have no +// dot before the name, so only call sites match. +// +// Both resolvers, because both turn an agent-supplied ID into a path from a +// directory the caller passes in, and the restored one additionally derives its +// answer from checkpoint transcript bytes. +const resolveSessionFilePattern = `\.Resolve[A-Za-z]*SessionFile\(` + +// resolveSessionFileCallers is every file allowed to call an agent's +// ResolveSessionFile directly, with the reason it may. +// +// The method takes an agentSessionID that reached us from a hook payload or +// from checkpoint metadata on the shared entire/checkpoints/v1 branch, and +// several agents use it as a DIRECTORY component (Copilot: +// //events.jsonl) or return it verbatim when absolute (Codex, Pi). Its +// doc comment therefore says not to call it with unvalidated input — an +// invariant the compiler cannot check, and one that two agents violated for as +// long as it existed, because a new integration copies the nearest existing one +// rather than re-deriving whether the ID was checked. +// +// The fix for a new violation is not an entry here: it is +// SessionStore.SessionFile, which validates the ID and confirms the resolved +// path is inside the store, and which is what both former violators now use. +var resolveSessionFileCallers = map[string]string{ + "cmd/entire/cli/agent/session_store.go": "SessionFile itself — the chokepoint that validates the ID before resolving it, and converts the result back into a name inside the store", + + // Calls ResolveRestoredSessionFile with an ID SessionFile has already + // validated, and passes the result back through SessionStore.Name before + // using it. + "cmd/entire/cli/strategy/manual_commit_pending.go": "restore path: resolves an already-validated ID and re-checks containment through the store", + + // Pure delegation across the external-plugin boundary: the wrapper forwards + // to the plugin's implementation and resolves nothing of its own, so it is + // the method rather than a caller of it. + "cmd/entire/cli/agent/external/capabilities.go": "wrappedAgent forwards the call to the external agent; it is an implementation, not a caller", + + // A test helper, in a package with no _test.go suffix for the exclusion to + // catch. It resolves an ID the harness itself wrote into a temp repo, so + // there is no untrusted input; listed rather than excluded by path so that a + // production caller added under e2e/ cannot hide behind the exclusion. + "e2e/testutil/session_paths.go": "e2e harness resolving an ID it generated itself, against a temp repo", +} + +// TestResolveSessionFileCallersAreSanctioned fails the build when a new caller +// of ResolveSessionFile appears, and when a sanctioned one stops calling it. +// +// Both directions, for the same reason as the transcript-read ratchet: growth +// is the regression, and a stale entry makes the list stop meaning anything. +func TestResolveSessionFileCallersAreSanctioned(t *testing.T) { + t.Parallel() + + repoRoot, ok := testutil.GitGrepGuardRepoRoot(t) + if !ok { + return + } + + // testutil.GitGrepGuard owns --untracked, --no-color and the repo-selector + // scrubbing, and explains why each is load-bearing for a guard like this. + // The pathspec is restricted to *.go so an unparseable path can be fatal + // below rather than skipped, and spans EVERY Go package rather than cmd/: + // scoped to cmd/**/*.go it could not see e2e/testutil, which had been + // calling the resolver the whole time. A guard that cannot see a caller is + // not a guard. + out := testutil.GitGrepGuard(t, repoRoot, "-l", "-E", "--", resolveSessionFilePattern, + "--", ":(glob)**/*.go", ":(exclude,glob)**/*_test.go") + + found := map[string]bool{} + for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") { + if line == "" { + continue + } + if !strings.HasSuffix(line, ".go") { + t.Fatalf("cannot parse git grep output; expected a path, got:\n %s\n"+ + "The filename field is unusable, so this test can prove nothing. "+ + "Check whether git is colorizing into a pipe (color.ui or color.grep set to `always`).", line) + } + found[line] = true + } + if len(found) == 0 { + t.Fatal("guard matched no ResolveSessionFile callers at all; the detection pattern has gone stale and must be re-pointed") + } + + for file := range found { + if _, sanctioned := resolveSessionFileCallers[file]; !sanctioned { + t.Errorf("%s calls ResolveSessionFile directly with an ID this guard cannot prove was validated.\n"+ + "Resolve through agent.OpenSessionStore(...).SessionFile(id) instead: it validates the ID "+ + "and rejects one that resolved outside the store. See the contract on agent.Agent.ResolveSessionFile.", file) + } + } + for file, why := range resolveSessionFileCallers { + if !found[file] { + t.Errorf("%s is sanctioned to call ResolveSessionFile (%s) but no longer does; remove the entry.", file, why) + } + } +} diff --git a/cmd/entire/cli/agent/resume_command.go b/cmd/entire/cli/agent/resume_command.go index a22081596c..f2c232731f 100644 --- a/cmd/entire/cli/agent/resume_command.go +++ b/cmd/entire/cli/agent/resume_command.go @@ -43,11 +43,6 @@ func ResumeCommandSpecFor(name types.AgentName, sessionID string) (ForegroundCom return ForegroundCommandSpec{}, false } return ForegroundCommandSpec{Binary: "droid", Args: []string{"--session-id", sessionID}}, true - case AgentNameGemini: - if !isLaunchableResumeSessionID(sessionID) { - return ForegroundCommandSpec{}, false - } - return ForegroundCommandSpec{Binary: "gemini", Args: []string{"--resume", sessionID}}, true case AgentNameOpenCode: if sessionID == "" { return ForegroundCommandSpec{Binary: openCodeBinary}, true diff --git a/cmd/entire/cli/agent/resume_command_registry_test.go b/cmd/entire/cli/agent/resume_command_registry_test.go index 114ca1ed1a..1b76120931 100644 --- a/cmd/entire/cli/agent/resume_command_registry_test.go +++ b/cmd/entire/cli/agent/resume_command_registry_test.go @@ -9,7 +9,6 @@ import ( _ "github.com/entireio/cli/cmd/entire/cli/agent/codex" _ "github.com/entireio/cli/cmd/entire/cli/agent/copilotcli" _ "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" - _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" _ "github.com/entireio/cli/cmd/entire/cli/agent/opencode" _ "github.com/entireio/cli/cmd/entire/cli/agent/pi" "github.com/entireio/cli/cmd/entire/cli/agent/types" @@ -24,7 +23,6 @@ func TestResumeCommandSpecMatchesFormattedResumeCommand(t *testing.T) { agent.AgentNameCodex, agent.AgentNameCopilotCLI, agent.AgentNameFactoryAIDroid, - agent.AgentNameGemini, agent.AgentNameOpenCode, agent.AgentNamePi, } { diff --git a/cmd/entire/cli/agent/resume_command_test.go b/cmd/entire/cli/agent/resume_command_test.go index de6b35c4f1..5b26cf43c2 100644 --- a/cmd/entire/cli/agent/resume_command_test.go +++ b/cmd/entire/cli/agent/resume_command_test.go @@ -45,13 +45,6 @@ func TestResumeCommandSpecFor(t *testing.T) { want: ForegroundCommandSpec{Binary: "droid", Args: []string{"--session-id", "session-123"}}, wantOK: true, }, - { - name: "gemini", - agentName: AgentNameGemini, - sessionID: "session-123", - want: ForegroundCommandSpec{Binary: "gemini", Args: []string{"--resume", "session-123"}}, - wantOK: true, - }, { name: "opencode", agentName: AgentNameOpenCode, diff --git a/cmd/entire/cli/agent/session_store.go b/cmd/entire/cli/agent/session_store.go index 545f995302..4a37da0e1b 100644 --- a/cmd/entire/cli/agent/session_store.go +++ b/cmd/entire/cli/agent/session_store.go @@ -5,11 +5,13 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/jsonutil" "github.com/entireio/cli/cmd/entire/cli/osroot" "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/validation" ) // SessionStore is one agent's own session directory, held as an *os.Root. @@ -44,6 +46,17 @@ type SessionLocator interface { // agent's session directory. Callers match it with errors.Is. var ErrOutsideSessionStore = errors.New("path is outside the agent's session directory") +// ErrUnsafeSessionName reports a name that is malformed as a filesystem +// component — a traversal segment, a control character, a trailing space or +// period, a Windows device name or volume separator. +// +// Separate from ErrOutsideSessionStore on purpose: that one says a path +// resolved OUT of the store, which is a containment failure, and reporting it +// for a merely malformed ID produced "path is outside the agent's session +// directory: invalid session ID \"session.\": ends with period", which names +// the wrong problem. Callers match either with errors.Is. +var ErrUnsafeSessionName = errors.New("unsafe session file name") + // OpenSessionStore returns ag's session store for repoPath. // // The directory need NOT exist yet: resolving a session file is path arithmetic @@ -107,8 +120,17 @@ func (s *SessionStore) openRoot() (*os.Root, error) { // openRootForWrite is openRoot with the store directory created first. The // directory is the root itself, so it cannot be created through it — this is the // one place that reaches it from the outside. The caller closes the result. +// +// The store's own location is not a boundary Entire enforces: it comes from the +// agent (GetSessionDir), not from checkpoint data or a hook payload, and it +// routinely lives under a symlinked ~/.claude or ~/.codex. What IS enforced is +// everything below it — see WriteFile, which creates nested directories with +// MkdirAllNoSymlink and refuses a symlinked leaf. +// 0700, not 0750: this directory holds session transcripts. It matches what +// the resume path used to create it with before that MkdirAll was removed as +// redundant, and a no-op when the agent already made the directory itself. func (s *SessionStore) openRootForWrite() (*os.Root, error) { - if err := os.MkdirAll(s.dir, 0o750); err != nil { + if err := os.MkdirAll(s.dir, 0o700); err != nil { return nil, fmt.Errorf("create session directory: %w", err) } return s.openRoot() @@ -123,6 +145,9 @@ func (s *SessionStore) openRootForWrite() (*os.Root, error) { // name relative to the store rejects an ID that walked out of the directory, // which a plain filepath.Join would have produced silently. func (s *SessionStore) SessionFile(agentSessionID string) (name, absPath string, err error) { + if err := validation.ValidateSessionID(agentSessionID); err != nil { + return "", "", fmt.Errorf("resolve session file: %w: %w", ErrUnsafeSessionName, err) + } resolved := s.agent.ResolveSessionFile(s.dir, agentSessionID) name, err = s.Name(resolved) if err != nil { @@ -136,7 +161,8 @@ func (s *SessionStore) SessionFile(agentSessionID string) (name, absPath string, // store is accepted as-is. func (s *SessionStore) Name(p string) (string, error) { if p == "" { - return "", fmt.Errorf("%w: empty path", ErrOutsideSessionStore) + // Malformed, not outside: nothing was compared against the store. + return "", fmt.Errorf("%w: empty path", ErrUnsafeSessionName) } // VolumeName alongside IsAbs, the pairing validation.ValidateSessionID and // gitrepo's alternates checks already use. A Windows drive-relative path @@ -147,8 +173,8 @@ func (s *SessionStore) Name(p string) (string, error) { // which is the answer this containment check exists to give. No-op on Unix, // where VolumeName is always empty. if !filepath.IsAbs(p) && filepath.VolumeName(p) == "" { - cleaned := filepath.ToSlash(filepath.Clean(filepath.FromSlash(p))) - if cleaned == "." || paths.IsRelativeTraversal(cleaned) { + cleaned := cleanRelativeName(p) + if relativeNameEscapes(cleaned) { return "", fmt.Errorf("%w: %s", ErrOutsideSessionStore, p) } return cleaned, nil @@ -160,6 +186,188 @@ func (s *SessionStore) Name(p string) (string, error) { return filepath.ToSlash(rel), nil } +func cleanRelativeName(p string) string { + return filepath.ToSlash(filepath.Clean(filepath.FromSlash(p))) +} + +// relativeNameEscapes reports whether an already-cleaned relative name leaves +// its own base. One implementation, shared by Name and by the external-agent +// preflight, which has no store to resolve against and used to carry a copy. +func relativeNameEscapes(cleaned string) bool { + return cleaned == "." || paths.IsRelativeTraversal(cleaned) +} + +// SessionRefIsFilesystemPath reports whether ref is unambiguously a filesystem +// path rather than an agent-defined opaque key. +func SessionRefIsFilesystemPath(ref string) bool { + return filepath.IsAbs(ref) || filepath.VolumeName(ref) != "" +} + +// ValidateExternalSessionRef applies every rule on an external agent's +// session_ref that needs no session store, and reports whether ref is +// filesystem-shaped — that is, whether the caller must also run +// (*SessionStore).ValidateExternalWriteRef against the agent's store. +// +// The protocol leaves session_ref agent-defined, so a relative value may be an +// opaque key (a database row, a tenant-scoped identifier) and is forwarded as +// given. Two rules still apply to it: it must not be rooted, and it must not +// lexically escape its own base. +// +// Every rule here is decided without a session store, so every failure reports +// ErrUnsafeSessionName and never ErrOutsideSessionStore: `/./s.jsonl` +// cleans to a file INSIDE the store, and a rooted reference was compared +// against nothing at all. Containment is ValidateExternalWriteRef's answer to +// give. +// +// Deliberately independent of RepoPath. The store-backed half needs a repo to +// resolve a session directory; these rules do not, and gating them on a field +// both current callers happen to set is a check that disappears for the next +// caller that does not. +func ValidateExternalSessionRef(ref string) (filesystemPath bool, err error) { + if ref == "" { + return false, nil + } + if SessionRefIsFilesystemPath(ref) { + // A dot component survives no round trip through a plugin that joins or + // normalizes it, so it is refused rather than cleaned away here. + for _, component := range strings.Split(filepath.ToSlash(ref), "/") { + if component == "." || component == ".." { + return false, fmt.Errorf("%w: %s contains a dot path component", ErrUnsafeSessionName, ref) + } + } + return true, nil + } + if os.IsPathSeparator(ref[0]) { + return false, fmt.Errorf("%w: %s is rooted", ErrUnsafeSessionName, ref) + } + if relativeNameEscapes(cleanRelativeName(ref)) { + return false, fmt.Errorf("%w: %s escapes its relative base", ErrUnsafeSessionName, ref) + } + return false, nil +} + +// ValidateExternalWriteRef is the store-backed half of the preflight: it +// resolves ref as a name inside the store and checks that name. Callers pass a +// ref that ValidateExternalSessionRef reported as filesystem-shaped. +// +// The Name-then-check order is WriteFile's own prologue and stays here rather +// than in every caller. +func (s *SessionStore) ValidateExternalWriteRef(ref string) error { + name, err := s.Name(ref) + if err != nil { + // Name is lexical, and the plugin is a separate program that need not + // spell the store the way get-session-dir did. A dotfile-managed + // ~/.agentx/sessions reported as the link and read back as its target — + // or anything under macOS /var, which is a link to /private/var — is the + // SAME directory, and refusing it would break exactly the setup the + // built-in write path deliberately follows (see openRootForWrite). So a + // containment failure is retried with both sides resolved before it is + // believed. + resolved, ok := s.nameAcrossSymlinks(ref) + if !ok { + return err + } + name = resolved + } + return s.validateWritePath(name) +} + +// nameAcrossSymlinks retries Name with the store and the reference both +// resolved. It reports false when either cannot be resolved, so the caller +// keeps the lexical answer rather than trading a definite refusal for an +// unknown. +// +// Used by the external preflight only. The built-in write path +// (WriteSessionFile, sessionStoreForWrite) compares lexically and needs no +// retry because there is only one speller: its SessionRef is derived from the +// same GetSessionDir result the store was opened with. An external plugin is a +// separate program that reports its directory and returns its reference +// independently, which is what puts two spellings of one directory in play. +func (s *SessionStore) nameAcrossSymlinks(ref string) (string, bool) { + realDir, ok := evalSymlinksAllowingMissingLeaf(s.dir) + if !ok { + return "", false + } + realRef, ok := evalSymlinksAllowingMissingLeaf(ref) + if !ok { + return "", false + } + rel, err := filepath.Rel(realDir, realRef) + if err != nil || rel == "." || paths.IsRelativeTraversal(rel) { + return "", false + } + return filepath.ToSlash(rel), true +} + +// evalSymlinksAllowingMissingLeaf resolves the deepest existing prefix of p and +// re-appends the rest. filepath.EvalSymlinks requires the whole path to exist, +// and the reference being validated names a file that is about to be written — +// so the leaf, and sometimes its directory, legitimately does not exist yet. +func evalSymlinksAllowingMissingLeaf(p string) (string, bool) { + rest := "" + for cur := p; ; { + resolved, err := filepath.EvalSymlinks(cur) + if err == nil { + return filepath.Join(resolved, rest), true + } + if !os.IsNotExist(err) { + return "", false + } + parent := filepath.Dir(cur) + if parent == cur { + return "", false + } + rest = filepath.Join(filepath.Base(cur), rest) + cur = parent + } +} + +// validateWritePath rejects an unsafe component in name and a symlink at name +// itself. A missing store is allowed because the external agent may create it. +// +// This is a point-in-time preflight for a path handed to an external +// subprocess, not a containment boundary: the subprocess can race it, and the +// store's own location is the agent's to choose. Built-in writes go through +// WriteFile, which repeats the name check and then writes through an os.Root. +func (s *SessionStore) validateWritePath(name string) error { + if err := validateWriteName(name); err != nil { + return err + } + + root, err := s.openRoot() + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("inspect session write path: %w", err) + } + defer root.Close() + + info, err := osroot.LstatNoSymlinks(root, name) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("inspect session write path: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%s: %w", name, osroot.ErrSymlinkedPath) + } + return nil +} + +// validateWriteName checks each component of name. Lexical only — it touches no +// filesystem, which is why it reports ErrUnsafeSessionName rather than the +// containment sentinel. +func validateWriteName(name string) error { + for _, component := range strings.Split(filepath.ToSlash(name), "/") { + if err := validation.ValidateFileNameComponent(component); err != nil { + return fmt.Errorf("validate session file name: %w: %w", ErrUnsafeSessionName, err) + } + } + return nil +} + // ReadFile reads name from the store. func (s *SessionStore) ReadFile(name string) ([]byte, error) { root, err := s.openRoot() @@ -171,16 +379,23 @@ func (s *SessionStore) ReadFile(name string) ([]byte, error) { } // WriteFile writes name in the store, creating parent directories. Session -// layouts nest (Gemini keys by project hash, Pi by encoded repo path), so the -// parents are made here rather than at each call site. +// layouts nest (Copilot `/events.jsonl`, Cursor `/.jsonl`, Codex +// `YYYY/MM/DD/`), so the parents are made here rather than at each call site. +// +// Not Pi, which this comment used to cite: it resolves to a flat name and puts +// its project component in GetSessionDir, i.e. in the store root — so it is an +// agent for which the MkdirAll below never fires. func (s *SessionStore) WriteFile(name string, data []byte, perm os.FileMode) error { + if err := validateWriteName(name); err != nil { + return err + } root, err := s.openRootForWrite() if err != nil { return err } defer root.Close() if dir := filepath.ToSlash(filepath.Dir(filepath.FromSlash(name))); dir != "." { - if err := osroot.MkdirAllNoSymlink(root, dir, 0o750); err != nil { + if err := osroot.MkdirAllNoSymlink(root, dir, 0o700); err != nil { return fmt.Errorf("create session directory: %w", err) } } @@ -192,28 +407,59 @@ func (s *SessionStore) WriteFile(name string, data []byte, perm os.FileMode) err return jsonutil.WriteFileAtomicIn(root, name, data, perm) //nolint:wrapcheck // preserved for os.IsNotExist at call sites } -// Exists reports whether name is present in the store. Lstat, not Stat: a -// dangling symlink is still a file that exists and must not be overwritten -// silently (see the rewind restore path, which distinguishes the two). -func (s *SessionStore) Exists(name string) bool { +// Lstat returns the FileInfo for name inside the store without following a +// symlink at any component. The leaf is returned as-is, so a caller can tell a +// symlink from a regular file and refuse it. A missing name is reported +// unwrapped for os.IsNotExist / errors.Is(err, fs.ErrNotExist). +func (s *SessionStore) Lstat(name string) (os.FileInfo, error) { root, err := s.openRoot() if err != nil { - return false + return nil, err + } + defer root.Close() + return osroot.LstatNoSymlinks(root, name) //nolint:wrapcheck // preserved for os.IsNotExist at call sites +} + +// CreateExclusive creates name as an empty file inside the store, creating +// parent directories, and fails when anything already exists there — the +// error wraps fs.ErrExist so callers can treat "the agent wrote it first" as +// success. A symlink in any component, including the leaf, is refused rather +// than followed. It exists for the late-transcript agents that must +// materialise a placeholder without ever replacing a file the agent has since +// written, which an atomic rename would do. +func (s *SessionStore) CreateExclusive(name string, perm os.FileMode) error { + root, err := s.openRootForWrite() + if err != nil { + return err } defer root.Close() - _, err = osroot.LstatNoSymlinks(root, name) + if dir := filepath.ToSlash(filepath.Dir(filepath.FromSlash(name))); dir != "." { + if err := osroot.MkdirAllNoSymlink(root, dir, 0o700); err != nil { + return fmt.Errorf("create session directory: %w", err) + } + } + f, err := osroot.OpenFileNoFollow(root, name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm) + if err != nil { + return err //nolint:wrapcheck // preserved for errors.Is(err, fs.ErrExist) at call sites + } + if err := f.Close(); err != nil { + return fmt.Errorf("close session file: %w", err) + } + return nil +} + +// Exists reports whether name is present in the store. Lstat, not Stat: a +// dangling symlink is still a file that exists and must not be overwritten +// silently (see the rewind restore path, which distinguishes the two). +func (s *SessionStore) Exists(name string) bool { + _, err := s.Lstat(name) return err == nil } // IsDir reports whether name is a real directory in the store. Symlinks in the // path are rejected by LstatNoSymlinks rather than followed. func (s *SessionStore) IsDir(name string) bool { - root, err := s.openRoot() - if err != nil { - return false - } - defer root.Close() - info, err := osroot.LstatNoSymlinks(root, name) + info, err := s.Lstat(name) return err == nil && info.IsDir() } diff --git a/cmd/entire/cli/agent/session_store_test.go b/cmd/entire/cli/agent/session_store_test.go index 6df76b2a28..58b6ef3a6c 100644 --- a/cmd/entire/cli/agent/session_store_test.go +++ b/cmd/entire/cli/agent/session_store_test.go @@ -2,6 +2,7 @@ package agent_test import ( "fmt" + "io/fs" "os" "path/filepath" "testing" @@ -56,7 +57,26 @@ func TestSessionStore_SessionFileRejectsEscapingSessionID(t *testing.T) { store, _ := newStore(t, joinResolve) _, _, err := store.SessionFile("../../escaped") - require.ErrorIs(t, err, agent.ErrOutsideSessionStore) + require.ErrorIs(t, err, agent.ErrUnsafeSessionName) +} + +func TestSessionStore_SessionFileRejectsUnsafeIDBeforeResolver(t *testing.T) { + t.Parallel() + + for _, sessionID := range []string{"session.", "CON"} { + t.Run(sessionID, func(t *testing.T) { + t.Parallel() + + resolverCalled := false + store, _ := newStore(t, func(dir, id string) string { + resolverCalled = true + return joinResolve(dir, id) + }) + _, _, err := store.SessionFile(sessionID) + require.ErrorIs(t, err, agent.ErrUnsafeSessionName) + assert.False(t, resolverCalled, "unsafe ID must not reach the agent resolver") + }) + } } // An agent that resolves to a sibling directory is rejected too — the store's @@ -93,6 +113,65 @@ func TestSessionStore_WriteFileRejectsEscapingName(t *testing.T) { assert.True(t, os.IsNotExist(err), "an escaping write must not land outside the store") } +// A symlinked store root is FOLLOWED, deliberately. The store's location comes +// from the agent (GetSessionDir), not from checkpoint metadata or a hook +// payload, and a ~/.claude or ~/.codex managed by a dotfile tool is an ordinary +// setup among exactly the people who run coding agents. Containment starts one +// level down — see TestSessionStore_WriteFileRejectsEscapingName and the +// symlinked-parent cases in the external agent's preflight. +func TestSessionStore_FollowsSymlinkedStoreRoot(t *testing.T) { + t.Parallel() + + realStore := t.TempDir() + storeDir := filepath.Join(t.TempDir(), "store") + if err := os.Symlink(realStore, storeDir); err != nil { + t.Skipf("symlink not supported: %v", err) + } + store, err := agent.OpenSessionStoreAt(&storeStubAgent{dir: storeDir, resolve: joinResolve}, storeDir) + require.NoError(t, err) + + // ABSOLUTE refs on purpose. A relative one is returned by Name unchanged + // without s.dir being consulted at all, so it answers nil whether the store + // root is a symlink, a real directory, or absent — which is what let the + // realpath case below ship broken. + require.NoError(t, store.ValidateExternalWriteRef(filepath.Join(storeDir, "session.jsonl"))) + + // The same file named through the link's TARGET. A plugin reports its store + // through get-session-dir and may hand back a realpath'd reference; both + // spellings are one directory and both must be accepted. + require.NoError(t, store.ValidateExternalWriteRef(filepath.Join(realStore, "session.jsonl"))) + + // Several missing components, so the resolve-the-deepest-existing-prefix walk + // has to strip more than the leaf. A version of it that stripped only the + // leaf passes every other assertion here. + require.NoError(t, store.ValidateExternalWriteRef(filepath.Join(realStore, "a", "b", "session.jsonl"))) + + // Still outside is still refused, resolved or not. + outside := t.TempDir() + require.Error(t, store.ValidateExternalWriteRef(filepath.Join(outside, "session.jsonl"))) + + require.NoError(t, store.WriteFile("session.jsonl", []byte("hi\n"), 0o600)) + assert.FileExists(t, filepath.Join(realStore, "session.jsonl")) +} + +// A store that does not exist yet is created even below a symlinked ancestor, +// for the same reason: that is where a dotfile-managed agent directory puts it. +func TestSessionStore_CreatesMissingStoreBelowSymlinkedAncestor(t *testing.T) { + t.Parallel() + + outside := t.TempDir() + linkedParent := filepath.Join(t.TempDir(), "linked") + if err := os.Symlink(outside, linkedParent); err != nil { + t.Skipf("symlink not supported: %v", err) + } + storeDir := filepath.Join(linkedParent, "missing-store") + store, err := agent.OpenSessionStoreAt(&storeStubAgent{dir: storeDir, resolve: joinResolve}, storeDir) + require.NoError(t, err) + + require.NoError(t, store.WriteFile("session.jsonl", []byte("hi\n"), 0o600)) + assert.FileExists(t, filepath.Join(outside, "missing-store", "session.jsonl")) +} + // Lstat, not Stat: a dangling session log still exists, and both the rewind and // resume paths must keep it rather than silently overwrite it. func TestSessionStore_ExistsReportsDanglingSymlink(t *testing.T) { @@ -105,6 +184,38 @@ func TestSessionStore_ExistsReportsDanglingSymlink(t *testing.T) { assert.True(t, store.Exists("a.jsonl")) } +func TestSessionStore_ValidateExternalWriteRefAllowsMissingStore(t *testing.T) { + t.Parallel() + + storeDir := filepath.Join(t.TempDir(), "missing-store") + store, err := agent.OpenSessionStoreAt(&storeStubAgent{dir: storeDir, resolve: joinResolve}, storeDir) + require.NoError(t, err) + + require.NoError(t, store.ValidateExternalWriteRef("session.jsonl")) + _, err = os.Stat(storeDir) + assert.True(t, os.IsNotExist(err), "validation must not create the missing store") +} + +func TestSessionStore_ValidateExternalWriteRefRejectsUnsafeNameWithMissingStore(t *testing.T) { + t.Parallel() + + storeDir := filepath.Join(t.TempDir(), "missing-store") + store, err := agent.OpenSessionStoreAt(&storeStubAgent{dir: storeDir, resolve: joinResolve}, storeDir) + require.NoError(t, err) + + for _, name := range []string{ + filepath.Join(".. ", "session.jsonl"), + filepath.Join("session.", "session.jsonl"), + filepath.Join("bad\x00name", "session.jsonl"), + filepath.Join("CON", "session.jsonl"), + filepath.Join("nul.jsonl", "session.jsonl"), + } { + require.Error(t, store.ValidateExternalWriteRef(name), name) + } + _, err = os.Stat(storeDir) + assert.True(t, os.IsNotExist(err), "validation must not create the missing store") +} + func TestWriteSessionFile_WritesThroughTheStore(t *testing.T) { t.Parallel() @@ -171,3 +282,112 @@ func TestSessionStore_ProbingManyDirectoriesRetainsNoDescriptors(t *testing.T) { require.Less(t, countFDs()-before, 16, "probing %d candidate directories must not retain a descriptor per directory", candidates) } + +func TestSessionStore_LstatRefusesSymlinkedParentAndReportsLeaf(t *testing.T) { + t.Parallel() + store, dir := newStore(t, joinResolve) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "real.jsonl"), []byte("x"), 0o600)) + info, err := store.Lstat("real.jsonl") + require.NoError(t, err) + assert.Equal(t, int64(1), info.Size()) + + _, err = store.Lstat("missing.jsonl") + assert.True(t, os.IsNotExist(err), "a missing name must classify as not-exist, got %v", err) + + if err := os.Symlink(filepath.Join(dir, "real.jsonl"), filepath.Join(dir, "link.jsonl")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + info, err = store.Lstat("link.jsonl") + require.NoError(t, err) + assert.NotZero(t, info.Mode()&os.ModeSymlink, "the leaf is returned as-is so the caller can refuse it") + + elsewhere := t.TempDir() + require.NoError(t, os.Symlink(elsewhere, filepath.Join(dir, "sub"))) + _, err = store.Lstat("sub/anything.jsonl") + require.Error(t, err, "a symlinked parent component must be refused") +} + +func TestSessionStore_CreateExclusiveCreatesOnceAndNeverReplaces(t *testing.T) { + t.Parallel() + store, dir := newStore(t, joinResolve) + + require.NoError(t, store.CreateExclusive("conv/logs/transcript.jsonl", 0o600)) + info, err := os.Stat(filepath.Join(dir, "conv", "logs", "transcript.jsonl")) + require.NoError(t, err) + assert.Equal(t, int64(0), info.Size()) + + // The agent wrote the real file in between: a second create must fail with + // fs.ErrExist and leave the content alone. + require.NoError(t, os.WriteFile(filepath.Join(dir, "conv", "logs", "transcript.jsonl"), []byte("real"), 0o600)) + err = store.CreateExclusive("conv/logs/transcript.jsonl", 0o600) + require.ErrorIs(t, err, fs.ErrExist) + data, err := os.ReadFile(filepath.Join(dir, "conv", "logs", "transcript.jsonl")) + require.NoError(t, err) + assert.Equal(t, "real", string(data)) + + if err := os.Symlink(t.TempDir(), filepath.Join(dir, "linked")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + require.Error(t, store.CreateExclusive("linked/transcript.jsonl", 0o600), "a symlinked parent must be refused") +} + +// The store itself may not exist yet — an external plugin is allowed to create +// it — so the walk has to resolve a symlinked ANCESTOR while the store and the +// reference below it are both still missing. +func TestSessionStore_FollowsSymlinkedAncestorOfAMissingStore(t *testing.T) { + t.Parallel() + + base := t.TempDir() + realParent := filepath.Join(base, "real-parent") + require.NoError(t, os.MkdirAll(realParent, 0o700)) + linkedParent := filepath.Join(base, "linked-parent") + if err := os.Symlink(realParent, linkedParent); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + storeDir := filepath.Join(linkedParent, "missing-store") + store, err := agent.OpenSessionStoreAt(&storeStubAgent{dir: storeDir, resolve: joinResolve}, storeDir) + require.NoError(t, err) + + // The plugin's own spelling, and the same location through the link's target. + require.NoError(t, store.ValidateExternalWriteRef(filepath.Join(storeDir, "session.jsonl"))) + require.NoError(t, store.ValidateExternalWriteRef(filepath.Join(realParent, "missing-store", "session.jsonl"))) + + // A sibling of the missing store is still outside it. + require.Error(t, store.ValidateExternalWriteRef(filepath.Join(realParent, "other-store", "session.jsonl"))) +} + +// Nested directories inherit the store root's 0700 rather than 0750: they hold +// the same transcripts, and Copilot, Cursor and Codex all write into them. Both +// writers that create those directories are covered: CreateExclusive reaches +// the same tree as WriteFile, through the late-transcript placeholder. +func TestSessionStore_CreatesNestedDirectories0700(t *testing.T) { + t.Parallel() + + writers := map[string]func(*agent.SessionStore) error{ + "WriteFile": func(s *agent.SessionStore) error { + return s.WriteFile("nested/deeper/session.jsonl", []byte("hi\n"), 0o600) + }, + "CreateExclusive": func(s *agent.SessionStore) error { + return s.CreateExclusive("nested/deeper/session.jsonl", 0o600) + }, + } + + for name, write := range writers { + t.Run(name, func(t *testing.T) { + t.Parallel() + + storeDir := filepath.Join(t.TempDir(), "store") + store, err := agent.OpenSessionStoreAt(&storeStubAgent{dir: storeDir, resolve: joinResolve}, storeDir) + require.NoError(t, err) + require.NoError(t, write(store)) + + for _, dir := range []string{storeDir, filepath.Join(storeDir, "nested"), filepath.Join(storeDir, "nested", "deeper")} { + info, err := os.Stat(dir) + require.NoError(t, err, dir) + assert.Equal(t, os.FileMode(0o700), info.Mode().Perm(), dir) + } + }) + } +} diff --git a/cmd/entire/cli/agent/skilldiscovery/registry.go b/cmd/entire/cli/agent/skilldiscovery/registry.go index 1d74e7ff40..44fa17c444 100644 --- a/cmd/entire/cli/agent/skilldiscovery/registry.go +++ b/cmd/entire/cli/agent/skilldiscovery/registry.go @@ -14,7 +14,7 @@ type CuratedSkill struct { // discovered set, the hint is suppressed. // // When ProvidesAny is nil, the hint is always shown — use this for -// ecosystems where we can't predict plugin skill names (e.g. Gemini). +// ecosystems where we can't predict plugin skill names. type InstallHint struct { Message string ProvidesAny []string @@ -23,8 +23,7 @@ type InstallHint struct { // curatedBuiltins lists the review-adjacent commands that ship with each // agent binary (no plugin install required). See // docs/superpowers/specs/2026-04-22-entire-review-picker-install-awareness-design.md -// §Data model for the sources these names came from. Gemini CLI has no -// built-in review command and relies on the install hint below. +// §Data model for the sources these names came from. var curatedBuiltins = map[string][]CuratedSkill{ "claude-code": { {Name: "/review", Desc: "Review changes and find issues"}, @@ -36,8 +35,7 @@ var curatedBuiltins = map[string][]CuratedSkill{ // not when piped through exec. Codex's review skills (code-reviewer, // review-swarm, …) live on disk and are surfaced by DiscoverReviewSkills in // $name form, so there are no curated built-ins to hardcode here. - "codex": {}, - "gemini": {}, + "codex": {}, } // installHints lists the passive install pointers shown in the picker when @@ -76,9 +74,15 @@ var installHints = map[string][]InstallHint{ ProvidesAny: []string{"$codex:adversarial-review"}, }, }, - "gemini": { + // Antigravity has no built-in review command and no predictable plugin + // skill names, so the hint is always shown (ProvidesAny nil). The path is + // agy 1.1+'s global skills root; ~/.gemini/skills is a pre-1.1 layout (see + // antigravity/discovery.go). Entire scans both, so a skill placed at the + // old path still reaches Entire's prompt — but agy itself will not load + // it, which is the half a hint pointing there would silently get wrong. + "antigravity": { { - Message: "Install gemini-code-review: gemini extensions install ", + Message: "Add a review skill under ~/.gemini/config/skills//SKILL.md, e.g.: npx antigravity-awesome-skills --agy", ProvidesAny: nil, }, }, diff --git a/cmd/entire/cli/agent/skilldiscovery/registry_test.go b/cmd/entire/cli/agent/skilldiscovery/registry_test.go index 9a1baf34c7..2c9a6d1b1a 100644 --- a/cmd/entire/cli/agent/skilldiscovery/registry_test.go +++ b/cmd/entire/cli/agent/skilldiscovery/registry_test.go @@ -18,10 +18,6 @@ func TestCuratedBuiltinsFor_KnownAgents(t *testing.T) { if len(codex) != 0 { t.Errorf("codex built-ins: got %+v, want 0 (discovery-driven)", codex) } - gemini := skilldiscovery.CuratedBuiltinsFor("gemini") - if len(gemini) != 0 { - t.Errorf("gemini built-ins: got %d, want 0", len(gemini)) - } } func TestCuratedBuiltinsFor_UnknownAgentReturnsEmpty(t *testing.T) { @@ -52,18 +48,26 @@ func TestActiveInstallHintsFor_ShowsAllWhenNothingDiscovered(t *testing.T) { } } -func TestActiveInstallHintsFor_GeminiAlwaysShownRegardlessOfDiscovery(t *testing.T) { +func TestActiveInstallHintsFor_AntigravityAlwaysShownRegardlessOfDiscovery(t *testing.T) { t.Parallel() - hints := skilldiscovery.ActiveInstallHintsFor("gemini", map[string]struct{}{"/anything": {}}) + hints := skilldiscovery.ActiveInstallHintsFor("antigravity", map[string]struct{}{"/code-review": {}}) if len(hints) == 0 { - t.Error("gemini hint with nil ProvidesAny should always show") + t.Error("antigravity hint with nil ProvidesAny should always show") + } +} + +func TestIsEligible_IncludesAntigravity(t *testing.T) { + t.Parallel() + if !skilldiscovery.IsEligible("antigravity") { + t.Error("antigravity should be eligible via install hint") } } func TestIsEligible_IncludesAgentWithOnlyInstallHint(t *testing.T) { t.Parallel() - if !skilldiscovery.IsEligible("gemini") { - t.Error("gemini should be eligible via install hint alone") + // Codex has no curated built-ins, only an install hint. + if !skilldiscovery.IsEligible("codex") { + t.Error("codex should be eligible via install hint alone") } if !skilldiscovery.IsEligible("claude-code") { t.Error("claude-code should be eligible via built-ins") diff --git a/cmd/entire/cli/agent/skilldiscovery/scan_test.go b/cmd/entire/cli/agent/skilldiscovery/scan_test.go new file mode 100644 index 0000000000..a4d0b67dab --- /dev/null +++ b/cmd/entire/cli/agent/skilldiscovery/scan_test.go @@ -0,0 +1,71 @@ +package skilldiscovery + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func writeSkillMD(t *testing.T, root, name, contents string) { + t.Helper() + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(contents), 0o600); err != nil { + t.Fatalf("write: %v", err) + } +} + +func TestScanSkillsDir_FindsReviewSkillsAndSkipsRest(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeSkillMD(t, root, "code-review", "---\nname: code-review\ndescription: Review a PR.\n---\nbody") + writeSkillMD(t, root, "formatter", "---\nname: formatter\ndescription: Format code.\n---\nbody") + writeSkillMD(t, root, "broken", "no frontmatter here") + + got := ScanSkillsDir(context.Background(), root, "", SlashForm) + if len(got) != 1 { + t.Fatalf("got %d skills, want 1: %+v", len(got), got) + } + if got[0].Name != "/code-review" { + t.Errorf("Name = %q, want /code-review", got[0].Name) + } + if got[0].Description != "Review a PR." { + t.Errorf("Description = %q, want %q", got[0].Description, "Review a PR.") + } + if got[0].SourcePath == "" { + t.Error("SourcePath should be populated") + } +} + +func TestScanSkillsDir_FallsBackToDirNameWhenNoNameField(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeSkillMD(t, root, "security-audit", "---\ndescription: Audit deps.\n---\nbody") + + got := ScanSkillsDir(context.Background(), root, "", SlashForm) + if len(got) != 1 || got[0].Name != "/security-audit" { + t.Fatalf("want /security-audit from dir name, got %+v", got) + } +} + +func TestScanSkillsDir_PluginPrefix(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeSkillMD(t, root, "hunter", "---\nname: hunter\ndescription: x.\n---\nbody") + + got := ScanSkillsDir(context.Background(), root, "pr-review-toolkit", SlashForm) + if len(got) != 1 || got[0].Name != "/pr-review-toolkit:hunter" { + t.Fatalf("want /pr-review-toolkit:hunter (matches via plugin prefix), got %+v", got) + } +} + +func TestScanSkillsDir_MissingDirReturnsNil(t *testing.T) { + t.Parallel() + got := ScanSkillsDir(context.Background(), filepath.Join(t.TempDir(), "nope"), "", SlashForm) + if got != nil { + t.Errorf("want nil for missing dir, got %+v", got) + } +} diff --git a/cmd/entire/cli/agent/testutil/hooks.go b/cmd/entire/cli/agent/testutil/hooks.go index bc22cb0bcd..1b5175e48c 100644 --- a/cmd/entire/cli/agent/testutil/hooks.go +++ b/cmd/entire/cli/agent/testutil/hooks.go @@ -9,7 +9,7 @@ import ( ) // ReadRawHooks reads the raw hooks map from a settings file. -// settingsDir is the directory name (e.g., ".claude" or ".gemini"). +// settingsDir is the directory name (e.g., ".claude" or ".cursor"). func ReadRawHooks(t *testing.T, tempDir, settingsDir string) map[string]json.RawMessage { t.Helper() settingsPath := filepath.Join(tempDir, settingsDir, "settings.json") diff --git a/cmd/entire/cli/agent/text_generator_cli.go b/cmd/entire/cli/agent/text_generator_cli.go index 1979c70499..c8841cf47c 100644 --- a/cmd/entire/cli/agent/text_generator_cli.go +++ b/cmd/entire/cli/agent/text_generator_cli.go @@ -108,13 +108,13 @@ func RunIsolatedTextGeneratorCLI(ctx context.Context, runner TextCommandRunner, const openCodeBinary = "opencode" var summaryProviderBinaries = map[types.AgentName]string{ - AgentNameClaudeCode: "claude", - AgentNameCodex: "codex", - AgentNameCopilotCLI: "copilot", - AgentNameCursor: "agent", - AgentNameGemini: "gemini", - AgentNamePi: "pi", - AgentNameOpenCode: openCodeBinary, + AgentNameAntigravity: "agy", + AgentNameClaudeCode: "claude", + AgentNameCodex: "codex", + AgentNameCopilotCLI: "copilot", + AgentNameCursor: "agent", + AgentNamePi: "pi", + AgentNameOpenCode: openCodeBinary, } // SummaryCLIBinaryName returns the CLI binary name for a summary-capable @@ -128,7 +128,7 @@ func SummaryCLIBinaryName(name types.AgentName) string { // IsSummaryCLIAvailable reports whether the CLI binary for a summary-capable // agent is on PATH. This is distinct from DetectPresence, which checks // repo-level agent configuration — a repo configured with Claude Code for -// development can still use Codex or Gemini for summary generation as long +// development can still use Codex or Pi for summary generation as long // as the binary is installed. func IsSummaryCLIAvailable(name types.AgentName) bool { binary := SummaryCLIBinaryName(name) diff --git a/cmd/entire/cli/agent/transcript_read_guard_test.go b/cmd/entire/cli/agent/transcript_read_guard_test.go index a4b93a2429..74babf6955 100644 --- a/cmd/entire/cli/agent/transcript_read_guard_test.go +++ b/cmd/entire/cli/agent/transcript_read_guard_test.go @@ -34,6 +34,7 @@ const transcriptReadPattern = `os\.(ReadFile|Open)\((sessionRef|transcriptPath)\ // nothing while looking like it does. See // docs/development/filesystem-safety.md#the-root-anchors. var unconfinedTranscriptReads = map[string]int{ + "cmd/entire/cli/agent/antigravity/transcript.go": 1, "cmd/entire/cli/agent/claudecode/lifecycle.go": 1, "cmd/entire/cli/agent/codex/codex.go": 1, "cmd/entire/cli/agent/codex/transcript.go": 1, @@ -43,7 +44,6 @@ var unconfinedTranscriptReads = map[string]int{ "cmd/entire/cli/agent/cursor/transcript.go": 1, "cmd/entire/cli/agent/factoryaidroid/factoryaidroid.go": 1, "cmd/entire/cli/agent/factoryaidroid/lifecycle.go": 1, - "cmd/entire/cli/agent/geminicli/lifecycle.go": 1, "cmd/entire/cli/agent/vogon/vogon.go": 1, // The integration harness reads a transcript it wrote itself, in a temp diff --git a/cmd/entire/cli/agent/types/agent.go b/cmd/entire/cli/agent/types/agent.go index f7d8de1d4b..c6be6f797b 100644 --- a/cmd/entire/cli/agent/types/agent.go +++ b/cmd/entire/cli/agent/types/agent.go @@ -1,7 +1,7 @@ package types -// AgentName is the registry key type for agents (e.g., "claude-code", "gemini"). +// AgentName is the registry key type for agents (e.g., "claude-code", "codex"). type AgentName string -// AgentType is the display name type stored in metadata/trailers (e.g., "Claude Code", "Gemini CLI"). +// AgentType is the display name type stored in metadata/trailers (e.g., "Claude Code", "Codex"). type AgentType string diff --git a/cmd/entire/cli/agent/vogon/transcript.go b/cmd/entire/cli/agent/vogon/transcript.go index ebe8acafb3..523879921d 100644 --- a/cmd/entire/cli/agent/vogon/transcript.go +++ b/cmd/entire/cli/agent/vogon/transcript.go @@ -2,6 +2,7 @@ package vogon import ( "bufio" + "context" "encoding/json" "fmt" "os" @@ -41,7 +42,7 @@ func (v *Agent) GetTranscriptPosition(path string) (int, error) { // appear only in its own transcript. Without it, `entire hooks vogon post-task` // would fall back to worktree state alone and the canary could not see regressions // in subagent transcript resolution or file attribution. -func (v *Agent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { +func (v *Agent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) ([]string, int, error) { lines, err := readTranscriptLines(path) if err != nil { return nil, 0, err diff --git a/cmd/entire/cli/agent/vogon/transcript_test.go b/cmd/entire/cli/agent/vogon/transcript_test.go index 6639b1ab6f..97d6f34f0c 100644 --- a/cmd/entire/cli/agent/vogon/transcript_test.go +++ b/cmd/entire/cli/agent/vogon/transcript_test.go @@ -1,6 +1,7 @@ package vogon import ( + "context" "os" "path/filepath" "testing" @@ -23,7 +24,7 @@ func TestExtractModifiedFilesFromOffset_OnlyToolUseEntries(t *testing.T) { t.Fatalf("write transcript: %v", err) } - files, pos, err := (&Agent{}).ExtractModifiedFilesFromOffset(path, 0) + files, pos, err := (&Agent{}).ExtractModifiedFilesFromOffset(context.Background(), path, 0) if err != nil { t.Fatalf("ExtractModifiedFilesFromOffset: %v", err) } @@ -50,7 +51,7 @@ func TestExtractModifiedFilesFromOffset_HonoursOffsetAndDedupes(t *testing.T) { t.Fatalf("write transcript: %v", err) } - files, _, err := (&Agent{}).ExtractModifiedFilesFromOffset(path, 1) + files, _, err := (&Agent{}).ExtractModifiedFilesFromOffset(context.Background(), path, 1) if err != nil { t.Fatalf("ExtractModifiedFilesFromOffset: %v", err) } diff --git a/cmd/entire/cli/agent/vouched_dirs.go b/cmd/entire/cli/agent/vouched_dirs.go index 03138214b0..daf66dbcd7 100644 --- a/cmd/entire/cli/agent/vouched_dirs.go +++ b/cmd/entire/cli/agent/vouched_dirs.go @@ -204,11 +204,11 @@ func FollowedSymlinkedDirs(worktreeRoot string) []string { // runs in a binary where every built-in agent IS registered and fails in both // directions, so a new agent that forgets this list cannot ship. var vouchableDirs = []string{ + ".agents", ".claude", ".codex", ".cursor", ".factory", - ".gemini", ".github", ".github/hooks", ".opencode", diff --git a/cmd/entire/cli/agent/vouched_dirs_test.go b/cmd/entire/cli/agent/vouched_dirs_test.go index 11d169fb4e..dae9b350a8 100644 --- a/cmd/entire/cli/agent/vouched_dirs_test.go +++ b/cmd/entire/cli/agent/vouched_dirs_test.go @@ -366,14 +366,14 @@ func TestFollowedSymlinkedDirs_OnlyReportsActualLinks(t *testing.T) { t.Fatal(err) } // .cursor: vouched but absent -> not followed. - // .gemini: vouched but dangling -> not followed (and nothing is written there). - if err := os.Symlink(filepath.Join(t.TempDir(), "nowhere"), filepath.Join(worktree, ".gemini")); err != nil { + // .pi: vouched but dangling -> not followed (and nothing is written there). + if err := os.Symlink(filepath.Join(t.TempDir(), "nowhere"), filepath.Join(worktree, ".pi")); err != nil { t.Skipf("symlink not supported: %v", err) } - agent.SetVouchedSymlinkedDirs(worktree, []string{".claude", ".codex", ".cursor", ".gemini"}) + agent.SetVouchedSymlinkedDirs(worktree, []string{".claude", ".codex", ".cursor", ".pi"}) - if got := agent.FollowedSymlinkedDirs(worktree); !slices.Equal(got, []string{".claude", ".gemini"}) { + if got := agent.FollowedSymlinkedDirs(worktree); !slices.Equal(got, []string{".claude", ".pi"}) { t.Errorf("FollowedSymlinkedDirs() = %v, want only the paths that are symlinks on disk", got) } // The configuration is unchanged; the two answers are different questions. diff --git a/cmd/entire/cli/agent_group.go b/cmd/entire/cli/agent_group.go index 274bfdee9d..178131e1c8 100644 --- a/cmd/entire/cli/agent_group.go +++ b/cmd/entire/cli/agent_group.go @@ -100,7 +100,7 @@ func newAgentAddCmd() *cobra.Command { Examples: entire agent add claude-code - entire agent add gemini`, + entire agent add codex`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { name := args[0] diff --git a/cmd/entire/cli/agent_help_banner_test.go b/cmd/entire/cli/agent_help_banner_test.go index e7e27107af..0b4016de4f 100644 --- a/cmd/entire/cli/agent_help_banner_test.go +++ b/cmd/entire/cli/agent_help_banner_test.go @@ -25,7 +25,6 @@ func TestAgentHelpBannerSuffix(t *testing.T) { for _, name := range []types.AgentName{ agent.AgentNameClaudeCode, agent.AgentNameCodex, - agent.AgentNameGemini, agent.AgentNameCursor, agent.AgentNameCopilotCLI, agent.AgentNameOpenCode, diff --git a/cmd/entire/cli/agent_hook_config_guard_test.go b/cmd/entire/cli/agent_hook_config_guard_test.go index 9060f3482f..0bf682d59b 100644 --- a/cmd/entire/cli/agent_hook_config_guard_test.go +++ b/cmd/entire/cli/agent_hook_config_guard_test.go @@ -62,8 +62,8 @@ func TestAllHookConfigRelPaths_CoversEveryWorktreeConfigAgent(t *testing.T) { // A count, deliberately, directly under the argument against counts above — // and defeatable the same way, by dropping one agent from the registry while // adding another locator. A set comparison would need to map a package - // directory to the rel path it declares, and nothing does: `geminicli` - // declares `.gemini/settings.json` and `copilotcli` declares + // directory to the rel path it declares, and nothing does: `factoryaidroid` + // declares `.factory/settings.json` and `copilotcli` declares // `.github/hooks/entire.json`, so neither the package name nor the path's // first component derives the other. The set comparison above is the guard // that matters; this one only catches a locator the registry never sees. diff --git a/cmd/entire/cli/agentimport/agentimport.go b/cmd/entire/cli/agentimport/agentimport.go index 35d1b2d309..b5cf16f35f 100644 --- a/cmd/entire/cli/agentimport/agentimport.go +++ b/cmd/entire/cli/agentimport/agentimport.go @@ -88,7 +88,6 @@ var importers = []Importer{ factoryImporter{}, codexImporter{}, copilotImporter{}, - geminiImporter{}, } // All returns every supported importer, sorted by name. diff --git a/cmd/entire/cli/agentimport/agentimport_test.go b/cmd/entire/cli/agentimport/agentimport_test.go index 587ac1ff80..5307852dec 100644 --- a/cmd/entire/cli/agentimport/agentimport_test.go +++ b/cmd/entire/cli/agentimport/agentimport_test.go @@ -15,6 +15,7 @@ import ( cp "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/entireio/cli/cmd/entire/cli/testutil/gitenv" "github.com/entireio/cli/redact" ) @@ -50,7 +51,7 @@ func TestRegistry_HasClaude(t *testing.T) { func TestRegistry_AllSupportedAgents(t *testing.T) { t.Parallel() want := []string{ - "claude-code", "cursor", "pi", "factoryai-droid", "codex", "copilot-cli", "gemini", + "claude-code", "cursor", "pi", "factoryai-droid", "codex", "copilot-cli", } registered := make(map[string]Importer) for _, imp := range All() { @@ -458,21 +459,19 @@ func TestRun_StampsImporterGitAuthorOnCheckpointCommit(t *testing.T) { // already applies elsewhere, rather than an empty one. func TestRun_UnconfiguredGitIdentityFallsBackToDefaults(t *testing.T) { // Cannot use t.Parallel(): isolates git config resolution via t.Setenv so - // this repo can't see any real identity. GetGitAuthorFromRepo resolves - // GlobalScope through go-git's Auto loader, which reads all of git's global - // sources; neutralize every one or the fallback assertion is flaky wherever - // an identity is configured (~/.gitconfig, XDG, GIT_CONFIG_GLOBAL, or system - // /etc/gitconfig). Mirrors the checkpoint package's pointHomeAt helper. + // this repo can't see any real identity. The package TestMain already + // installs an empty ConfigLoader, so GlobalScope carries no identity; this + // keeps the env-level isolation as well, because it is what makes the + // assertion hold under go-git's Auto loader too — that one reads all of + // git's global sources (~/.gitconfig, XDG, GIT_CONFIG_GLOBAL, system + // /etc/gitconfig), so a test moved onto it stays correct rather than + // silently picking up the developer's identity. Mirrors the checkpoint + // package's pointHomeAt helper. home := t.TempDir() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", "") t.Setenv("GIT_CONFIG_NOSYSTEM", "1") - // t.Setenv registers restoration of the original value; unset it for the - // test since an empty GIT_CONFIG_GLOBAL disables global config entirely. - t.Setenv("GIT_CONFIG_GLOBAL", "") - if err := os.Unsetenv("GIT_CONFIG_GLOBAL"); err != nil { - t.Fatal(err) - } + gitenv.UnsetGlobalConfig(t) repoDir := t.TempDir() repo, err := git.PlainInit(repoDir, false) diff --git a/cmd/entire/cli/agentimport/cursor.go b/cmd/entire/cli/agentimport/cursor.go index c441109492..2eef002387 100644 --- a/cmd/entire/cli/agentimport/cursor.go +++ b/cmd/entire/cli/agentimport/cursor.go @@ -64,7 +64,7 @@ func cursorSessionFile(dir string, e os.DirEntry) (sessionID, path string) { // turn key (as the Codex importer does), so each prompt yields a distinct // checkpoint ID instead of colliding on an empty UUID and dropping every turn // after the first. The timestamp falls back to the transcript file's modtime -// (as the Factory/Gemini importers do). +// (as the Factory importer does). func (cursorImporter) SplitTurns(sf SessionFile, full []byte) ([]Turn, error) { var createdAt time.Time if info, statErr := os.Stat(sf.Path); statErr == nil { diff --git a/cmd/entire/cli/agentimport/factory.go b/cmd/entire/cli/agentimport/factory.go index 2e24079fee..532cf05f97 100644 --- a/cmd/entire/cli/agentimport/factory.go +++ b/cmd/entire/cli/agentimport/factory.go @@ -42,7 +42,7 @@ func (factoryImporter) Discover(repoRoot, overridePath string, now time.Time, se // from the session's adjacent settings file. Droid // envelopes carry no per-message timestamp (the agent stamps events with // time.Now() at hook time), so every turn falls back to the transcript file's -// modtime — the same fallback the Gemini importer uses. +// modtime — the same fallback the Cursor importer uses. func (factoryImporter) SplitTurns(sf SessionFile, full []byte) ([]Turn, error) { subagentsDir := paths.SubagentsDir(filepath.Dir(sf.Path), sf.SessionID) model := factoryaidroid.ExtractModelFromTranscript(context.Background(), sf.Path) diff --git a/cmd/entire/cli/agentimport/gemini.go b/cmd/entire/cli/agentimport/gemini.go deleted file mode 100644 index 4900250a31..0000000000 --- a/cmd/entire/cli/agentimport/gemini.go +++ /dev/null @@ -1,72 +0,0 @@ -package agentimport - -import ( - "fmt" - "os" - "time" - - "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" - "github.com/entireio/cli/cmd/entire/cli/agent/types" -) - -// geminiImporter imports Gemini CLI transcripts -// (~/.gemini/tmp//chats/session-*.json). Unlike the JSONL agents, -// a Gemini transcript is a single JSON document whose offsets are message -// indices, not line numbers, so import is per-session: one checkpoint covering -// the whole transcript rather than per-turn. -type geminiImporter struct{} - -func (geminiImporter) Name() string { return string(agent.AgentNameGemini) } - -func (geminiImporter) AgentType() types.AgentType { return agent.AgentTypeGemini } - -// Discover returns Gemini transcript files for the repo modified within the -// lookback window. The session ID is the file stem (session--). -func (geminiImporter) Discover(repoRoot, overridePath string, now time.Time, sessionFilter []string) ([]SessionFile, error) { - dir, err := resolveDir(repoRoot, overridePath, "gemini", (&geminicli.GeminiCLIAgent{}).GetSessionDir) - if err != nil { - return nil, err - } - return discoverSessionFiles(dir, now, sessionFilter, jsonlSessionResolver(".json", identitySessionID)) -} - -// SplitTurns returns a single Turn covering the whole session. Offsets are -// message indices (the native Gemini space): LineStart 0, LineEnd the message -// count. Token usage is the whole-session total and the prompt is the first -// user message. The turn UUID is the session ID so re-imports stay idempotent. -func (geminiImporter) SplitTurns(sf SessionFile, full []byte) ([]Turn, error) { - tr, err := geminicli.ParseTranscript(full) - if err != nil { - return nil, fmt.Errorf("parse gemini transcript: %w", err) - } - if len(tr.Messages) == 0 { - return nil, nil - } - - ag := &geminicli.GeminiCLIAgent{} - tokens, err := ag.CalculateTokenUsage(full, 0) - if err != nil { - return nil, fmt.Errorf("token usage: %w", err) - } - - prompt := "" - if prompts := geminicli.ExtractAllUserPromptsFromTranscript(tr); len(prompts) > 0 { - prompt = prompts[0] - } - // Gemini messages carry no per-message timestamp; the file modtime is the - // best available session time. - var createdAt time.Time - if info, statErr := os.Stat(sf.Path); statErr == nil { - createdAt = info.ModTime() - } - - return []Turn{{ - LineStart: 0, - LineEnd: len(tr.Messages), - UUID: sf.SessionID, - Prompt: prompt, - CreatedAt: createdAt, - Tokens: tokens, - }}, nil -} diff --git a/cmd/entire/cli/agentimport/gemini_test.go b/cmd/entire/cli/agentimport/gemini_test.go deleted file mode 100644 index d3dda79c17..0000000000 --- a/cmd/entire/cli/agentimport/gemini_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package agentimport - -import ( - "os" - "path/filepath" - "testing" - "time" -) - -func TestGeminiDiscover_LookbackAndFilter(t *testing.T) { - t.Parallel() - dir := t.TempDir() - now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC) - writeAged := func(name string, age time.Duration) { - p := filepath.Join(dir, name) - if err := os.WriteFile(p, []byte(`{"messages":[]}`), 0o644); err != nil { - t.Fatal(err) - } - mt := now.Add(-age) - if err := os.Chtimes(p, mt, mt); err != nil { - t.Fatal(err) - } - } - writeAged("session-2026-06-20-recent01.json", 5*24*time.Hour) - writeAged("session-2026-04-01-old00001.json", 60*24*time.Hour) - writeAged("notes.txt", 1*time.Hour) - - got, err := geminiImporter{}.Discover("", dir, now, nil) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 || got[0].SessionID != "session-2026-06-20-recent01" { - t.Fatalf("lookback/extension filter wrong: %v", got) - } - - got, err = geminiImporter{}.Discover("", dir, now, []string{"session-2026-06-20-recent01"}) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 { - t.Fatalf("session filter wrong: %v", got) - } -} - -// TestGeminiSplitTurns_OneCheckpointPerSession verifies Gemini imports at -// session granularity: a single Turn covering the whole (message-indexed) -// transcript, with whole-session tokens and the first user prompt. -func TestGeminiSplitTurns_OneCheckpointPerSession(t *testing.T) { - t.Parallel() - dir := t.TempDir() - p := filepath.Join(dir, "session-x.json") - full := []byte(`{"messages":[` + - `{"type":"user","id":"u1","content":[{"text":"first"}]},` + - `{"type":"gemini","id":"g1","content":"ok","tokens":{"input":10,"output":5}},` + - `{"type":"user","id":"u2","content":[{"text":"second"}]},` + - `{"type":"gemini","id":"g2","content":"done","tokens":{"input":20,"output":7}}` + - `]}`) - if err := os.WriteFile(p, full, 0o644); err != nil { - t.Fatal(err) - } - - turns, err := geminiImporter{}.SplitTurns(SessionFile{Path: p, SessionID: "session-x"}, full) - if err != nil { - t.Fatal(err) - } - if len(turns) != 1 { - t.Fatalf("gemini imports per-session; want 1 turn, got %d", len(turns)) - } - turn := turns[0] - if turn.LineStart != 0 || turn.LineEnd != 4 { - t.Errorf("turn bounds = [%d,%d), want [0,4) in message-index space", turn.LineStart, turn.LineEnd) - } - if turn.UUID != "session-x" { - t.Errorf("per-session turn uuid = %q, want session-x (stable/idempotent)", turn.UUID) - } - if turn.Prompt != "first" { - t.Errorf("prompt = %q, want the first user prompt", turn.Prompt) - } - // Whole-session totals: 5 + 7 output, 10 + 20 input. - if turn.Tokens == nil || turn.Tokens.OutputTokens != 12 || turn.Tokens.InputTokens != 30 { - t.Errorf("session tokens wrong: %+v", turn.Tokens) - } -} - -func TestGeminiSplitTurns_EmptyTranscriptNoTurns(t *testing.T) { - t.Parallel() - turns, err := geminiImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "s.json"), SessionID: "s"}, []byte(`{"messages":[]}`)) - if err != nil { - t.Fatal(err) - } - if len(turns) != 0 { - t.Fatalf("empty transcript should yield no turns, got %d", len(turns)) - } -} diff --git a/cmd/entire/cli/agentimport/global_test.go b/cmd/entire/cli/agentimport/global_test.go new file mode 100644 index 0000000000..42d0cac661 --- /dev/null +++ b/cmd/entire/cli/agentimport/global_test.go @@ -0,0 +1,27 @@ +package agentimport + +import ( + "fmt" + "os" + "testing" + + "github.com/go-git/go-git/v6/x/plugin" + "github.com/go-git/go-git/v6/x/plugin/config" +) + +func TestMain(m *testing.M) { + // Replace go-git's default Auto ConfigLoader, which reads the developer's + // ~/.gitconfig, with empty global and system configs. Without this every + // go-git write in this package inherits whatever the host has set: a + // developer with commit.gpgSign / tag.gpgSign fails each of them with + // "cannot auto-sign … or register an ObjectSigner plugin", because no + // signer plugin is registered here. Mirrors the checkpoint, strategy and + // cli TestMains. + if err := plugin.Register(plugin.ConfigLoader(), func() plugin.ConfigSource { + return config.NewEmpty() + }); err != nil { + panic(fmt.Errorf("failed to register config storers: %w", err)) + } + + os.Exit(m.Run()) +} diff --git a/cmd/entire/cli/agentimport/linesplit.go b/cmd/entire/cli/agentimport/linesplit.go index 78365f2c63..ba85088c21 100644 --- a/cmd/entire/cli/agentimport/linesplit.go +++ b/cmd/entire/cli/agentimport/linesplit.go @@ -25,9 +25,6 @@ func parseTimestamp(s string) time.Time { // (truncating the end bounds the turn while keeping the file's beginning, which // branch-aware agents like Pi need). build may return a nil Turn to skip a // start defensively (e.g. a line that unexpectedly fails to parse). -// -// Gemini imports per-session and does not use this — its transcript is a single -// JSON document, not newline-delimited records. func splitLineTurns( rawLines [][]byte, isPrompt func(raw []byte) bool, diff --git a/cmd/entire/cli/api/client.go b/cmd/entire/cli/api/client.go index 00bd4dd0b1..8d3f1a8e6a 100644 --- a/cmd/entire/cli/api/client.go +++ b/cmd/entire/cli/api/client.go @@ -280,11 +280,33 @@ func DecodeJSON(resp *http.Response, dest any) error { // ErrorResponse represents a standard API error response. Older endpoints // return {"error":"message"}; newer endpoints return -// {"error":{"code":"...","message":"...",...}}; entire-api cells proxied -// through the gateway return huma's {"title":..,"status":..,"detail":"message"}. +// {"error":{"code":"...","message":"...",...}}; entire-api cells return RFC +// 9457 problem details (application/problem+json), whose human-readable text +// is detail with title as the coarser fallback. RequestID is a problem-details +// extension entire-api sets on every error; it is what support needs to find +// the server-side trace, so it is carried through to HTTPError. type ErrorResponse struct { - Error any `json:"error"` - Detail string `json:"detail"` + Error any `json:"error"` + Detail string `json:"detail"` + Title string `json:"title"` + Code string `json:"code"` + RequestID string `json:"request_id"` +} + +// ErrorCode extracts the stable machine-readable code, if the server sent one. +// entire-api's contract (docs/api-errors.md there) is to branch on code, never +// on message text: problem details and compact cell errors carry it top-level; +// the legacy nested envelope carries it as error.code. +func (e ErrorResponse) ErrorCode() string { + if code := strings.TrimSpace(e.Code); code != "" { + return code + } + if v, ok := e.Error.(map[string]any); ok { + if code, ok := v["code"].(string); ok { + return strings.TrimSpace(code) + } + } + return "" } // Message extracts the human-readable error message from any envelope shape. @@ -302,7 +324,10 @@ func (e ErrorResponse) Message() string { return strings.TrimSpace(code) } } - return strings.TrimSpace(e.Detail) + if detail := strings.TrimSpace(e.Detail); detail != "" { + return detail + } + return strings.TrimSpace(e.Title) } // HTTPError is returned by CheckResponse for non-2xx responses. Callers can use @@ -310,13 +335,25 @@ func (e ErrorResponse) Message() string { type HTTPError struct { StatusCode int Message string + // Code is the server's stable error code (e.g. rate_limited, conflict, + // wrong_cell) when it sent one. Branch on it, not on Message, which is + // human-readable prose the server may reword. + Code string + // RequestID is the RFC 9457 request_id extension when the server sent one. + RequestID string } func (e *HTTPError) Error() string { + var out string if e.Message != "" { - return fmt.Sprintf("API error: %s (status %d)", e.Message, e.StatusCode) + out = fmt.Sprintf("API error: %s (status %d)", e.Message, e.StatusCode) + } else { + out = fmt.Sprintf("API error: status %d", e.StatusCode) + } + if e.RequestID != "" { + out += fmt.Sprintf(" [request %s]", e.RequestID) } - return fmt.Sprintf("API error: status %d", e.StatusCode) + return out } // IsHTTPErrorStatus reports whether err wraps an *HTTPError with the given HTTP status. @@ -342,6 +379,8 @@ func CheckResponse(resp *http.Response) error { var parsed ErrorResponse if err := json.Unmarshal(body, &parsed); err == nil { + apiError.RequestID = strings.TrimSpace(parsed.RequestID) + apiError.Code = parsed.ErrorCode() if message := parsed.Message(); message != "" { apiError.Message = message return apiError diff --git a/cmd/entire/cli/api/client_test.go b/cmd/entire/cli/api/client_test.go index b646bdad77..484b66e659 100644 --- a/cmd/entire/cli/api/client_test.go +++ b/cmd/entire/cli/api/client_test.go @@ -315,6 +315,47 @@ func TestCheckResponse_ErrorWithObjectEnvelope(t *testing.T) { } } +func TestCheckResponse_CarriesStableCode(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + body string + want string + }{ + {"problem details", `{"type":"https://entire.io/errors/rate_limited","title":"Too Many Requests","status":429,"detail":"slow down","code":"rate_limited","request_id":"r1"}`, "rate_limited"}, + {"compact cell error with code", `{"code":"wrong_cell","error":"not the primary"}`, "wrong_cell"}, + {"legacy nested envelope", `{"error":{"code":"not_found","message":"session not found"}}`, "not_found"}, + {"no code", `{"error":"insufficient permissions"}`, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", jsonContentType) + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte(tc.body)) //nolint:errcheck // test handler + })) + defer server.Close() + + resp, err := http.Get(server.URL) //nolint:noctx // test helper + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + err = CheckResponse(resp) + var httpErr *HTTPError + if !errors.As(err, &httpErr) { + t.Fatalf("CheckResponse = %v, want *HTTPError", err) + } + if httpErr.Code != tc.want { + t.Errorf("Code = %q, want %q", httpErr.Code, tc.want) + } + }) + } +} + func TestCheckResponse_ErrorWithPlainText(t *testing.T) { t.Parallel() @@ -519,12 +560,13 @@ func TestClient_Request_RespectsCallerContentType(t *testing.T) { } } -func TestCheckResponse_ErrorWithHumaDetail(t *testing.T) { +func TestCheckResponse_ErrorWithProblemDetail(t *testing.T) { t.Parallel() resp := &http.Response{ StatusCode: http.StatusNotFound, - Body: io.NopCloser(strings.NewReader(`{"title":"Not Found","status":404,"detail":"repository not found: a/b"}`)), + Header: http.Header{"Content-Type": {"application/problem+json"}}, + Body: io.NopCloser(strings.NewReader(`{"type":"https://example.test/problems/not_found","title":"Not Found","status":404,"detail":"repository not found: a/b","code":"not_found","request_id":"request-example"}`)), } err := CheckResponse(resp) var httpErr *HTTPError @@ -532,6 +574,35 @@ func TestCheckResponse_ErrorWithHumaDetail(t *testing.T) { t.Fatalf("expected *HTTPError, got %T", err) } if httpErr.Message != "repository not found: a/b" { - t.Fatalf("expected huma detail as message, got %q", httpErr.Message) + t.Fatalf("expected problem detail as message, got %q", httpErr.Message) + } + if httpErr.RequestID != "request-example" { + t.Fatalf("request id = %q, want request-example", httpErr.RequestID) + } + if want := "API error: repository not found: a/b (status 404) [request request-example]"; httpErr.Error() != want { + t.Fatalf("error = %q, want %q", httpErr.Error(), want) + } +} + +// A problem body may carry only the coarse title; it is still better for the +// user than echoing raw JSON, and request_id must survive that path too. +func TestCheckResponse_ProblemDetailTitleOnly(t *testing.T) { + t.Parallel() + + resp := &http.Response{ + StatusCode: http.StatusForbidden, + Header: http.Header{"Content-Type": {"application/problem+json"}}, + Body: io.NopCloser(strings.NewReader(`{"type":"https://example.test/problems/forbidden","title":"Forbidden","status":403,"request_id":"req-42"}`)), + } + err := CheckResponse(resp) + var httpErr *HTTPError + if !errors.As(err, &httpErr) { + t.Fatalf("expected *HTTPError, got %T", err) + } + if httpErr.Message != "Forbidden" { + t.Fatalf("message = %q, want Forbidden", httpErr.Message) + } + if httpErr.RequestID != "req-42" { + t.Fatalf("request id = %q, want req-42", httpErr.RequestID) } } diff --git a/cmd/entire/cli/api/trail_discussion_types.go b/cmd/entire/cli/api/trail_discussion_types.go new file mode 100644 index 0000000000..7e82819a66 --- /dev/null +++ b/cmd/entire/cli/api/trail_discussion_types.go @@ -0,0 +1,100 @@ +package api + +import "time" + +// Trail discussion wire types. A discussion has messages; each message may +// carry a single level of replies. Identity fields differ by source: Author, +// LastMessageAuthor, and Participants[].Login are GitHub logins, while +// CreatedBy and ResolvedBy are actor UUIDs (the server maps them differently). + +// TrailDiscussionReply is a reply on a discussion message. Replies do not nest further. +type TrailDiscussionReply struct { + ID string `json:"id"` + Author string `json:"author"` // GitHub login + CreatedAt time.Time `json:"created_at"` + Body string `json:"body"` +} + +// TrailDiscussionMessage is a top-level message in a discussion. +type TrailDiscussionMessage struct { + ID string `json:"id"` + Author string `json:"author"` // GitHub login + CreatedAt time.Time `json:"created_at"` + Body string `json:"body"` + Replies []TrailDiscussionReply `json:"replies"` +} + +// TrailDiscussionParticipant identifies a discussion participant by login. +type TrailDiscussionParticipant struct { + Login string `json:"login"` +} + +// TrailDiscussionSummary is a discussion's metadata. The server's review_comment blob +// (present only for kind=="code_review") is intentionally not decoded here: +// code-review discussions are surfaced through `trail finding`. +type TrailDiscussionSummary struct { + ID string `json:"id"` + TrailID string `json:"trail_id"` + Kind string `json:"kind"` // "discussion" | "code_review" + Title string `json:"title"` + ReviewCommentID *string `json:"review_comment_id"` + Resolved bool `json:"resolved"` + ResolvedBy *string `json:"resolved_by"` // actor UUID + ResolvedAt *time.Time `json:"resolved_at"` + CreatedBy *string `json:"created_by"` // actor UUID + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastMessageAt *time.Time `json:"last_message_at"` + LastMessageAuthor *string `json:"last_message_author"` // GitHub login + MessageCount int `json:"message_count"` + Participants []TrailDiscussionParticipant `json:"participants"` +} + +// TrailDiscussionsResponse is the response from GET .../:number/discussions. +type TrailDiscussionsResponse struct { + Items []TrailDiscussionSummary `json:"items"` + NextCursor *string `json:"next_cursor,omitempty"` + EventCursor string `json:"event_cursor"` +} + +// TrailDiscussionDetailResponse is the response from GET .../:number/discussions/:id. +type TrailDiscussionDetailResponse struct { + Discussion TrailDiscussionSummary `json:"discussion"` + Messages []TrailDiscussionMessage `json:"messages"` + EventCursor string `json:"event_cursor"` +} + +// TrailDiscussionCreateRequest is the body for POST .../:number/discussions. +// Body is required; Title is optional (server defaults it to "Conversation"). +type TrailDiscussionCreateRequest struct { + Title string `json:"title,omitempty"` + Body string `json:"body"` +} + +// TrailDiscussionCreateResponse is the response from POST .../:number/discussions. +type TrailDiscussionCreateResponse struct { + Discussion TrailDiscussionSummary `json:"discussion"` + Message *TrailDiscussionMessage `json:"message"` +} + +// TrailDiscussionUpdateRequest is the body for PATCH .../:number/discussions/:id. +// Pointer fields distinguish "not provided" from an explicit value. +type TrailDiscussionUpdateRequest struct { + Title *string `json:"title,omitempty"` + Resolved *bool `json:"resolved,omitempty"` +} + +// TrailDiscussionUpdateResponse is the response from PATCH .../:number/discussions/:id. +type TrailDiscussionUpdateResponse struct { + Discussion TrailDiscussionSummary `json:"discussion"` +} + +// TrailDiscussionMessageRequest is the body for POST/PATCH message endpoints. +type TrailDiscussionMessageRequest struct { + Body string `json:"body"` +} + +// TrailDiscussionMessageResponse is the response from the message endpoints. +type TrailDiscussionMessageResponse struct { + Message TrailDiscussionMessage `json:"message"` +} diff --git a/cmd/entire/cli/api/trail_discussion_types_test.go b/cmd/entire/cli/api/trail_discussion_types_test.go new file mode 100644 index 0000000000..43bf2d2adc --- /dev/null +++ b/cmd/entire/cli/api/trail_discussion_types_test.go @@ -0,0 +1,72 @@ +package api + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +const discussionTestLogin = "alice" + +func TestTrailDiscussionDetailDecodes(t *testing.T) { + t.Parallel() + payload := []byte(`{ + "discussion": { + "id": "th1", "trail_id": "tr1", "kind": "discussion", "title": "Design", + "review_comment_id": null, "resolved": false, + "resolved_by": null, "resolved_at": null, + "created_by": "actor-uuid", "created_at": "2026-07-10T00:00:00Z", + "updated_at": "2026-07-10T00:01:00Z", + "last_message_at": "2026-07-10T00:01:00Z", "last_message_author": "alice", + "message_count": 2, "participants": [{"login":"alice"},{"login":"bob"}] + }, + "messages": [ + {"id":"m1","author":"alice","created_at":"2026-07-10T00:00:00Z","body":"hi", + "replies":[{"id":"r1","author":"bob","created_at":"2026-07-10T00:00:30Z","body":"yo"}]} + ], + "event_cursor": "42" + }`) + var out TrailDiscussionDetailResponse + if err := json.Unmarshal(payload, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.EventCursor != "42" { + t.Errorf("EventCursor = %q, want 42", out.EventCursor) + } + if out.Discussion.CreatedBy == nil || *out.Discussion.CreatedBy != "actor-uuid" { + t.Errorf("CreatedBy = %v, want actor-uuid", out.Discussion.CreatedBy) + } + if out.Discussion.ResolvedBy != nil { + t.Errorf("ResolvedBy = %v, want nil", out.Discussion.ResolvedBy) + } + if out.Discussion.LastMessageAuthor == nil || *out.Discussion.LastMessageAuthor != discussionTestLogin { + t.Errorf("LastMessageAuthor = %v, want alice", out.Discussion.LastMessageAuthor) + } + if len(out.Discussion.Participants) != 2 || out.Discussion.Participants[0].Login != discussionTestLogin { + t.Errorf("Participants = %#v", out.Discussion.Participants) + } + if len(out.Messages) != 1 || out.Messages[0].Author != discussionTestLogin { + t.Fatalf("Messages = %#v", out.Messages) + } + if len(out.Messages[0].Replies) != 1 || out.Messages[0].Replies[0].Author != "bob" { + t.Errorf("Replies = %#v", out.Messages[0].Replies) + } +} + +func TestTrailDiscussionWriteResponsesDecode(t *testing.T) { + t.Parallel() + var created TrailDiscussionCreateResponse + require.NoError(t, json.Unmarshal([]byte(`{"discussion":{"id":"th1","trail_id":"tr1","kind":"discussion","title":"Thread safety"},"message":{"id":"m1","body":"Keep this thread safe"}}`), &created)) + require.Equal(t, "th1", created.Discussion.ID) + require.Equal(t, "Thread safety", created.Discussion.Title) + require.NotNil(t, created.Message) + require.Equal(t, "Keep this thread safe", created.Message.Body) + var updated TrailDiscussionUpdateResponse + require.NoError(t, json.Unmarshal([]byte(`{"discussion":{"id":"th1","kind":"code_review","resolved":true,"review_comment_id":"c1","resolved_by":"actor-example"}}`), &updated)) + require.Equal(t, "th1", updated.Discussion.ID) + require.Equal(t, "code_review", updated.Discussion.Kind) + require.True(t, updated.Discussion.Resolved) + require.Equal(t, "c1", *updated.Discussion.ReviewCommentID) + require.Equal(t, "actor-example", *updated.Discussion.ResolvedBy) +} diff --git a/cmd/entire/cli/api/trail_review_types.go b/cmd/entire/cli/api/trail_review_types.go index 76e9149427..8302f13c23 100644 --- a/cmd/entire/cli/api/trail_review_types.go +++ b/cmd/entire/cli/api/trail_review_types.go @@ -5,33 +5,33 @@ import "time" // TrailReviewStateResponse is returned by GET /api/v1/trails/{trail_id}/reviews/{id}. type TrailReviewStateResponse struct { Review TrailReview `json:"review"` - CodeVersion TrailReviewCodeVersion `json:"codeVersion"` + CodeVersion TrailReviewCodeVersion `json:"code_version"` Counts TrailReviewCounts `json:"counts"` Comments []TrailReviewComment `json:"comments"` - NextCursor *string `json:"nextCursor"` - EventCursor string `json:"eventCursor"` + NextCursor *string `json:"next_cursor"` + EventCursor string `json:"event_cursor"` } // TrailReview represents a review session. type TrailReview struct { ID string `json:"id"` - TrailID string `json:"trailId"` - CodeVersionID string `json:"codeVersionId"` - ActorID string `json:"actorId"` + TrailID string `json:"trail_id"` + CodeVersionID string `json:"code_version_id"` + ActorID string `json:"actor_id"` Summary *string `json:"summary"` - StartedAt time.Time `json:"startedAt"` + StartedAt time.Time `json:"started_at"` } // TrailReviewCodeVersion pins the base/head that a review covers. type TrailReviewCodeVersion struct { ID string `json:"id"` - TrailID string `json:"trailId"` - RepositoryID string `json:"repositoryId"` - BaseRef *string `json:"baseRef"` - HeadRef *string `json:"headRef"` - BaseSHA *string `json:"baseSha"` - HeadSHA *string `json:"headSha"` - CapturedAt time.Time `json:"capturedAt"` + TrailID string `json:"trail_id"` + RepositoryID string `json:"repo_id"` + BaseRef *string `json:"base_ref"` + HeadRef *string `json:"head_ref"` + BaseSHA *string `json:"base_sha"` + HeadSHA *string `json:"head_sha"` + CapturedAt time.Time `json:"captured_at"` } // TrailReviewCounts are review-scoped comment counts. @@ -46,66 +46,65 @@ type TrailReviewCounts struct { // TrailReviewCommentsResponse is returned by trail/review comment list endpoints. type TrailReviewCommentsResponse struct { Comments []TrailReviewComment `json:"comments"` - HasMore bool `json:"hasMore"` - NextOffset *int `json:"nextOffset"` - EventCursor string `json:"eventCursor,omitempty"` + NextCursor *string `json:"next_cursor,omitempty"` + EventCursor string `json:"event_cursor,omitempty"` } // TrailReviewComment is a single agent-native review finding. type TrailReviewComment struct { ID string `json:"id"` - TrailID string `json:"trailId"` - RepositoryID string `json:"repositoryId"` - ReviewID string `json:"reviewId"` - CodeVersionID string `json:"codeVersionId"` - ActorID string `json:"actorId"` + TrailID string `json:"trail_id"` + RepositoryID string `json:"repo_id"` + ReviewID string `json:"review_id"` + CodeVersionID string `json:"code_version_id"` + ActorID string `json:"actor_id"` Title *string `json:"title"` Body *string `json:"body"` Severity *string `json:"severity"` Confidence *float64 `json:"confidence"` Status string `json:"status"` - StatusReason *string `json:"statusReason"` - StaleOutcome string `json:"staleOutcome"` - StaleCheckedAt *time.Time `json:"staleCheckedAt"` - StaleCheckedCodeVersionID *string `json:"staleCheckedCodeVersionId"` - ClientID *string `json:"clientId"` - ClientIDHash *string `json:"clientIdHash"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + StatusReason *string `json:"status_reason"` + StaleOutcome string `json:"stale_outcome"` + StaleCheckedAt *time.Time `json:"stale_checked_at"` + StaleCheckedCodeVersionID *string `json:"stale_checked_code_version_id"` + ClientID *string `json:"client_id"` + ClientIDHash *string `json:"client_id_hash"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` Location TrailReviewLocation `json:"location"` - SuggestedChanges []TrailReviewSuggestedChange `json:"suggestedChanges,omitempty"` - ThreadID *string `json:"threadId,omitempty"` - ThreadMessageCount int `json:"threadMessageCount,omitempty"` - OutgoingLinks []TrailReviewOutgoingLink `json:"outgoingLinks,omitempty"` + SuggestedChanges []TrailReviewSuggestedChange `json:"suggested_changes,omitempty"` + DiscussionID *string `json:"discussion_id,omitempty"` + DiscussionMessageCount int `json:"discussion_message_count,omitempty"` + OutgoingLinks []TrailReviewOutgoingLink `json:"outgoing_links,omitempty"` } // TrailReviewStartRequest starts a review session for a trail via // POST /api/v1/trails/{trail_id}/reviews. All fields are optional; the server // resolves the code version (base/head) when they are omitted. type TrailReviewStartRequest struct { - HeadSHA *string `json:"headSha,omitempty"` - BaseSHA *string `json:"baseSha,omitempty"` - BaseRef *string `json:"baseRef,omitempty"` - HeadRef *string `json:"headRef,omitempty"` + HeadSHA *string `json:"head_sha,omitempty"` + BaseSHA *string `json:"base_sha,omitempty"` + BaseRef *string `json:"base_ref,omitempty"` + HeadRef *string `json:"head_ref,omitempty"` } // TrailReviewStartResponse is returned by POST /api/v1/trails/{trail_id}/reviews. type TrailReviewStartResponse struct { - ReviewID string `json:"reviewId"` - TrailID string `json:"trailId"` - RepositoryID string `json:"repositoryId"` - CodeVersionID string `json:"codeVersionId"` - BaseSHA *string `json:"baseSha"` - HeadSHA *string `json:"headSha"` - EventStreamURL string `json:"eventStreamUrl"` - DiffURL string `json:"diffUrl"` - FilesURL string `json:"filesUrl"` + ReviewID string `json:"review_id"` + TrailID string `json:"trail_id"` + RepositoryID string `json:"repo_id"` + CodeVersionID string `json:"code_version_id"` + BaseSHA *string `json:"base_sha"` + HeadSHA *string `json:"head_sha"` + EventStreamURL string `json:"event_stream_url"` + DiffURL string `json:"diff_url"` + FilesURL string `json:"files_url"` Limits TrailReviewLimits `json:"limits"` } // TrailReviewLimits carries the server-enforced batch limits for a review. type TrailReviewLimits struct { - MaxCommentsPerBatch int `json:"maxCommentsPerBatch"` + MaxCommentsPerBatch int `json:"max_comments_per_batch"` } // TrailReviewCommentBatchRequest posts a batch of findings to a review via @@ -119,14 +118,14 @@ type TrailReviewCommentBatchRequest struct { // TrailReviewCommentInput is a single finding within a batch create request. // client_id (an idempotency key) and location are required by the API. type TrailReviewCommentInput struct { - ClientID string `json:"clientId"` + ClientID string `json:"client_id"` Body *string `json:"body,omitempty"` Severity *string `json:"severity,omitempty"` Confidence *float64 `json:"confidence,omitempty"` Status *string `json:"status,omitempty"` - StatusReason *string `json:"statusReason,omitempty"` + StatusReason *string `json:"status_reason,omitempty"` Location TrailReviewLocationCreateRequest `json:"location"` - SuggestedChange *TrailReviewSuggestedChangeCreateRequest `json:"suggestedChange,omitempty"` + SuggestedChange *TrailReviewSuggestedChangeCreateRequest `json:"suggested_change,omitempty"` } // TrailReviewCommentBatchResponse is returned by the batch comment endpoint. @@ -138,10 +137,10 @@ type TrailReviewCommentBatchResponse struct { // Status is one of "created", "existing", or "error"; Comment is populated for // the first two, Error for the last. type TrailReviewCommentBatchResult struct { - ClientID string `json:"clientId"` + ClientID string `json:"client_id"` Status string `json:"status"` Comment *TrailReviewComment `json:"comment,omitempty"` - SuggestedChange *TrailReviewSuggestedChange `json:"suggestedChange,omitempty"` + SuggestedChange *TrailReviewSuggestedChange `json:"suggested_change,omitempty"` Error *TrailReviewCommentBatchError `json:"error,omitempty"` } @@ -156,13 +155,13 @@ type TrailReviewCommentBatchError struct { // TrailReviewLocationCreateRequest identifies where a new finding applies. type TrailReviewLocationCreateRequest struct { Granularity string `json:"granularity"` - FilePath *string `json:"filePath,omitempty"` - StartLine *int `json:"startLine,omitempty"` - StartColumn *int `json:"startColumn,omitempty"` - EndLine *int `json:"endLine,omitempty"` - EndColumn *int `json:"endColumn,omitempty"` - SelectedText *string `json:"selectedText,omitempty"` - NearbyText *string `json:"nearbyText,omitempty"` + FilePath *string `json:"file_path,omitempty"` + StartLine *int `json:"start_line,omitempty"` + StartColumn *int `json:"start_column,omitempty"` + EndLine *int `json:"end_line,omitempty"` + EndColumn *int `json:"end_column,omitempty"` + SelectedText *string `json:"selected_text,omitempty"` + NearbyText *string `json:"nearby_text,omitempty"` Language *string `json:"language,omitempty"` } @@ -180,55 +179,55 @@ type TrailReviewLocationCreateRequest struct { // - ExpectedLines is the byte-exact content of ExpectedStartLine..ExpectedEndLine // with line endings intact — not CRLF-normalized display text. type TrailReviewSuggestedChangeCreateRequest struct { - ChangeType string `json:"changeType"` + ChangeType string `json:"change_type"` Patch *string `json:"patch,omitempty"` Instruction *string `json:"instruction,omitempty"` - ExpectedFilePath *string `json:"expectedFilePath,omitempty"` - ExpectedFileHash *string `json:"expectedFileHash,omitempty"` - ExpectedStartLine *int `json:"expectedStartLine,omitempty"` - ExpectedEndLine *int `json:"expectedEndLine,omitempty"` - ExpectedLines *string `json:"expectedLines,omitempty"` + ExpectedFilePath *string `json:"expected_file_path,omitempty"` + ExpectedFileHash *string `json:"expected_file_hash,omitempty"` + ExpectedStartLine *int `json:"expected_start_line,omitempty"` + ExpectedEndLine *int `json:"expected_end_line,omitempty"` + ExpectedLines *string `json:"expected_lines,omitempty"` } // TrailReviewLocation identifies where a finding applies. type TrailReviewLocation struct { ID string `json:"id"` - ReviewCommentID string `json:"reviewCommentId"` - CodeVersionID string `json:"codeVersionId"` + ReviewCommentID string `json:"review_comment_id"` + CodeVersionID string `json:"code_version_id"` Granularity string `json:"granularity"` - FilePath *string `json:"filePath"` - StartLine *int `json:"startLine"` - StartColumn *int `json:"startColumn"` - EndLine *int `json:"endLine"` - EndColumn *int `json:"endColumn"` - SelectedText *string `json:"selectedText"` - NearbyText *string `json:"nearbyText"` + FilePath *string `json:"file_path"` + StartLine *int `json:"start_line"` + StartColumn *int `json:"start_column"` + EndLine *int `json:"end_line"` + EndColumn *int `json:"end_column"` + SelectedText *string `json:"selected_text"` + NearbyText *string `json:"nearby_text"` Language *string `json:"language"` } // TrailReviewSuggestedChange describes a machine-applicable or manual fix. type TrailReviewSuggestedChange struct { ID string `json:"id"` - ReviewCommentID string `json:"reviewCommentId"` - CodeVersionID string `json:"codeVersionId"` - ChangeType string `json:"changeType"` + ReviewCommentID string `json:"review_comment_id"` + CodeVersionID string `json:"code_version_id"` + ChangeType string `json:"change_type"` Patch *string `json:"patch"` Instruction *string `json:"instruction"` - ExpectedFilePath *string `json:"expectedFilePath"` - ExpectedFileHash *string `json:"expectedFileHash"` - ExpectedStartLine *int `json:"expectedStartLine"` - ExpectedEndLine *int `json:"expectedEndLine"` - ExpectedLines *string `json:"expectedLines"` - CreatedBy string `json:"createdBy"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ExpectedFilePath *string `json:"expected_file_path"` + ExpectedFileHash *string `json:"expected_file_hash"` + ExpectedStartLine *int `json:"expected_start_line"` + ExpectedEndLine *int `json:"expected_end_line"` + ExpectedLines *string `json:"expected_lines"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TrailReviewOutgoingLink relates two review comments. type TrailReviewOutgoingLink struct { - SourceCommentID string `json:"sourceCommentId"` - TargetCommentID string `json:"targetCommentId"` - LinkType string `json:"linkType"` + SourceCommentID string `json:"source_comment_id"` + TargetCommentID string `json:"target_comment_id"` + LinkType string `json:"link_type"` } // TrailReviewCommentPatchRequest updates a review finding. @@ -238,5 +237,5 @@ type TrailReviewCommentPatchRequest struct { Severity *string `json:"severity,omitempty"` Confidence *float64 `json:"confidence,omitempty"` Status string `json:"status,omitempty"` - StatusReason *string `json:"statusReason,omitempty"` + StatusReason *string `json:"status_reason,omitempty"` } diff --git a/cmd/entire/cli/api/trail_review_types_test.go b/cmd/entire/cli/api/trail_review_types_test.go new file mode 100644 index 0000000000..3dac58f4cc --- /dev/null +++ b/cmd/entire/cli/api/trail_review_types_test.go @@ -0,0 +1,102 @@ +package api + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReviewSnapshotDecodesSnakeCase(t *testing.T) { + t.Parallel() + const payload = `{ + "review":{"id":"review-example","trail_id":"trail-example","code_version_id":"cv-example","actor_id":"actor-example","started_at":"2026-09-01T00:00:00Z"}, + "code_version":{"id":"cv-example","trail_id":"trail-example","repo_id":"repo_example","base_ref":"main","head_ref":"feature/example","base_sha":"base","head_sha":"head","captured_at":"2026-09-01T00:00:00Z"}, + "counts":{"open":1,"resolved":2,"dismissed":3,"stale":4,"total":6}, + "comments":[{"id":"comment-example","trail_id":"trail-example","repo_id":"repo_example","review_id":"review-example","code_version_id":"cv-example","actor_id":"actor-example","status":"open","status_reason":"Needs work","stale_outcome":"current","stale_checked_at":"2026-09-01T00:01:00Z","stale_checked_code_version_id":"cv-example","client_id":"client-example","client_id_hash":"hash-example","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:01:00Z", + "location":{"id":"location-example","review_comment_id":"comment-example","code_version_id":"cv-example","granularity":"range","file_path":"example.go","start_line":2,"start_column":3,"end_line":4,"end_column":5,"selected_text":"old","nearby_text":"context"}, + "suggested_changes":[{"id":"change-example","review_comment_id":"comment-example","code_version_id":"cv-example","change_type":"unified_diff","patch":"patch","expected_file_path":"example.go","expected_file_hash":"blob","expected_start_line":2,"expected_end_line":4,"expected_lines":"old","created_by":"actor-example","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:01:00Z"}], + "discussion_id":"thread-example","discussion_message_count":2,"outgoing_links":[{"source_comment_id":"comment-example","target_comment_id":"other-example","link_type":"related"}]}], + "next_cursor":"opaque-example","event_cursor":"42" + }` + var got TrailReviewStateResponse + require.NoError(t, json.Unmarshal([]byte(payload), &got)) + require.Equal(t, "trail-example", got.Review.TrailID) + require.Equal(t, "actor-example", got.Review.ActorID) + require.False(t, got.Review.StartedAt.IsZero()) + require.Equal(t, "repo_example", got.CodeVersion.RepositoryID) + require.Equal(t, "head", *got.CodeVersion.HeadSHA) + require.Equal(t, "main", *got.CodeVersion.BaseRef) + require.False(t, got.CodeVersion.CapturedAt.IsZero()) + require.Equal(t, 6, got.Counts.Total) + require.Equal(t, "opaque-example", *got.NextCursor) + require.Equal(t, "42", got.EventCursor) + require.Len(t, got.Comments, 1) + comment := got.Comments[0] + require.Equal(t, "repo_example", comment.RepositoryID) + require.Equal(t, "review-example", comment.ReviewID) + require.Equal(t, "Needs work", *comment.StatusReason) + require.Equal(t, "current", comment.StaleOutcome) + require.NotNil(t, comment.StaleCheckedAt) + require.Equal(t, "cv-example", *comment.StaleCheckedCodeVersionID) + require.Equal(t, "client-example", *comment.ClientID) + require.Equal(t, "hash-example", *comment.ClientIDHash) + require.False(t, comment.CreatedAt.IsZero()) + require.False(t, comment.UpdatedAt.IsZero()) + require.Equal(t, "example.go", *comment.Location.FilePath) + require.Equal(t, 2, *comment.Location.StartLine) + require.Equal(t, 5, *comment.Location.EndColumn) + require.Equal(t, "context", *comment.Location.NearbyText) + require.Len(t, comment.SuggestedChanges, 1) + change := comment.SuggestedChanges[0] + require.Equal(t, "unified_diff", change.ChangeType) + require.Equal(t, "blob", *change.ExpectedFileHash) + require.Equal(t, 4, *change.ExpectedEndLine) + require.Equal(t, "actor-example", change.CreatedBy) + require.False(t, change.CreatedAt.IsZero()) + require.NotNil(t, comment.DiscussionID) + require.Equal(t, "thread-example", *comment.DiscussionID) + require.Equal(t, 2, comment.DiscussionMessageCount) + require.Len(t, comment.OutgoingLinks, 1) + require.Equal(t, "other-example", comment.OutgoingLinks[0].TargetCommentID) +} + +func TestReviewWritesUseSnakeCase(t *testing.T) { + t.Parallel() + head, base, file, text, reason := "head", "base", "example.go", "old", "fixed" + line := 2 + for _, tc := range []struct { + name string + body any + want string + }{ + {"start", TrailReviewStartRequest{HeadSHA: &head, BaseSHA: &base, HeadRef: &head, BaseRef: &base}, `{"head_sha":"head","base_sha":"base","head_ref":"head","base_ref":"base"}`}, + {"batch", TrailReviewCommentBatchRequest{Comments: []TrailReviewCommentInput{{ClientID: "client-example", StatusReason: &reason, Location: TrailReviewLocationCreateRequest{Granularity: "line", FilePath: &file, StartLine: &line, SelectedText: &text}, SuggestedChange: &TrailReviewSuggestedChangeCreateRequest{ChangeType: "manual_instruction", Instruction: &reason}}}}, `{"comments":[{"client_id":"client-example","status_reason":"fixed","location":{"granularity":"line","file_path":"example.go","start_line":2,"selected_text":"old"},"suggested_change":{"change_type":"manual_instruction","instruction":"fixed"}}]}`}, + {"patch", TrailReviewCommentPatchRequest{Status: "resolved", StatusReason: &reason}, `{"status":"resolved","status_reason":"fixed"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := json.Marshal(tc.body) + require.NoError(t, err) + require.JSONEq(t, tc.want, string(got)) + }) + } +} + +func TestReviewStartAndBatchResponsesDecodeSnakeCase(t *testing.T) { + t.Parallel() + var start TrailReviewStartResponse + require.NoError(t, json.Unmarshal([]byte(`{"review_id":"review-example","trail_id":"trail-example","repo_id":"repo_example","code_version_id":"cv-example","base_sha":"base","head_sha":"head","event_stream_url":"/events","diff_url":"/diff","files_url":"/files","limits":{"max_comments_per_batch":10}}`), &start)) + require.Equal(t, "review-example", start.ReviewID) + require.Equal(t, "repo_example", start.RepositoryID) + require.Equal(t, "/events", start.EventStreamURL) + require.Equal(t, "/diff", start.DiffURL) + require.Equal(t, "/files", start.FilesURL) + require.Equal(t, 10, start.Limits.MaxCommentsPerBatch) + var batch TrailReviewCommentBatchResponse + require.NoError(t, json.Unmarshal([]byte(`{"results":[{"client_id":"client-example","status":"created","comment":{"id":"comment-example","review_id":"review-example"},"suggested_change":{"id":"change-example","change_type":"manual_instruction"}}]}`), &batch)) + require.Len(t, batch.Results, 1) + require.Equal(t, "client-example", batch.Results[0].ClientID) + require.Equal(t, "review-example", batch.Results[0].Comment.ReviewID) + require.Equal(t, "manual_instruction", batch.Results[0].SuggestedChange.ChangeType) +} diff --git a/cmd/entire/cli/api/trail_thread_types.go b/cmd/entire/cli/api/trail_thread_types.go deleted file mode 100644 index e5a4c1dce5..0000000000 --- a/cmd/entire/cli/api/trail_thread_types.go +++ /dev/null @@ -1,100 +0,0 @@ -package api - -import "time" - -// Trail discussion-thread wire types. A thread has messages; each message may -// carry a single level of replies. Identity fields differ by source: Author, -// LastMessageAuthor, and Participants[].Login are GitHub logins, while -// CreatedBy and ResolvedBy are actor UUIDs (the server maps them differently). - -// TrailThreadReply is a reply on a thread message. Replies do not nest further. -type TrailThreadReply struct { - ID string `json:"id"` - Author string `json:"author"` // GitHub login - CreatedAt time.Time `json:"createdAt"` - Body string `json:"body"` -} - -// TrailThreadMessage is a top-level message in a thread. -type TrailThreadMessage struct { - ID string `json:"id"` - Author string `json:"author"` // GitHub login - CreatedAt time.Time `json:"createdAt"` - Body string `json:"body"` - Replies []TrailThreadReply `json:"replies"` -} - -// TrailThreadParticipant identifies a thread participant by login. -type TrailThreadParticipant struct { - Login string `json:"login"` -} - -// TrailThreadSummary is a thread's metadata. The server's review_comment blob -// (present only for kind=="code_review") is intentionally not decoded here: -// code-review threads are surfaced through `trail finding`. -type TrailThreadSummary struct { - ID string `json:"id"` - TrailID string `json:"trailId"` - Kind string `json:"kind"` // "discussion" | "code_review" - Title string `json:"title"` - ReviewCommentID *string `json:"reviewCommentId"` - Resolved bool `json:"resolved"` - ResolvedBy *string `json:"resolvedBy"` // actor UUID - ResolvedAt *time.Time `json:"resolvedAt"` - CreatedBy *string `json:"createdBy"` // actor UUID - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - LastMessageAt *time.Time `json:"lastMessageAt"` - LastMessageAuthor *string `json:"lastMessageAuthor"` // GitHub login - MessageCount int `json:"messageCount"` - Participants []TrailThreadParticipant `json:"participants"` -} - -// TrailThreadsResponse is the response from GET .../:number/threads. -type TrailThreadsResponse struct { - Items []TrailThreadSummary `json:"items"` - NextPageToken *string `json:"nextPageToken,omitempty"` - EventCursor string `json:"eventCursor"` -} - -// TrailThreadDetailResponse is the response from GET .../:number/threads/:id. -type TrailThreadDetailResponse struct { - Thread TrailThreadSummary `json:"thread"` - Messages []TrailThreadMessage `json:"messages"` - EventCursor string `json:"eventCursor"` -} - -// TrailThreadCreateRequest is the body for POST .../:number/threads. -// Body is required; Title is optional (server defaults it to "Conversation"). -type TrailThreadCreateRequest struct { - Title string `json:"title,omitempty"` - Body string `json:"body"` -} - -// TrailThreadCreateResponse is the response from POST .../:number/threads. -type TrailThreadCreateResponse struct { - Thread TrailThreadSummary `json:"thread"` - Message *TrailThreadMessage `json:"message"` -} - -// TrailThreadUpdateRequest is the body for PATCH .../:number/threads/:id. -// Pointer fields distinguish "not provided" from an explicit value. -type TrailThreadUpdateRequest struct { - Title *string `json:"title,omitempty"` - Resolved *bool `json:"resolved,omitempty"` -} - -// TrailThreadUpdateResponse is the response from PATCH .../:number/threads/:id. -type TrailThreadUpdateResponse struct { - Thread TrailThreadSummary `json:"thread"` -} - -// TrailThreadMessageRequest is the body for POST/PATCH message endpoints. -type TrailThreadMessageRequest struct { - Body string `json:"body"` -} - -// TrailThreadMessageResponse is the response from the message endpoints. -type TrailThreadMessageResponse struct { - Message TrailThreadMessage `json:"message"` -} diff --git a/cmd/entire/cli/api/trail_thread_types_test.go b/cmd/entire/cli/api/trail_thread_types_test.go deleted file mode 100644 index 29028575f3..0000000000 --- a/cmd/entire/cli/api/trail_thread_types_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package api - -import ( - "encoding/json" - "testing" -) - -const threadTestLogin = "alice" - -func TestTrailThreadDetailDecodes(t *testing.T) { - t.Parallel() - payload := []byte(`{ - "thread": { - "id": "th1", "trailId": "tr1", "kind": "discussion", "title": "Design", - "reviewCommentId": null, "resolved": false, - "resolvedBy": null, "resolvedAt": null, - "createdBy": "actor-uuid", "createdAt": "2026-07-10T00:00:00Z", - "updatedAt": "2026-07-10T00:01:00Z", - "lastMessageAt": "2026-07-10T00:01:00Z", "lastMessageAuthor": "alice", - "messageCount": 2, "participants": [{"login":"alice"},{"login":"bob"}] - }, - "messages": [ - {"id":"m1","author":"alice","createdAt":"2026-07-10T00:00:00Z","body":"hi", - "replies":[{"id":"r1","author":"bob","createdAt":"2026-07-10T00:00:30Z","body":"yo"}]} - ], - "eventCursor": "42" - }`) - var out TrailThreadDetailResponse - if err := json.Unmarshal(payload, &out); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if out.EventCursor != "42" { - t.Errorf("EventCursor = %q, want 42", out.EventCursor) - } - if out.Thread.CreatedBy == nil || *out.Thread.CreatedBy != "actor-uuid" { - t.Errorf("CreatedBy = %v, want actor-uuid", out.Thread.CreatedBy) - } - if out.Thread.ResolvedBy != nil { - t.Errorf("ResolvedBy = %v, want nil", out.Thread.ResolvedBy) - } - if out.Thread.LastMessageAuthor == nil || *out.Thread.LastMessageAuthor != threadTestLogin { - t.Errorf("LastMessageAuthor = %v, want alice", out.Thread.LastMessageAuthor) - } - if len(out.Thread.Participants) != 2 || out.Thread.Participants[0].Login != threadTestLogin { - t.Errorf("Participants = %#v", out.Thread.Participants) - } - if len(out.Messages) != 1 || out.Messages[0].Author != threadTestLogin { - t.Fatalf("Messages = %#v", out.Messages) - } - if len(out.Messages[0].Replies) != 1 || out.Messages[0].Replies[0].Author != "bob" { - t.Errorf("Replies = %#v", out.Messages[0].Replies) - } -} - -func TestTrailThreadUpdateRequestMarshalsResolvedFalse(t *testing.T) { - t.Parallel() - f := false - b, err := json.Marshal(TrailThreadUpdateRequest{Resolved: &f}) - if err != nil { - t.Fatalf("marshal: %v", err) - } - if string(b) != `{"resolved":false}` { - t.Errorf("got %s, want {\"resolved\":false}", b) - } - // Omitting resolved (nil) must drop the field. - b2, err := json.Marshal(TrailThreadUpdateRequest{}) - if err != nil { - t.Fatalf("marshal: %v", err) - } - if string(b2) != `{}` { - t.Errorf("got %s, want {}", b2) - } -} diff --git a/cmd/entire/cli/api/trail_types.go b/cmd/entire/cli/api/trail_types.go index cf655c96e9..1ea0f22a83 100644 --- a/cmd/entire/cli/api/trail_types.go +++ b/cmd/entire/cli/api/trail_types.go @@ -10,20 +10,20 @@ import ( // TrailListResponse is the response from entire-api's trail list endpoint. type TrailListResponse struct { - Trails []TrailResource `json:"items"` - Total int `json:"totalCount"` - NextPageToken *string `json:"nextPageToken"` + Trails []TrailResource `json:"items"` + Total int `json:"total_count"` + NextCursor *string `json:"next_cursor"` } // TrailResource represents a trail returned by entire-api. The backend uses -// camelCase and nullable branch fields. Branch is empty when the trail is +// snake_case and nullable branch fields. Branch is empty when the trail is // currently unlinked; OriginalBranch separately preserves its last link. type TrailResource struct { ID string `json:"id,omitempty"` Number int `json:"number,omitempty"` URL string `json:"url,omitempty"` Branch string `json:"branch"` - OriginalBranch string `json:"originalBranch,omitempty"` + OriginalBranch string `json:"original_branch,omitempty"` Base string `json:"base"` Title string `json:"title"` Body string `json:"body,omitempty"` @@ -35,26 +35,26 @@ type TrailResource struct { Priority string `json:"priority,omitempty"` Type string `json:"type,omitempty"` Reviewers []trail.Reviewer `json:"reviewers,omitempty"` - RequestedReviewers []string `json:"requestedReviewers,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - MergedAt *time.Time `json:"mergedAt,omitempty"` - CommentCount int `json:"commentCount,omitempty"` - UnresolvedCount int `json:"unresolvedCount,omitempty"` - CheckpointCount int `json:"checkpointCount,omitempty"` - CommitsAhead int `json:"commitsAhead,omitempty"` - BodyDocument *TrailBodyDocument `json:"bodyDocument,omitempty"` + RequestedReviewers []string `json:"requested_reviewers,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MergedAt *time.Time `json:"merged_at,omitempty"` + CommentCount int `json:"comment_count,omitempty"` + UnresolvedCount int `json:"unresolved_count,omitempty"` + CheckpointCount int `json:"checkpoint_count,omitempty"` + CommitsAhead int `json:"commits_ahead,omitempty"` + BodyDocument *TrailBodyDocument `json:"body_document,omitempty"` } // TrailBodyDocument is the trail's description editor document. TextSnapshot // is the rendered plain text displayed by the CLI. The document is also what a // body write returns (see TrailBodyRequest), so both directions decode into this -// type; the fields the CLI does not use (id, documentKey, schemaVersion, -// contentJson, updatedAt) are simply left out of it. ETag is populated on a +// type; the fields the CLI does not use (id, document_key, schema_version, +// content_json, updated_at) are simply left out of it. ETag is populated on a // read as well as on a write response, and is what makes If-Match viable on // the next write (see sendTrailBody). type TrailBodyDocument struct { - TextSnapshot string `json:"textSnapshot"` + TextSnapshot string `json:"text_snapshot"` ETag string `json:"etag,omitempty"` } @@ -81,8 +81,8 @@ func (r *TrailResource) ToMetadata() *trail.Metadata { type TrailCreateRequest struct { Title string `json:"title"` Body string `json:"body,omitempty"` - BranchName string `json:"branchName,omitempty"` - BranchAction string `json:"branchAction,omitempty"` + BranchName string `json:"branch_name,omitempty"` + BranchAction string `json:"branch_action,omitempty"` Base string `json:"base,omitempty"` Status string `json:"status,omitempty"` Assignees []string `json:"assignees,omitempty"` @@ -110,7 +110,7 @@ type TrailUpdateRequest struct { Status *string `json:"status,omitempty"` Title *string `json:"title,omitempty"` Assignees *[]string `json:"assignees,omitempty"` - RequestedReviewers *[]string `json:"requestedReviewers,omitempty"` + RequestedReviewers *[]string `json:"requested_reviewers,omitempty"` Type *string `json:"type,omitempty"` Priority *string `json:"priority,omitempty"` } @@ -128,10 +128,10 @@ type TrailUpdateResponse struct { // Markdown carries no omitempty: an empty string is how a description is // cleared, and the server distinguishes present-and-empty from absent — with // omitempty the field would vanish from the JSON and the request would be -// rejected as "exactly one of markdown/contentJson is required". +// rejected as "exactly one of markdown/content_json is required". // -// The route also accepts contentJson (ProseMirror JSON, written as-is) in place -// of markdown; the CLI only ever writes Markdown, so contentJson is not +// The route also accepts content_json (ProseMirror JSON, written as-is) in place +// of markdown; the CLI only ever writes Markdown, so content_json is not // modeled here. The route also accepts an If-Match header for optimistic // concurrency, populated from a prior read of TrailBodyDocument.ETag — see // sendTrailBody for the dispatch between If-Match and Overwrite. @@ -149,8 +149,8 @@ type TrailApproval struct { Author string `json:"author"` Event string `json:"event"` Body string `json:"body,omitempty"` - CommitSHA string `json:"commitSha,omitempty"` - CreatedAt time.Time `json:"createdAt"` + CommitSHA string `json:"commit_sha,omitempty"` + CreatedAt time.Time `json:"created_at"` } func (a *TrailApproval) UnmarshalJSON(data []byte) error { @@ -159,8 +159,8 @@ func (a *TrailApproval) UnmarshalJSON(data []byte) error { Author json.RawMessage `json:"author"` Event string `json:"event"` Body *string `json:"body"` - CommitSHA string `json:"commitSha"` - CreatedAt time.Time `json:"createdAt"` + CommitSHA string `json:"commit_sha"` + CreatedAt time.Time `json:"created_at"` } if err := json.Unmarshal(data, &wire); err != nil { return fmt.Errorf("decode trail approval: %w", err) diff --git a/cmd/entire/cli/api/trail_types_test.go b/cmd/entire/cli/api/trail_types_test.go index 0c71393e73..00b291dbb4 100644 --- a/cmd/entire/cli/api/trail_types_test.go +++ b/cmd/entire/cli/api/trail_types_test.go @@ -51,18 +51,18 @@ func TestTrailListResponseDecodesEntireAPIContract(t *testing.T) { payload := []byte(`{ "items":[{ "id":"01JTRAIL","number":7,"title":"Native trail","status":"open", - "branch":null,"originalBranch":"feature/native","base":"main", - "requestedReviewers":["reviewer"],"phase":"reviewing", - "createdAt":"2026-08-10T10:00:00.000Z","updatedAt":"2026-08-10T11:00:00.000Z" + "branch":null,"original_branch":"feature/native","base":"main", + "requested_reviewers":["reviewer"],"phase":"reviewing", + "created_at":"2026-08-10T10:00:00.000Z","updated_at":"2026-08-10T11:00:00.000Z" }], - "nextPageToken":"cursor-2","totalCount":12 + "next_cursor":"cursor-2","total_count":12 }`) var got TrailListResponse if err := json.Unmarshal(payload, &got); err != nil { t.Fatalf("decode native list: %v", err) } - if got.Total != 12 || got.NextPageToken == nil || *got.NextPageToken != "cursor-2" { - t.Fatalf("pagination = total %d token %v", got.Total, got.NextPageToken) + if got.Total != 12 || got.NextCursor == nil || *got.NextCursor != "cursor-2" { + t.Fatalf("pagination = total %d token %v", got.Total, got.NextCursor) } if len(got.Trails) != 1 || got.Trails[0].Branch != "" || got.Trails[0].OriginalBranch != "feature/native" || got.Trails[0].Phase != "reviewing" { t.Fatalf("trail = %#v", got.Trails) @@ -76,14 +76,11 @@ func TestTrailRequestsUseEntireAPICasing(t *testing.T) { t.Fatal(err) } text := string(body) - for _, want := range []string{`"branchName"`, `"branchAction"`} { + for _, want := range []string{`"branch_name"`, `"branch_action"`} { if !strings.Contains(text, want) { t.Fatalf("request %s missing %s", text, want) } } - if strings.Contains(text, "branch_name") || strings.Contains(text, "branch_action") { - t.Fatalf("request still uses snake_case: %s", text) - } } func TestTrailResourceToMetadataUsesID(t *testing.T) { @@ -134,10 +131,7 @@ func TestToMetadataMapsTypePriorityReviewers(t *testing.T) { } // TestTrailApprovalDecodesStringAuthor pins the current entire-api approvals -// wire shape. Re-verified against entire-api's TrailApprovalWire: the HTTP -// response uses commitSha/createdAt and a bare login string for author. The -// server's similarly named storedTrailApproval remains snake_case, but is an -// internal JSONB shape that is converted before the response is written. +// wire shape: snake_case fields and a bare login string for author. // // Author deliberately remains a string rather than *trail.Author. A populated // approvals response otherwise fails to decode even though an empty response @@ -147,25 +141,25 @@ func TestTrailApprovalDecodesStringAuthor(t *testing.T) { t.Parallel() const body = `{"approvals":[{"id":"59ef5b87","body":null,"event":"approved",` + - `"author":"nodo","commitSha":"e9a9dcbf1fbc55580e7212096824a01e1691853d",` + - `"createdAt":"2026-08-11T09:35:11.714Z"}]}` + `"author":"reviewer-example","commit_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",` + + `"created_at":"2026-08-11T09:35:11.714Z"}]}` var got TrailApprovalsResponse if err := json.Unmarshal([]byte(body), &got); err != nil { - t.Fatalf("decoding a real approvals response failed: %v", err) + t.Fatalf("decoding an approvals response failed: %v", err) } if len(got.Approvals) != 1 { t.Fatalf("Approvals len = %d, want 1", len(got.Approvals)) } a := got.Approvals[0] - if a.Author != "nodo" { - t.Errorf("Author = %q, want %q", a.Author, "nodo") + if a.Author != "reviewer-example" { + t.Errorf("Author = %q, want %q", a.Author, "reviewer-example") } if a.Event != "approved" { t.Errorf("Event = %q, want approved", a.Event) } - if a.CommitSHA != "e9a9dcbf1fbc55580e7212096824a01e1691853d" { + if a.CommitSHA != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { t.Errorf("CommitSHA = %q", a.CommitSHA) } // body:null must not become the string "null". @@ -177,21 +171,53 @@ func TestTrailApprovalDecodesStringAuthor(t *testing.T) { } } -// The submit response embeds the same camelCase TrailApprovalWire shape. +// The submit response embeds the same snake_case TrailApprovalWire shape. func TestTrailApprovalResponseDecodesStringAuthor(t *testing.T) { t.Parallel() const body = `{"ok":true,"approval":{"id":"9f65e574","event":"approved",` + - `"author":"nodo","createdAt":"2026-08-11T09:35:34.998Z"}}` + `"author":"reviewer-example","created_at":"2026-08-11T09:35:34.998Z"}}` var got TrailApprovalResponse if err := json.Unmarshal([]byte(body), &got); err != nil { - t.Fatalf("decoding a real approve response failed: %v", err) + t.Fatalf("decoding an approve response failed: %v", err) } if !got.OK { t.Error("OK = false, want true") } - if got.Approval.Author != "nodo" { - t.Errorf("Approval.Author = %q, want nodo", got.Approval.Author) + if got.Approval.Author != "reviewer-example" { + t.Errorf("Approval.Author = %q, want reviewer-example", got.Approval.Author) + } +} + +func TestTrailWriteContracts(t *testing.T) { + t.Parallel() + reviewers := []string{} + title := "Renamed" + no := false + for _, tc := range []struct { + name string + body any + want string + }{ + {"update", TrailUpdateRequest{Title: &title, RequestedReviewers: &reviewers}, `{"title":"Renamed","requested_reviewers":[]}`}, + {"clear body", TrailBodyRequest{Markdown: ""}, `{"markdown":""}`}, + {"approve", TrailApprovalRequest{Event: "approve", Body: "Reviewed"}, `{"event":"approve","body":"Reviewed"}`}, + {"request changes", TrailApprovalRequest{Event: "request_changes", Body: "Please fix"}, `{"event":"request_changes","body":"Please fix"}`}, + {"discussion", TrailDiscussionCreateRequest{Title: "Design", Body: "Discuss"}, `{"title":"Design","body":"Discuss"}`}, + {"reopen", TrailDiscussionUpdateRequest{Resolved: &no}, `{"resolved":false}`}, + {"no-op update", TrailDiscussionUpdateRequest{}, `{}`}, + {"message", TrailDiscussionMessageRequest{Body: "Reply"}, `{"body":"Reply"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + body, err := json.Marshal(tc.body) + if err != nil { + t.Fatal(err) + } + if string(body) != tc.want { + t.Fatalf("body = %s, want %s", body, tc.want) + } + }) } } diff --git a/cmd/entire/cli/api/trails.go b/cmd/entire/cli/api/trails.go index 2cd2c8935f..c194b2a476 100644 --- a/cmd/entire/cli/api/trails.go +++ b/cmd/entire/cli/api/trails.go @@ -11,7 +11,7 @@ import ( // TrailsEnabled probes trail availability: 2xx=true, 403/404/410=false, // everything else ambiguous. func (c *Client) TrailsEnabled(ctx context.Context, forge, owner, repo string) (bool, error) { - resp, err := c.Get(ctx, fmt.Sprintf("/api/v1/trails/%s/%s/%s?pageSize=1", + resp, err := c.Get(ctx, fmt.Sprintf("/api/v1/trails/%s/%s/%s?per_page=1", url.PathEscape(forge), url.PathEscape(owner), url.PathEscape(repo))) if err != nil { return false, fmt.Errorf("probe trails enablement: %w", err) diff --git a/cmd/entire/cli/api/trails_test.go b/cmd/entire/cli/api/trails_test.go index 630e21f291..9dac62283a 100644 --- a/cmd/entire/cli/api/trails_test.go +++ b/cmd/entire/cli/api/trails_test.go @@ -15,7 +15,7 @@ func TestClient_TrailsEnabledEscapesPathComponents(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotURI = r.RequestURI w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"items":[],"nextPageToken":null,"totalCount":0}`)) //nolint:errcheck // test handler + w.Write([]byte(`{"items":[],"next_cursor":null,"total_count":0}`)) //nolint:errcheck // test handler })) defer server.Close() @@ -28,7 +28,7 @@ func TestClient_TrailsEnabledEscapesPathComponents(t *testing.T) { if !ok { t.Fatal("enabled = false, want true") } - want := "/api/v1/trails/g%2Fh/acme%3Forg/repo%23frag?pageSize=1" + want := "/api/v1/trails/g%2Fh/acme%3Forg/repo%23frag?per_page=1" if gotURI != want { t.Errorf("request URI = %q, want %q", gotURI, want) } @@ -55,7 +55,7 @@ func TestClient_RewritesResolvedTrailReviewRoute(t *testing.T) { } } -func TestClient_TrailRequestsUseCamelCase(t *testing.T) { +func TestClient_TrailRequestsUseSnakeCase(t *testing.T) { t.Parallel() var got map[string]any server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -74,11 +74,13 @@ func TestClient_TrailRequestsUseCamelCase(t *testing.T) { t.Fatal(err) } resp.Body.Close() - if got["branchName"] != "feature/test" || got["branchAction"] != "link" { + if got["branch_name"] != "feature/test" || got["branch_action"] != "link" { t.Fatalf("body = %#v", got) } - if _, ok := got["branch_name"]; ok { - t.Fatalf("body contains snake_case: %#v", got) + for _, key := range []string{"branchName", "branchAction"} { + if _, ok := got[key]; ok { + t.Fatalf("body contains camelCase %q: %#v", key, got) + } } } @@ -92,10 +94,10 @@ func TestClient_TrailsEnabled(t *testing.T) { wantOK bool wantErrNil bool }{ - {"enabled (200)", http.StatusOK, `{"items":[],"nextPageToken":null,"totalCount":0}`, true, true}, - {"enabled empty (200)", http.StatusOK, `{"items":[],"nextPageToken":null,"totalCount":0}`, true, true}, + {"enabled (200)", http.StatusOK, `{"items":[],"next_cursor":null,"total_count":0}`, true, true}, + {"enabled empty (200)", http.StatusOK, `{"items":[],"next_cursor":null,"total_count":0}`, true, true}, {"not enabled (404)", http.StatusNotFound, `{"error":"not found"}`, false, true}, - {"forbidden (403)", http.StatusForbidden, `{"error":"forbidden"}`, false, true}, + {"forbidden (403)", http.StatusForbidden, `{"type":"https://example.test/problems/forbidden","title":"Forbidden","status":403,"detail":"No access","code":"forbidden","request_id":"request-example"}`, false, true}, {"gone (410)", http.StatusGone, `{"error":"gone"}`, false, true}, {"unauthorized (401)", http.StatusUnauthorized, `{"error":"unauthorized"}`, false, false}, {"server error (500)", http.StatusInternalServerError, `{"error":"boom"}`, false, false}, @@ -127,8 +129,8 @@ func TestClient_TrailsEnabled(t *testing.T) { if gotPath != "/api/v1/trails/gh/acme/repo" { t.Errorf("path = %q, want /api/v1/trails/gh/acme/repo", gotPath) } - if gotQuery != "pageSize=1" { - t.Errorf("query = %q, want pageSize=1", gotQuery) + if gotQuery != "per_page=1" { + t.Errorf("query = %q, want per_page=1", gotQuery) } }) } diff --git a/cmd/entire/cli/attach.go b/cmd/entire/cli/attach.go index c1d9c2ab36..7a276f3c86 100644 --- a/cmd/entire/cli/attach.go +++ b/cmd/entire/cli/attach.go @@ -12,7 +12,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/external" - "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/entireio/cli/cmd/entire/cli/agent/types" cpkg "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" @@ -43,7 +42,7 @@ type attachOptions struct { // resolved inside runAttach after the real agent is known (via session // state or transcript auto-detection), not at the cobra layer — the // --agent flag's default points at claude-code, which would otherwise - // make a Gemini session incorrectly look up review.claude-code config. + // make a Codex session incorrectly look up review.claude-code config. Review bool // ReviewSkillsOverride, when non-empty, declares which review skills were // run. Empty is valid: the session is still tagged as a review, with no @@ -271,16 +270,6 @@ func runAttach(ctx context.Context, w, errW io.Writer, sessionID string, agentNa return fmt.Errorf("failed to read transcript: %w", err) } - // Normalize Gemini transcripts for storage. - storedTranscript := transcriptData - if ag.Type() == agent.AgentTypeGemini { - if normalized, normErr := geminicli.NormalizeTranscript(transcriptData); normErr == nil { - storedTranscript = normalized - } else { - logging.Warn(logCtx, "failed to normalize Gemini transcript, storing raw", "error", normErr) - } - } - meta := extractTranscriptMetadataForAgent(ag, transcriptPath, transcriptData) warnEmptyTranscriptMetadata(errW, ag.Name(), meta, opts) @@ -345,7 +334,7 @@ func runAttach(ctx context.Context, w, errW io.Writer, sessionID string, agentNa } _, redactSpan := perf.Start(ctx, "redact_transcript") - redactedTranscript, redactErr := redact.JSONLBytes(storedTranscript) + redactedTranscript, redactErr := redact.JSONLBytes(transcriptData) redactSpan.End() if redactErr != nil { return fmt.Errorf("failed to redact transcript: %w", redactErr) @@ -412,7 +401,7 @@ func amendOrPrintTrailer(logCtx context.Context, w, errW io.Writer, headCommit * // warnEmptyTranscriptMetadata warns (without failing) when nothing parsed out // of the transcript: the checkpoint is still written and useful (code + token // usage), but it carries no prompt or title. extractTranscriptMetadata only -// understands generic JSONL + Gemini JSON, so agents with other user-content +// understands generic JSONL, so agents with other user-content // shapes (codex/copilot/pi/factory) can legitimately yield empty meta from a // valid transcript — a hard error would regress attach for them. func warnEmptyTranscriptMetadata(errW io.Writer, agentName types.AgentName, meta transcriptMetadata, opts attachOptions) { diff --git a/cmd/entire/cli/attach_test.go b/cmd/entire/cli/attach_test.go index d662d17ef6..ab011a7c17 100644 --- a/cmd/entire/cli/attach_test.go +++ b/cmd/entire/cli/attach_test.go @@ -19,7 +19,6 @@ import ( codexagent "github.com/entireio/cli/cmd/entire/cli/agent/codex" _ "github.com/entireio/cli/cmd/entire/cli/agent/cursor" // register agent _ "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid" // register agent - _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" // register agent piagent "github.com/entireio/cli/cmd/entire/cli/agent/pi" "github.com/entireio/cli/cmd/entire/cli/agent/types" cpkg "github.com/entireio/cli/cmd/entire/cli/checkpoint" @@ -773,11 +772,6 @@ func TestCountUserTurns(t *testing.T) { data []byte want int }{ - { - name: "gemini format", - data: []byte(`{"messages":[{"type":"user","content":"first"},{"type":"gemini","content":"ok"},{"type":"user","content":"second"},{"type":"gemini","content":"done"}]}`), - want: 2, - }, { name: "jsonl with tool_result should not double count", data: []byte(`{"type":"user","message":{"role":"user","content":"hello"},"uuid":"u1"} @@ -822,11 +816,6 @@ func TestExtractModelFromTranscript(t *testing.T) { `), want: "", }, - { - name: "gemini format (no model in transcript)", - data: []byte(`{"messages":[{"type":"user","content":"hi"},{"type":"gemini","content":"hello"}]}`), - want: "", - }, } for _, tt := range tests { @@ -840,16 +829,6 @@ func TestExtractModelFromTranscript(t *testing.T) { } } -func TestExtractFirstPromptFromTranscript_GeminiFormat(t *testing.T) { - t.Parallel() - - data := []byte(`{"messages":[{"type":"user","content":"fix the login bug"},{"type":"gemini","content":"I'll look at that"}]}`) - got := extractTranscriptMetadata(data).FirstPrompt - if got != "fix the login bug" { - t.Errorf("extractTranscriptMetadata(gemini).FirstPrompt = %q, want %q", got, "fix the login bug") - } -} - func TestExtractFirstPromptFromTranscript_JSONLFormat(t *testing.T) { t.Parallel() @@ -1000,110 +979,6 @@ func TestExtractTranscriptMetadata_JSONLOnlyInjectedPreamble(t *testing.T) { } } -func TestAttach_GeminiSubdirectorySession(t *testing.T) { - setupAttachTestRepo(t) - - // Redirect HOME so searchTranscriptInProjectDirs searches our fake Gemini dir - fakeHome := t.TempDir() - t.Setenv("HOME", fakeHome) - - // Create a Gemini transcript in a *different* project hash directory, - // simulating a session started from a subdirectory (different CWD hash). - differentProjectDir := filepath.Join(fakeHome, ".gemini", "tmp", "different-hash", "chats") - if err := os.MkdirAll(differentProjectDir, 0o750); err != nil { - t.Fatal(err) - } - - sessionID := "abcd1234-gemini-subdir-test" - transcriptContent := `{"messages":[{"type":"user","content":"hello"},{"type":"gemini","content":"hi"}]}` - // Gemini names files as session--.json where shortid = sessionID[:8] - transcriptFile := filepath.Join(differentProjectDir, "session-2026-01-01T10-00-abcd1234.json") - if err := os.WriteFile(transcriptFile, []byte(transcriptContent), 0o600); err != nil { - t.Fatal(err) - } - - // Set the expected project dir to an empty directory so the primary lookup fails - // and the fallback search kicks in. - emptyProjectDir := t.TempDir() - t.Setenv("ENTIRE_TEST_GEMINI_PROJECT_DIR", emptyProjectDir) - - var out bytes.Buffer - err := runAttach(context.Background(), &out, &out, sessionID, agent.AgentNameGemini, attachOptions{Force: true}) - if err != nil { - t.Fatalf("runAttach failed: %v", err) - } - - output := out.String() - if !strings.Contains(output, "Attached session") { - t.Errorf("expected 'Attached session' in output, got: %s", output) - } - - store, storeErr := session.NewStateStore(context.Background()) - if storeErr != nil { - t.Fatal(storeErr) - } - state, loadErr := store.Load(context.Background(), sessionID) - if loadErr != nil { - t.Fatal(loadErr) - } - if state == nil { - t.Fatal("expected session state to be created") - return - } - if state.AgentType != agent.AgentTypeGemini { - t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeGemini) - } - if state.LastCheckpointID.IsEmpty() { - t.Error("expected LastCheckpointID to be set after attach") - } -} - -func TestAttach_GeminiSuccess(t *testing.T) { - setupAttachTestRepo(t) - - // Create Gemini transcript in expected project dir - geminiDir := t.TempDir() - t.Setenv("ENTIRE_TEST_GEMINI_PROJECT_DIR", geminiDir) - - sessionID := "abcd1234-gemini-success-test" - transcriptContent := `{"messages":[{"type":"user","content":"fix the login bug"},{"type":"gemini","content":"I will fix the login bug now."}]}` - transcriptFile := filepath.Join(geminiDir, "session-2026-01-01T10-00-abcd1234.json") - if err := os.WriteFile(transcriptFile, []byte(transcriptContent), 0o600); err != nil { - t.Fatal(err) - } - - var out bytes.Buffer - err := runAttach(context.Background(), &out, &out, sessionID, agent.AgentNameGemini, attachOptions{Force: true}) - if err != nil { - t.Fatalf("runAttach failed: %v", err) - } - - output := out.String() - if !strings.Contains(output, "Attached session") { - t.Errorf("expected 'Attached session' in output, got: %s", output) - } - - // Verify session state - store, storeErr := session.NewStateStore(context.Background()) - if storeErr != nil { - t.Fatal(storeErr) - } - state, loadErr := store.Load(context.Background(), sessionID) - if loadErr != nil { - t.Fatal(loadErr) - } - if state == nil { - t.Fatal("expected session state to be created") - return - } - if state.AgentType != agent.AgentTypeGemini { - t.Errorf("AgentType = %q, want %q", state.AgentType, agent.AgentTypeGemini) - } - if state.SessionTurnCount != 1 { - t.Errorf("SessionTurnCount = %d, want 1", state.SessionTurnCount) - } -} - func TestAttach_CursorSuccess(t *testing.T) { setupAttachTestRepo(t) @@ -1771,9 +1646,9 @@ func TestAttachCmd_ReviewWithoutSkillsOrConfigSucceeds(t *testing.T) { } } -// Regression: `entire attach --review ` without +// Regression: `entire attach --review ` without // --agent must attach successfully. The plain attach flow already -// auto-detects Gemini from the transcript; the review path must not +// auto-detects Cursor from the transcript; the review path must not // add a blocking pre-check against the --agent flag's default // (claude-code), which would have failed when claude-code had no // matching transcript/config. @@ -1784,18 +1659,21 @@ func TestAttachCmd_ReviewAutoDetectsAgent(t *testing.T) { t.Setenv("ENTIRE_TEST_CLAUDE_PROJECT_DIR", t.TempDir()) t.Setenv("HOME", t.TempDir()) - // Create a valid Gemini transcript in the expected project dir. - geminiDir := t.TempDir() - t.Setenv("ENTIRE_TEST_GEMINI_PROJECT_DIR", geminiDir) - sessionID := "abcd1234-review-gemini-autodetect" - transcriptContent := `{"messages":[{"type":"user","content":"review this"},{"type":"gemini","content":"reviewing"}]}` - transcriptFile := filepath.Join(geminiDir, "session-2026-01-01T10-00-abcd1234.json") + // Create a valid Cursor transcript in the expected project dir + // (flat layout: /.jsonl). + cursorDir := t.TempDir() + t.Setenv("ENTIRE_TEST_CURSOR_PROJECT_DIR", cursorDir) + sessionID := "test-review-cursor-autodetect" + transcriptContent := `{"type":"user","message":{"role":"user","content":"review this"},"uuid":"u1"} +{"type":"assistant","message":{"role":"assistant","content":"reviewing"},"uuid":"a1"} +` + transcriptFile := filepath.Join(cursorDir, sessionID+".jsonl") if err := os.WriteFile(transcriptFile, []byte(transcriptContent), 0o600); err != nil { t.Fatal(err) } // Invoke without --agent (flag falls through to DefaultAgentName = - // claude-code). runAttach's auto-detect should find Gemini. + // claude-code). runAttach's auto-detect should find Cursor. rootCmd := NewRootCmd() var errBuf, outBuf bytes.Buffer rootCmd.SetErr(&errBuf) @@ -1816,8 +1694,8 @@ func TestAttachCmd_ReviewAutoDetectsAgent(t *testing.T) { if state == nil || state.Kind != session.KindAgentReview { t.Fatalf("expected session tagged as review; got state=%+v", state) } - if state.AgentType != agent.AgentTypeGemini { - t.Errorf("AgentType = %q, want %q (auto-detect should have found Gemini)", state.AgentType, agent.AgentTypeGemini) + if state.AgentType != agent.AgentTypeCursor { + t.Errorf("AgentType = %q, want %q (auto-detect should have found Cursor)", state.AgentType, agent.AgentTypeCursor) } } diff --git a/cmd/entire/cli/attach_transcript.go b/cmd/entire/cli/attach_transcript.go index 0e7cd0f5f1..15486beb11 100644 --- a/cmd/entire/cli/attach_transcript.go +++ b/cmd/entire/cli/attach_transcript.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/textutil" "github.com/entireio/cli/cmd/entire/cli/transcript" @@ -19,8 +18,7 @@ type transcriptMetadata struct { } // extractTranscriptMetadata parses transcript bytes once and extracts the first user prompt, -// user turn count, and model name. Supports both JSONL (Claude Code, Cursor, OpenCode) and -// Gemini JSON format. +// user turn count, and model name from JSONL transcripts (Claude Code, Cursor, OpenCode). func extractTranscriptMetadata(data []byte) transcriptMetadata { var meta transcriptMetadata @@ -63,19 +61,6 @@ func extractTranscriptMetadata(data []byte) transcriptMetadata { if meta.FirstPrompt == "" { meta.FirstPrompt = firstUserPrompt } - if meta.TurnCount > 0 || meta.Model != "" || meta.FirstPrompt != "" { - return meta - } - } - - // Fallback: try Gemini JSON format {"messages": [...]} - if prompts, gemErr := geminicli.ExtractAllUserPrompts(data); gemErr == nil && len(prompts) > 0 { - if first := strategy.FirstDisplayPrompt(prompts); first != "" { - meta.FirstPrompt = first - } else { - meta.FirstPrompt = prompts[0] - } - meta.TurnCount = countUserTurns(prompts) } return meta diff --git a/cmd/entire/cli/auth/control_plane.go b/cmd/entire/cli/auth/control_plane.go index 5103890c86..5caf5a2ec2 100644 --- a/cmd/entire/cli/auth/control_plane.go +++ b/cmd/entire/cli/auth/control_plane.go @@ -64,7 +64,7 @@ func errNoLogin() error { // ResolveControlPlaneTargetForCluster chooses which core a *resource-provider* // control-plane command should dial — one whose subject is a mirror on a -// specific cluster (mirror add/remove, access list) +// specific cluster (mirror add/remove, reading a mirror's collaborators) // rather than the caller's own account. // // Unlike ResolveControlPlaneTarget, the core is NOT taken from the active diff --git a/cmd/entire/cli/auth/control_plane_test.go b/cmd/entire/cli/auth/control_plane_test.go index f08753473d..c1972aa4b9 100644 --- a/cmd/entire/cli/auth/control_plane_test.go +++ b/cmd/entire/cli/auth/control_plane_test.go @@ -67,8 +67,8 @@ func TestResolveControlPlaneTarget_ActiveContextWins(t *testing.T) { // A cluster-addressed control-plane command dials the core that fronts the // cluster (discovered from /.well-known) using the matching local context — // NOT the active context, which may belong to a different federation. This is -// the fix for `repo access list … --cluster ` 400ing with -// "unknown cluster_host" while the active context is a staging login. +// the fix for a cluster-addressed command 400ing with "unknown cluster_host" +// while the active context is a staging login. func TestResolveControlPlaneTargetForCluster_DialsClusterCoreNotActive(t *testing.T) { configDir := t.TempDir() t.Setenv("ENTIRE_CONFIG_DIR", configDir) diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index 0ee4a6ac13..4181d52ae0 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -186,8 +186,8 @@ type WriteEphemeralTaskOptions struct { AgentID string // Agent identifies the agent that spawned the subagent. Needed to sanitize the - // stored subagent transcript: the type cannot be recovered from content, since - // DetectAgentTypeFromContent only recognizes Gemini. + // stored subagent transcript and to chunk the session transcript in the + // agent's own format; the type cannot be recovered from content. Agent types.AgentType // ModifiedFiles are files that have been modified (relative paths) diff --git a/cmd/entire/cli/checkpoint/configloader_test.go b/cmd/entire/cli/checkpoint/configloader_test.go index 009d1bcf4a..eea03a5960 100644 --- a/cmd/entire/cli/checkpoint/configloader_test.go +++ b/cmd/entire/cli/checkpoint/configloader_test.go @@ -7,6 +7,7 @@ import ( "runtime" "testing" + "github.com/entireio/cli/cmd/entire/cli/testutil/gitenv" "github.com/go-git/go-billy/v6/osfs" git "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/config" @@ -47,19 +48,13 @@ func writeSymlinkedGlobalConfig(t *testing.T, contents string) (home string) { // pointHomeAt isolates global git config resolution onto home: it sets HOME, // disables system config, and forces XDG resolution onto ~/.config by clearing -// XDG_CONFIG_HOME and GIT_CONFIG_GLOBAL (the latter is unset, not emptied, -// since an empty value disables global config entirely). +// XDG_CONFIG_HOME and unsetting GIT_CONFIG_GLOBAL (see gitenv.UnsetGlobalConfig). func pointHomeAt(t *testing.T, home string) { t.Helper() t.Setenv("HOME", home) t.Setenv("XDG_CONFIG_HOME", "") t.Setenv("GIT_CONFIG_NOSYSTEM", "1") - // t.Setenv registers restoration of the original value; unset it for the - // test so go-git falls back to XDG (an empty value disables global config). - t.Setenv("GIT_CONFIG_GLOBAL", "") - if err := os.Unsetenv("GIT_CONFIG_GLOBAL"); err != nil { - t.Fatal(err) - } + gitenv.UnsetGlobalConfig(t) } // TestOSSymlinkFS_ReadsGlobalConfigBehindSymlink reproduces the customer's diff --git a/cmd/entire/cli/checkpoint/ephemeral.go b/cmd/entire/cli/checkpoint/ephemeral.go index 950a678307..f19356f95b 100644 --- a/cmd/entire/cli/checkpoint/ephemeral.go +++ b/cmd/entire/cli/checkpoint/ephemeral.go @@ -415,10 +415,8 @@ func (s *ephemeralStore) addTaskMetadataToTree(ctx context.Context, baseTreeHash // Add session transcript (with chunking support for large transcripts) if opts.TranscriptPath != "" { if transcriptContent, readErr := agent.ReadTranscriptFile(opts.TranscriptPath); readErr == nil { - agentType := agent.DetectAgentTypeFromContent(transcriptContent) - // Chunk if necessary - chunks, chunkErr := agent.ChunkTranscript(ctx, transcriptContent, agentType) + chunks, chunkErr := agent.ChunkTranscript(ctx, transcriptContent, opts.Agent) if chunkErr != nil { logging.Warn(ctx, "failed to chunk transcript, checkpoint will be saved without transcript", slog.String("error", chunkErr.Error()), diff --git a/cmd/entire/cli/checkpoint/global_test.go b/cmd/entire/cli/checkpoint/global_test.go index 1433d465df..851d62e647 100644 --- a/cmd/entire/cli/checkpoint/global_test.go +++ b/cmd/entire/cli/checkpoint/global_test.go @@ -7,6 +7,7 @@ import ( _ "unsafe" + "github.com/entireio/cli/cmd/entire/cli/testutil/gitenv" "github.com/go-git/go-git/v6/x/plugin" "github.com/go-git/go-git/v6/x/plugin/config" ) @@ -33,10 +34,13 @@ const configLoaderKey plugin.Name = "config-loader" // useAutoConfigLoader swaps the registered ConfigLoader plugin to NewAuto (which // reads $HOME/.gitconfig) for the duration of t, then restores NewEmpty on cleanup. -// Also sets GIT_CONFIG_NOSYSTEM=1 so NewAuto skips the host's /etc/gitconfig. +// Also sets GIT_CONFIG_NOSYSTEM=1 so NewAuto skips the host's /etc/gitconfig, and +// unsets GIT_CONFIG_GLOBAL so NewAuto resolves the caller's $HOME at all — see +// gitenv.UnsetGlobalConfig. Matches pointHomeAt in configloader_test.go. func useAutoConfigLoader(t *testing.T) { t.Helper() t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + gitenv.UnsetGlobalConfig(t) registerConfigLoaderForTest(t, func() error { return plugin.Register(plugin.ConfigLoader(), func() plugin.ConfigSource { return config.NewAuto() }) }) diff --git a/cmd/entire/cli/checkpoint/remote/checkpoint_ref_from_test.go b/cmd/entire/cli/checkpoint/remote/checkpoint_ref_from_test.go index 6c135421c9..60b34fb964 100644 --- a/cmd/entire/cli/checkpoint/remote/checkpoint_ref_from_test.go +++ b/cmd/entire/cli/checkpoint/remote/checkpoint_ref_from_test.go @@ -209,7 +209,12 @@ func dedicatedCandidatesFixture(t *testing.T, refOnFork, refOnOrigin bool) (stri // placed first on PATH, so a rewrite key carrying `;` or `)` was command // execution. Keep them literal: dedupe by giving each caller its own // script, never by generating one from parameters. - testutil.WriteFile(t, binDir, "git", `#!/bin/bash + // + // bash specifically (indexed arrays, ${!args[@]}), reached through + // /usr/bin/env: /bin/bash is not universal — NixOS ships /bin/sh alone, and + // a hardcoded interpreter path fails there at exec time, surfacing as a + // baffling "fork/exec …/git: no such file or directory". + testutil.WriteFile(t, binDir, "git", `#!/usr/bin/env bash args=("$@") for arg in "$@"; do if [[ "$arg" == ls-remote || "$arg" == fetch ]]; then diff --git a/cmd/entire/cli/checkpoint/remote/git_test.go b/cmd/entire/cli/checkpoint/remote/git_test.go index 869bb7c9a7..c20d616780 100644 --- a/cmd/entire/cli/checkpoint/remote/git_test.go +++ b/cmd/entire/cli/checkpoint/remote/git_test.go @@ -73,6 +73,17 @@ func TestResolveTargetForTokenAuth(t *testing.T) { assert.Equal(t, ProtocolHTTPS, proto) }) + t.Run("git+ssh alias URL rewrites to HTTPS", func(t *testing.T) { + t.Parallel() + // This call site reaches deriveTokenOriginURL only through its own + // ProtocolSSH check, so an alias admitted in that helper's allow-list + // alone would still reach newCommand's default branch: no token, and + // not even the SSH path's warning. + got, proto := resolveTargetForTokenAuth(ctx, "git+ssh://git@github.com/org/repo.git") + assert.Equal(t, "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/org/repo.git", got) + assert.Equal(t, ProtocolHTTPS, proto) + }) + t.Run("local path returns empty protocol", func(t *testing.T) { t.Parallel() got, proto := resolveTargetForTokenAuth(ctx, "/tmp/some-bare-repo") diff --git a/cmd/entire/cli/checkpoint/remote/shallow_parity_test.go b/cmd/entire/cli/checkpoint/remote/shallow_parity_test.go new file mode 100644 index 0000000000..6da08096d8 --- /dev/null +++ b/cmd/entire/cli/checkpoint/remote/shallow_parity_test.go @@ -0,0 +1,76 @@ +package remote + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/execx" + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/entireio/cli/cmd/entire/cli/testutil/gitenv" + "github.com/stretchr/testify/require" +) + +func TestIsShallowRepository_Layouts(t *testing.T) { + gitenv.IsolateRepository(t) + origin, clone := setupShallowClone(t) + full := t.TempDir() + testutil.InitRepo(t, full) + t.Chdir(full) + require.False(t, isShallowRepository(t.Context(), ""), "empty Dir uses CWD") + require.True(t, isShallowRepository(t.Context(), clone), "explicit Dir wins over CWD") + + linked := filepath.Join(t.TempDir(), "linked") + testutil.RunGit(t, clone, "worktree", "add", "--detach", linked) + require.True(t, isShallowRepository(t.Context(), linked), "linked worktree shares shallow boundaries") + t.Chdir(linked) + require.True(t, isShallowRepository(t.Context(), "")) + require.False(t, isShallowRepository(t.Context(), full)) + + bare := filepath.Join(t.TempDir(), "bare.git") + testutil.RunGit(t, full, "clone", "--bare", "--depth=1", "--branch", "main", "file://"+origin, bare) + require.True(t, isShallowRepository(t.Context(), bare)) + testutil.RunGit(t, clone, "fetch", "--unshallow", "origin") + require.False(t, isShallowRepository(t.Context(), clone)) + require.False(t, isShallowRepository(t.Context(), linked), "must observe removed common shallow state") +} + +func TestIsShallowRepository_FailureAndFileSemantics(t *testing.T) { + for _, state := range []string{"not a repository", "canceled", "empty file", "malformed file"} { + t.Run(state, func(t *testing.T) { + gitenv.IsolateRepository(t) + dir := t.TempDir() + t.Chdir(dir) + if state != "not a repository" { + testutil.InitRepo(t, dir) + } + ctx := t.Context() + switch state { + case "empty file", "malformed file": + content := "" + if state == "malformed file" { + content = "not an object id\n" + } + require.NoError(t, os.WriteFile(filepath.Join(dir, ".git", "shallow"), []byte(content), 0o600)) + case "canceled": + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + } + cmd := execx.NonInteractive(ctx, "git", "rev-parse", "--is-shallow-repository") + cmd.Dir = dir + cmd.Env = testutil.GitIsolatedEnv() + out, err := cmd.Output() + want := err == nil && strings.TrimSpace(string(out)) == "true" + require.Equal(t, want, isShallowRepository(ctx, dir)) + // The existing best-effort bool API deliberately swallows failed reads. + if state == "not a repository" || state == "canceled" || state == "malformed file" { + require.False(t, want) + } else { + require.True(t, want, "native Git treats an existing empty shallow file as shallow") + } + }) + } +} diff --git a/cmd/entire/cli/checkpoint/remote/util.go b/cmd/entire/cli/checkpoint/remote/util.go index 912e3c89d6..5da6775a22 100644 --- a/cmd/entire/cli/checkpoint/remote/util.go +++ b/cmd/entire/cli/checkpoint/remote/util.go @@ -21,6 +21,8 @@ const originRemote = "origin" const ( ProtocolSSH = gitremote.ProtocolSSH ProtocolHTTPS = gitremote.ProtocolHTTPS + ProtocolHTTP = gitremote.ProtocolHTTP + ProtocolGit = gitremote.ProtocolGit ProtocolEntire = gitremote.ProtocolEntire ) @@ -731,6 +733,37 @@ func isDirectGitTransport(protocol string) bool { return protocol == ProtocolSSH || protocol == ProtocolHTTPS } +// isTokenRewritableTransport reports whether an origin on this protocol may be +// rewritten into a token-bearing HTTPS URL. It is an allow-list because the +// returned URL is one a checkpoint token gets attached to: an unrecognized +// scheme must fail closed rather than inherit the rewrite. +// +// Every admitted scheme names the git host itself, so "https://" is +// the same repository reached over a transport the token can authenticate — +// the upgrade this function exists to perform. It is wider than +// isDirectGitTransport, which answers whether a remote is usable as configured +// and so excludes the two schemes that have to be upgraded first: http:// and +// git:// carry no credential of their own, and rewriting them moves the token +// onto HTTPS instead of that host's cleartext port. +// +// These are transports, not spellings. git's git+ssh:// and ssh+git:// arrive +// as ProtocolSSH (gitremote.normalizeProtocol) and so need no case of their +// own; a case here would admit them while every call site that switches on +// ProtocolSSH stayed blind to them. +// +// entire:// and file:// are the exclusions that matter. An entire:// host is an +// Entire cluster rather than a git endpoint, so the rewrite invents an HTTPS +// git URL on a host that serves none and sends the token there; file:// names +// no host at all. +func isTokenRewritableTransport(protocol string) bool { + switch protocol { + case ProtocolSSH, ProtocolHTTPS, ProtocolHTTP, ProtocolGit: + return true + default: + return false + } +} + func deriveCheckpointURLFromInfo(info *Info, config *settings.CheckpointRemoteConfig) (string, error) { switch info.Protocol { case ProtocolSSH: @@ -870,6 +903,13 @@ func deriveTokenOriginURL(originURL string) (string, bool) { if err != nil { return "", false } + // The guard lives here rather than at the call sites because most callers + // gate only on the token being set. resolveTargetForTokenAuth checks the + // protocol before calling and stays correct with the check duplicated. + // See COR-1892 for the analysis. + if !isTokenRewritableTransport(info.Protocol) { + return "", false + } if info.Host == "" || info.Owner == "" || info.Repo == "" { return "", false } diff --git a/cmd/entire/cli/checkpoint/remote/util_test.go b/cmd/entire/cli/checkpoint/remote/util_test.go index 701fbb215c..bbe9f5047c 100644 --- a/cmd/entire/cli/checkpoint/remote/util_test.go +++ b/cmd/entire/cli/checkpoint/remote/util_test.go @@ -10,6 +10,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/testutil" "github.com/go-git/go-git/v6" + "github.com/stretchr/testify/require" ) func TestFetchURL(t *testing.T) { @@ -80,6 +81,29 @@ func TestFetchURL(t *testing.T) { token: "secret-token", wantURL: "https://git.example.com:8443/acme/app.git", }, + { + // The seam, not the helper: FetchURL is one of the five callers + // that gate on the token alone, so this fails if the transport + // guard moves back out of deriveTokenOriginURL or a new caller + // rewrites the URL itself. An entire:// host is an Entire cluster, + // and coercing it to HTTPS made it the fetch target with the + // token attached. See COR-1892. + name: "token does not coerce entire:// origin to https", + originURL: "entire://aws-us-east-2.entire.io/gh/acme/app", + settingsJSON: `{"enabled":true}`, + token: "secret-token", + wantURL: "entire://aws-us-east-2.entire.io/gh/acme/app", + }, + { + // The other half of that seam: an origin git dials over ssh must + // reach the token whichever of git's three ssh spellings named it. + // Left as configured, the fetch carries no credential at all. + name: "token coerces a git+ssh origin to https", + originURL: "git+ssh://git@github.com/acme/app.git", + settingsJSON: `{"enabled":true}`, + token: "secret-token", + wantURL: "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/acme/app.git", + }, } for _, tt := range tests { @@ -479,6 +503,22 @@ func TestPushURL(t *testing.T) { wantURL: "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/acme/checkpoints.git", wantEnabled: true, }, + { + // The push side of the same coercion (isDirectGitTransport), + // reached through a spelling of ssh:// rather than ssh:// itself. + // The host is deliberately not github.com: an unrecognized + // transport fails the coercion, fails deriveCheckpointURLFromInfo, + // and lands on the provider-host fallback — which on github.com + // returns the right URL by accident and pins nothing. Here that + // fallback sends an enterprise host's checkpoints to github.com. + name: "token forces https for push url with git+ssh remote", + originURL: "git+ssh://git@ghe.example.com/acme/app.git", + pushRemote: "origin", + settingsJSON: `{"enabled":true,"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"acme/checkpoints"}}}`, + token: "push-token", + wantURL: "https://ghe.example.com/acme/checkpoints.git", + wantEnabled: true, + }, { name: "token drops ssh port when coercing ssh origin to https", originURL: "ssh://git@git.example.com:2222/acme/app.git", @@ -958,6 +998,15 @@ func TestDeriveCheckpointURLFromInfo(t *testing.T) { checkpointRepo: "org/checkpoints", want: "git@github.com:org/checkpoints.git", }, + { + // Same transport as the ssh:// row above, so it must derive the + // same checkpoint URL. Unnormalized it reaches the switch's + // default and errors out. + name: "git+ssh alias push remote", + pushRemoteURL: "git+ssh://git@github.com/org/main-repo.git", + checkpointRepo: "org/checkpoints", + want: "git@github.com:org/checkpoints.git", + }, { name: "different host", pushRemoteURL: "git@github.example.com:org/main-repo.git", @@ -1038,3 +1087,68 @@ func TestDeriveCheckpointURLFromInfo(t *testing.T) { }) } } + +// TestDeriveTokenOriginURL_RefusesNonGitHostTransports pins that an origin whose +// host is not the git host is never rewritten into a token-bearing HTTPS URL. +// +// This is a credential guard, not URL hygiene: the URL this function returns +// is one a checkpoint token will be attached to, so a transport the token +// should never reach must not produce one. An entire:// remote names a +// cluster rather than a git endpoint, and file:// names no host at all. +// +// Most callers gate only on the token being set, never on protocol, which is +// why the guard belongs in the function rather than at each call site. +// See COR-1892 for the analysis. +// +// The file:// case would also be refused by the empty-host check further down, +// so it does not discriminate on its own; the entire:// cases are the ones +// that fail if the guard is removed. +func TestDeriveTokenOriginURL_RefusesNonGitHostTransports(t *testing.T) { + t.Parallel() + for _, rawURL := range []string{ + "entire://aws-us-east-2.entire.io/et/acme/app", + "entire://aws-us-east-2.entire.io/gh/acme/app", + "file:///srv/mirrors/app.git", + } { + t.Run(rawURL, func(t *testing.T) { + t.Parallel() + got, ok := deriveTokenOriginURL(rawURL) + require.False(t, ok, "a non-direct transport must not be rewritten into a token-bearing URL") + require.Empty(t, got) + }) + } +} + +// TestDeriveTokenOriginURL_RewritesGitHostTransports pins that the guard did not +// disturb the case the function exists for. +// +// http:// and git:// are here because the guard is an allow-list and they were +// rewritable before it existed: their host is the git host, so dropping them +// would have turned checkpoint auth on such a remote into a bare 401 — the +// token is simply not injected, and no call site on that path warns. +func TestDeriveTokenOriginURL_RewritesGitHostTransports(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + in string + want string + }{ + {name: "scp ssh", in: "git@github.com:acme/app.git", want: "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/acme/app.git"}, + {name: "https", in: "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/acme/app.git", want: "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/acme/app.git"}, + {name: "https with port", in: "https://ghe.example.com:8443/acme/app.git", want: "https://ghe.example.com:8443/acme/app.git"}, + {name: "http upgrades to https", in: "http://git.example.com/acme/app.git", want: "https://git.example.com/acme/app.git"}, + {name: "git upgrades to https", in: "git://git.example.com/acme/app.git", want: "https://git.example.com/acme/app.git"}, + // git's own ssh aliases: an ordinary SSH remote the token can + // authenticate over HTTPS, so refusing them was the same silent + // no-credential fetch as dropping http:// would be. + {name: "git+ssh alias", in: "git+ssh://git@github.com/acme/app.git", want: "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/acme/app.git"}, + {name: "ssh+git alias drops the ssh port", in: "ssh+git://git@git.example.com:2222/acme/app.git", want: "https://git.example.com/acme/app.git"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, ok := deriveTokenOriginURL(tc.in) + require.True(t, ok) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/cmd/entire/cli/checkpoint/subagent_transcript.go b/cmd/entire/cli/checkpoint/subagent_transcript.go index d180394a4d..f844df2815 100644 --- a/cmd/entire/cli/checkpoint/subagent_transcript.go +++ b/cmd/entire/cli/checkpoint/subagent_transcript.go @@ -28,9 +28,9 @@ import ( // large (there is no chunked form to fall back to), so it warns rather than failing // the checkpoint, which still records the subagent's files and metadata. // -// The agent type must be passed in, not detected: DetectAgentTypeFromContent only -// recognizes Gemini, so content-based detection would silently make this a no-op for -// Codex — the one agent that actually needs sanitizing. +// The agent type must be passed in, not detected: nothing recovers it from content, +// and without it this would silently be a no-op for Codex — the one agent that +// actually needs sanitizing. func prepareSubagentTranscript(ctx context.Context, agentType types.AgentType, path string, content []byte) (prepared []byte, tooLarge bool) { // Sanitize first, then measure. The size that matters is what would be stored, // and sanitizing strips the bulk: Codex encrypted_content runs to ~20% of a diff --git a/cmd/entire/cli/cluster_list.go b/cmd/entire/cli/cluster_list.go index 8a4e260d59..52acc71dd7 100644 --- a/cmd/entire/cli/cluster_list.go +++ b/cmd/entire/cli/cluster_list.go @@ -16,7 +16,7 @@ import ( // jurisdiction slug `org create` and `project create` name with --region, // CLUSTER is the placement slug `repo mirror list --cluster` filters on, and // HOST is what every targeting --cluster takes (`repo mirror add`, `repo mirror -// remove`, `repo access list`, `repo clone`, `repo remote use`) as well as the +// remove`, `repo clone`, `repo remote add`) as well as the // host in an entire:// clone URL. The catalog's apiUrl is --json only: the CLI // dials the API URL itself. var clusterColumns = []string{colHeaderRegion, colHeaderCluster, "HOST"} diff --git a/cmd/entire/cli/commit_message_test.go b/cmd/entire/cli/commit_message_test.go index 62bc052795..28a521b486 100644 --- a/cmd/entire/cli/commit_message_test.go +++ b/cmd/entire/cli/commit_message_test.go @@ -236,10 +236,10 @@ func TestGenerateCommitMessage(t *testing.T) { expected: "Cursor session updates", }, { - name: "returns Gemini CLI fallback for empty prompt", + name: "returns Copilot CLI fallback for empty prompt", prompt: "", - agentType: agent.AgentTypeGemini, - expected: "Gemini CLI session updates", + agentType: agent.AgentTypeCopilotCLI, + expected: "Copilot CLI session updates", }, { name: "returns OpenCode fallback for empty prompt", diff --git a/cmd/entire/cli/corecmd.go b/cmd/entire/cli/corecmd.go index 159cb3ec49..a6ddfb618b 100644 --- a/cmd/entire/cli/corecmd.go +++ b/cmd/entire/cli/corecmd.go @@ -89,16 +89,16 @@ func forceRequested(cmd *cobra.Command) bool { func runControlPlaneDelete( cmd *cobra.Command, noun, ref string, - resolve func(context.Context, *coreapi.Client) (string, error), + resolve func(context.Context, *coreapi.Client) (resolvedRef, error), del func(context.Context, *coreapi.Client, string) error, ) error { force := forceRequested(cmd) return runCore(cmd, func(ctx context.Context, c *coreapi.Client) error { - id, err := resolve(ctx, c) + resolved, err := resolve(ctx, c) if err != nil { return err } - label := noun + " " + resolvedRefLabel(ref, id) + label := noun + " " + resolvedRefLabel(ref, resolved) proceed, err := confirmControlPlaneDeletion(ctx, cmd.OutOrStdout(), label, force, interactive.CanPromptInteractively()) if err != nil { return err @@ -106,7 +106,7 @@ func runControlPlaneDelete( if !proceed { return nil } - if err := del(ctx, c, id); err != nil { + if err := del(ctx, c, resolved.ID); err != nil { // Idempotent delete: a resource that's already gone (a 404 from the // delete call — e.g. a ULID passed straight through, or a concurrent // delete) is the desired end state, not an error. @@ -172,9 +172,13 @@ func runCoreList[T any](cmd *cobra.Command, empty string, headers []string, row // listView is how a list renders once its items are known, for the command // whose output depends on what came back. table is required and picks the // headers and row function; toJSON is optional and, when set, replaces the -// raw wire model on --json — it must be additive-only, merging synthesized -// fields into the marshalled objects (see mergeSynthesizedField) and never -// dropping or overriding a server field. +// raw wire model on --json — it merges synthesized fields into the marshalled +// objects (see mergeSynthesizedField) and never overrides a server value. It +// may drop a server key only by renaming it: where a synthesized key carries +// the very string the server sent under another name, printing both says one +// value twice (see mirrorCollaboratorJSON, which renames `accountId` to the +// `granteeId` its sibling listing uses). Dropping a value outright is not the +// same thing, and is not allowed. type listView[T any] struct { table func(items []T) (headers []string, row func(T) []string) toJSON func(items []T) (any, error) @@ -187,17 +191,16 @@ func runCoreListShaped[T any](cmd *cobra.Command, empty string, view listView[T] return runCore(cmd, renderCoreListShaped(cmd, empty, view, fn)) } -// runCoreListForCluster is runCoreList for a resource-provider command (see -// runCoreForCluster): identical table/JSON/empty-state rendering, but dialing -// the core that fronts clusterHost rather than the active context. -func runCoreListForCluster[T any](cmd *cobra.Command, clusterHost, empty string, headers []string, row func(T) []string, fn func(ctx context.Context, c *coreapi.Client) ([]T, error)) error { - return runCoreForCluster(cmd, clusterHost, renderCoreList(cmd, empty, headers, row, fn)) +// runCoreListShapedForCluster is runCoreListShaped for a resource-provider +// command (see runCoreForCluster): the same rendering decided after the fetch, +// dialing the core that fronts clusterHost rather than the active context. +func runCoreListShapedForCluster[T any](cmd *cobra.Command, clusterHost, empty string, view listView[T], fn func(ctx context.Context, c *coreapi.Client) ([]T, error)) error { + return runCoreForCluster(cmd, clusterHost, renderCoreListShaped(cmd, empty, view, fn)) } -// renderCoreList builds the run-function shared by runCoreList and -// runCoreListForCluster for a table with fixed columns. Kept separate from the -// client-selection so the two list variants differ only in which core they -// dial. +// renderCoreList builds the run-function runCoreList uses for a table with +// fixed columns. Kept separate from the client-selection so a list variant +// differs from another only in which core it dials. func renderCoreList[T any](cmd *cobra.Command, empty string, headers []string, row func(T) []string, fn func(ctx context.Context, c *coreapi.Client) ([]T, error)) func(context.Context, *coreapi.Client) error { view := listView[T]{table: func([]T) ([]string, func(T) []string) { return headers, row }} return renderCoreListShaped(cmd, empty, view, fn) @@ -647,7 +650,7 @@ func runCore(cmd *cobra.Command, fn func(ctx context.Context, c *coreapi.Client) } // runCoreForCluster is runCore for resource-provider commands addressed at a -// specific cluster (mirror add/remove, access list): +// specific cluster (mirror add/remove, `repo grant list` of a mirror ref): // it dials the core that fronts clusterHost — discovered from the cluster's // /.well-known/entire-cluster.json, authenticating with the matching local // context — instead of the active context. So the command works on a cluster in diff --git a/cmd/entire/cli/corecmd_json_flag_test.go b/cmd/entire/cli/corecmd_json_flag_test.go index 0a51f043b6..19b7ba3d26 100644 --- a/cmd/entire/cli/corecmd_json_flag_test.go +++ b/cmd/entire/cli/corecmd_json_flag_test.go @@ -40,16 +40,14 @@ func TestControlPlaneJSONFlag_OnlyOnHonoringCommands(t *testing.T) { "repo edit": true, "repo delete": false, "repo clone": false, - "repo remote url": false, "repo mirror add": false, "repo mirror list": true, "repo mirror get": true, "repo mirror remove": false, - // `remote use` writes local git config and reports what it changed; + // `remote add` writes local git config and reports what it changed; // there is no object to render, so it stays off the --json surface like // the other side-effect verbs. - "repo remote use": false, - "repo access list": true, + "repo remote add": false, "repo visibility get": true, // add/remove print the resulting rule list, so they render JSON too. "repo protection list": true, diff --git a/cmd/entire/cli/corecmd_test.go b/cmd/entire/cli/corecmd_test.go index b23fd7bc74..2536c42515 100644 --- a/cmd/entire/cli/corecmd_test.go +++ b/cmd/entire/cli/corecmd_test.go @@ -278,3 +278,47 @@ func TestPrintFields(t *testing.T) { t.Errorf("printFields output:\n%q\nwant:\n%q", got, want) } } + +// TestResolvedRefLabel pins the delete confirmation line: whenever a lookup +// happened, the line names what the server resolved rather than what the user +// typed. +func TestResolvedRefLabel(t *testing.T) { + t.Parallel() + const id = "01M3427PK3T21NMN3N7Q1EHBG1" + for _, tc := range []struct { + name string + ref string + r resolvedRef + want string + }{ + { + name: "path ref names the resolved repo", + ref: "/et/audit1/victim.git", + r: resolvedRef{ID: id, Name: "/et/audit1/victim.git"}, + want: "/et/audit1/victim.git (" + id + ")", + }, + { + name: "bare name ref names the resolved repo", + ref: "victim.git", + r: resolvedRef{ID: id, Name: "/et/audit1/victim.git"}, + want: "/et/audit1/victim.git (" + id + ")", + }, + { + name: "ulid ref stands alone", + ref: id, + r: resolvedRef{ID: id}, + want: id, + }, + { + name: "no server name falls back to the typed ref", + ref: "acme", + r: resolvedRef{ID: id}, + want: "acme (" + id + ")", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, resolvedRefLabel(tc.ref, tc.r)) + }) + } +} diff --git a/cmd/entire/cli/deprecated_strings_test.go b/cmd/entire/cli/deprecated_strings_test.go index e00c174fcc..a53ddd8868 100644 --- a/cmd/entire/cli/deprecated_strings_test.go +++ b/cmd/entire/cli/deprecated_strings_test.go @@ -36,8 +36,11 @@ func TestNoDeprecatedCommandFormsInUserFacingStrings(t *testing.T) { "entire auth use", // → entire auth switch "entire repo get", // → entire repo view "entire repo mirror create", // → entire repo mirror add - "entire repo mirror use", // → entire repo remote use - "entire repo mirror collaborators", // → entire repo access + "entire repo mirror use", // → entire repo remote add + "entire repo remote use", // → entire repo remote add + "entire repo remote url", // → removed; entire repo mirror get lists a URL per cluster + "entire repo mirror collaborators", // → entire repo grant list + "entire repo access", // → entire repo grant list "entire repo visibility set", // → entire repo edit --visibility // The grant family moved under its nouns; the old spelling is gone. "entire grant org", // → entire org grant diff --git a/cmd/entire/cli/dispatch_test.go b/cmd/entire/cli/dispatch_test.go index 1161d63ede..2d1b1173c8 100644 --- a/cmd/entire/cli/dispatch_test.go +++ b/cmd/entire/cli/dispatch_test.go @@ -638,12 +638,12 @@ func TestDispatchWizard_LocalWithoutConfiguredAgentPromptsAndPersistsSelection(t } discoverSummaryProvidersAlways = func(context.Context) {} listRegisteredAgents = func() []types.AgentName { - return []types.AgentName{agent.AgentNameCodex, agent.AgentNameGemini} + return []types.AgentName{agent.AgentNameCodex, agent.AgentNameCursor} } getSummaryAgent = func(name types.AgentName) (agent.Agent, error) { kind := agent.AgentTypeCodex - if name == agent.AgentNameGemini { - kind = agent.AgentTypeGemini + if name == agent.AgentNameCursor { + kind = agent.AgentTypeCursor } return &stubTextAgent{name: name, kind: kind}, nil } @@ -651,10 +651,10 @@ func TestDispatchWizard_LocalWithoutConfiguredAgentPromptsAndPersistsSelection(t canPromptForSummaryProvider = func() bool { return true } promptSummaryProvider = func(providers []checkpointSummaryProvider) (types.AgentName, error) { calls = append(calls, "picker") - if len(providers) != 2 || providers[0].Name != agent.AgentNameCodex || providers[1].Name != agent.AgentNameGemini { - t.Fatalf("picker providers = %+v, want enabled codex and gemini", providers) + if len(providers) != 2 || providers[0].Name != agent.AgentNameCodex || providers[1].Name != agent.AgentNameCursor { + t.Fatalf("picker providers = %+v, want enabled codex and cursor", providers) } - return agent.AgentNameGemini, nil + return agent.AgentNameCursor, nil } var persistedProvider string saveLocalSummarySettings = func(_ context.Context, s *settings.EntireSettings) error { @@ -666,8 +666,8 @@ func TestDispatchWizard_LocalWithoutConfiguredAgentPromptsAndPersistsSelection(t runDispatch = func(_ context.Context, opts dispatchpkg.Options) (*dispatchpkg.Dispatch, error) { calls = append(calls, "dispatch") selected, ok := opts.TextGenerator.(*stubTextAgent) - if !ok || selected.name != agent.AgentNameGemini { - t.Fatalf("dispatch generator = %#v, want selected gemini agent", opts.TextGenerator) + if !ok || selected.name != agent.AgentNameCursor { + t.Fatalf("dispatch generator = %#v, want selected cursor agent", opts.TextGenerator) } return &dispatchpkg.Dispatch{}, nil } @@ -681,8 +681,8 @@ func TestDispatchWizard_LocalWithoutConfiguredAgentPromptsAndPersistsSelection(t if got := strings.Join(calls, ","); got != "wizard,picker,dispatch" { t.Fatalf("call order = %q, want wizard,picker,dispatch", got) } - if persistedProvider != string(agent.AgentNameGemini) { - t.Fatalf("persisted provider = %q, want %q", persistedProvider, agent.AgentNameGemini) + if persistedProvider != string(agent.AgentNameCursor) { + t.Fatalf("persisted provider = %q, want %q", persistedProvider, agent.AgentNameCursor) } } diff --git a/cmd/entire/cli/doctor.go b/cmd/entire/cli/doctor.go index bbbf009690..8ab444d2cc 100644 --- a/cmd/entire/cli/doctor.go +++ b/cmd/entire/cli/doctor.go @@ -8,6 +8,7 @@ import ( "io/fs" "log/slog" "os" + "os/exec" "path" "path/filepath" "slices" @@ -16,6 +17,7 @@ import ( "charm.land/huh/v2" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/antigravity" "github.com/entireio/cli/cmd/entire/cli/agent/codex" "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/checkpoint" @@ -64,12 +66,16 @@ Checks performed: no longer fire, or a committed Pi/OpenCode extension has gone stale). Fix by re-running 'entire enable --force'. - 5. Summary provider: warn when summary_generation.provider names a registered + 5. Retired Gemini CLI hooks: remove Entire hook entries left in + .gemini/settings.json. Gemini CLI support was removed; the entries now do + nothing but run a no-op on every Gemini event. + + 6. Summary provider: warn when summary_generation.provider names a registered agent that cannot generate text (e.g. factoryai-droid), which makes 'entire checkpoint explain --generate', 'entire dispatch' and 'entire runner setup' fail. Reports the file to change; does not rewrite it. - 6. Stuck sessions: sessions stuck in ACTIVE or ENDED phase that need cleanup. + 7. Stuck sessions: sessions stuck in ACTIVE or ENDED phase that need cleanup. A session is considered stuck if: - It is in ACTIVE phase with no interaction for over 1 hour @@ -168,6 +174,12 @@ func runSessionsFix(cmd *cobra.Command, force bool) error { // Agent-specific: Codex hook trust state. checkCodexHookTrust(cmd) + // Agent-specific: Antigravity title-tee (token-usage surface). + checkAntigravityTitleTee(cmd) + + // Agent-specific: does agy actually load the workspace hooks? + checkAntigravityHooksLoaded(cmd) + // Agent-specific: Claude Code hook config drift. checkHookDrift(cmd) @@ -175,6 +187,10 @@ func runSessionsFix(cmd *cobra.Command, force bool) error { // Fixes rather than only reporting: what it removes is a rule Entire wrote. checkRetiredDenyRule(cmd) + // Hooks left by removed Gemini CLI support. Fixes rather than only + // reporting, for the same reason: every entry it removes is Entire's own. + checkRetiredGeminiHooks(cmd) + // A configured summary provider that cannot generate text. After the hook // checks: it breaks three commands, not capture, so it is the milder fault. checkSummaryProvider(cmd) @@ -769,7 +785,7 @@ func printCappedList(w io.Writer, names []string, render func(string) string) { // checkAgentDirSymlinks reports a symlink at any directory component Entire // creates or writes through for an agent: the agents' own config directories -// (.claude, .codex, .cursor, .gemini, .factory, .opencode, .pi, .github/hooks) +// (.claude, .codex, .cursor, .factory, .opencode, .pi, .github/hooks) // and the managed skill scaffolds' parents (.claude/skills, .codex/agents, ...). // // The condition is otherwise invisible after the fact. `entire enable` fails @@ -1084,7 +1100,7 @@ func agentSymlinkCheckPaths() []string { // The pre-skill subagent Entire scaffolded and now deletes. Uninstall // goes through osroot.LstatNoSymlinks, which refuses a symlinked parent, // so .claude/agents/ has to be here or a link there is refused with - // nothing said about it. .codex/agents and .gemini/agents were already + // nothing said about it. .codex/agents was already // covered, but only as a side effect of the agent-help template living // under them. add(legacySearchSubagentPath(name)) @@ -1334,6 +1350,33 @@ func checkRetiredDenyRule(cmd *cobra.Command) { } } +// checkRetiredGeminiHooks removes the Entire hook entries Gemini CLI support +// installed in .gemini/settings.json. Nothing else will: that agent is no +// longer registered, so `entire enable`, `entire agent remove` and the uninstall +// sweep over installed agents never visit its config. The user's own hooks and +// settings in the file are kept. +func checkRetiredGeminiHooks(cmd *cobra.Command) { + ctx := cmd.Context() + w := cmd.OutOrStdout() + worktreeRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + return // no repository: nothing to check + } + changed, err := removeRetiredGeminiHooks(worktreeRoot) + if err != nil { + fmt.Fprintln(w, "Gemini CLI hooks: CHECK FAILED") + fmt.Fprintf(w, " Could not remove Entire hooks left by removed Gemini CLI support: %v\n", err) + fmt.Fprintf(w, " Delete the entries running 'entire hooks gemini ...' from %s by hand.\n", retiredGeminiHookConfigRelPath) + return + } + if changed { + fmt.Fprintln(w, "Gemini CLI hooks: RETIRED") + fmt.Fprintf(w, " Entire no longer supports Gemini CLI, but %s still ran Entire hooks.\n", retiredGeminiHookConfigRelPath) + fmt.Fprintln(w, " ✓ Fixed: Entire's entries removed (your other hooks and settings are untouched).") + fmt.Fprintln(w, " The settings file changed — commit or revert it as you prefer.") + } +} + // checkSummaryProvider reports a configured summary_generation.provider naming // a registered agent that cannot generate text. See // unsupportedSummaryProviderError for how such a value gets written. @@ -1368,14 +1411,21 @@ func checkSummaryProvider(cmd *cobra.Command) { name := types.AgentName(s.SummaryGeneration.Provider) _, registered, capable := summaryCapableAgent(name) - if !registered || capable { + // The retired name is unregistered, but unlike a plugin's it is known not + // to be coming back, so it is reported rather than left to the resolver. + retired := !registered && name == retiredGeminiAgentName && !retiredGeminiNameClaimed() + if (!registered && !retired) || capable { return } w := cmd.OutOrStdout() sourceFile, isLocal := summaryProviderSourceLayer(ctx, s) fmt.Fprintln(w, "Summary provider: UNUSABLE") - fmt.Fprintf(w, " summary_generation.provider is %q in %s, which cannot generate text.\n", name, sourceFile) + if retired { + fmt.Fprintf(w, " summary_generation.provider is %q in %s, but Gemini CLI is no longer supported.\n", name, sourceFile) + } else { + fmt.Fprintf(w, " summary_generation.provider is %q in %s, which cannot generate text.\n", name, sourceFile) + } fmt.Fprintln(w, " `entire checkpoint explain --generate`, `entire dispatch`, and") fmt.Fprintln(w, " `entire runner setup` all fail while it is set.") // The command names an INSTALLED provider, not merely a capable one. @@ -1543,6 +1593,108 @@ func writeCodexHookStatus(w io.Writer, diagnostics codex.HookDiagnostics, active } } +// antigravityDoctorSubject gates both Antigravity checks the same way: they +// only apply where Entire's Antigravity hooks are installed and switched on +// AND agy is on PATH. A teammate's checkout can carry the hooks on a machine +// that never uses agy; reporting there would be a false positive. One gate, +// evaluated once. +// +// The "enabled": false check is here rather than in AreHooksInstalled because +// that predicate also drives agent auto-detection and `entire agent list`, +// where an entry the user switched off is still genuinely present. Only +// doctor's advice is unwanted for a configuration nobody asked to run. +func antigravityDoctorSubject(cmd *cobra.Command) (*antigravity.AntigravityAgent, bool) { + ag := &antigravity.AntigravityAgent{} + installed, err := ag.AreHooksInstalled(cmd.Context()) + if err != nil || !installed { + return nil, false + } + if disabled, err := ag.HooksDisabled(cmd.Context()); err != nil || disabled { + return nil, false + } + if _, err := exec.LookPath("agy"); err != nil { + return nil, false + } + return ag, true +} + +// checkAntigravityTitleTee warns when Antigravity hooks are installed in this +// repo but agy's global title slot — agy's only token-usage surface — is not +// routed through Entire, which leaves token counts missing from checkpoints. +// Warn-only. +func checkAntigravityTitleTee(cmd *cobra.Command) { + if _, ok := antigravityDoctorSubject(cmd); !ok { + return + } + w := cmd.OutOrStdout() + if antigravity.TitleTeeInstalled() { + fmt.Fprintln(w, "✓ Antigravity title-tee: OK") + return + } + + fmt.Fprintln(w, "Antigravity title-tee: NOT CONFIGURED") + fmt.Fprintln(w, " agy's title command isn't routed through Entire, so token counts") + fmt.Fprintln(w, " will be missing for Antigravity checkpoints.") + fmt.Fprintln(w, " Re-run agent setup (`entire agent add antigravity`) to configure it.") +} + +// checkAntigravityHooksLoaded reports two things about the installed hooks. +// +// Always, at zero cost: whether the "entire" entry in .agents/hooks.json is +// the one this host needs. The command's shape is host-specific — agy runs it +// through cmd.exe on Windows and sh elsewhere — and a file committed from a +// macOS checkout carries a sh wrapper that cmd.exe tears apart: the hook exits +// 1, the failure shows only in agy's log, the turn reports SUCCESS and nothing +// is tracked. The file being present said nothing about that, and a green +// doctor over zero tracked sessions is worse than no check. +// +// Only when ENTIRE_ANTIGRAVITY_DOCTOR_PROBE=1: ask agy itself whether it loads +// this workspace's hooks (`agy -p /hooks --add-dir `), which catches the +// untrusted-workspace trap. Opt-in because agy 1.2.7 on Windows was observed +// to answer that with a full model turn, and the probe must never spend the +// user's quota by default. Warn-only throughout. +func checkAntigravityHooksLoaded(cmd *cobra.Command) { + ag, ok := antigravityDoctorSubject(cmd) + if !ok { + return + } + w := cmd.OutOrStdout() + + if installed, current, err := ag.HooksEntryMatchesHost(cmd.Context()); err == nil && installed && !current { + fmt.Fprintln(w, "Antigravity hooks: STALE FOR THIS HOST") + fmt.Fprintln(w, " The \"entire\" entry in .agents/hooks.json is not the command this host") + fmt.Fprintln(w, " needs (agy runs hooks through cmd.exe on Windows and sh elsewhere), so") + fmt.Fprintln(w, " the hooks fail silently and nothing is tracked.") + fmt.Fprintln(w, " Re-run `entire agent add antigravity` to reinstall them for this host.") + } + + if os.Getenv(antigravity.DoctorProbeEnv) == "" { + return + } + repoRoot, err := paths.WorktreeRoot(cmd.Context()) + if err != nil { + return + } + probe, err := antigravity.ProbeLoadedHooks(cmd.Context(), repoRoot) + switch { + case errors.Is(err, antigravity.ErrHooksProbeVersionUnknown): + fmt.Fprintf(w, "Antigravity hooks: NOT VERIFIED (could not determine the agy version from %q; skipping the `/hooks` probe)\n", + probe.Version) + case errors.Is(err, antigravity.ErrHooksProbeUnsupported): + fmt.Fprintf(w, "Antigravity hooks: NOT VERIFIED (agy %s is too old to answer `/hooks` headlessly; %s+ needed — run `agy update`)\n", + probe.Version, antigravity.MinHooksProbeVersion) + case err != nil: + fmt.Fprintf(w, "Antigravity hooks: NOT VERIFIED (%v)\n", err) + case probe.Loaded: + fmt.Fprintln(w, "✓ Antigravity hooks: LOADED by agy") + default: + fmt.Fprintln(w, "Antigravity hooks: NOT LOADED by agy") + fmt.Fprintln(w, " .agents/hooks.json exists but agy does not load it for this workspace,") + fmt.Fprintln(w, " so Entire's hooks never fire. Trust the folder in an interactive `agy`") + fmt.Fprintln(w, " session, and pass `--add-dir ` to `agy -p` runs.") + } +} + func writeCodexInactiveWorktreeWarning(w io.Writer, worktreePath, discoveredPath string) { fmt.Fprintln(w, "Codex hooks: NOT ACTIVE IN THIS WORKTREE") fmt.Fprintln(w, " Entire hooks are configured at the current-worktree path:") diff --git a/cmd/entire/cli/doctor_test.go b/cmd/entire/cli/doctor_test.go index ec3225dfa8..76b7939217 100644 --- a/cmd/entire/cli/doctor_test.go +++ b/cmd/entire/cli/doctor_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/antigravity" "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" "github.com/entireio/cli/cmd/entire/cli/agent/codex" "github.com/entireio/cli/cmd/entire/cli/checkpoint" @@ -1351,6 +1352,102 @@ trusted_hash = "sha256:ccc" require.NotContains(t, out, "Codex hook trust: REVIEW NEEDED") } +// antigravityHooksJSON returns a minimal .agents/hooks.json declaring the +// Entire PreInvocation hook, enough for AreHooksInstalled to report true. +func antigravityHooksJSON() string { + return `{"entire":{"PreInvocation":[{"type":"command","command":"entire hooks antigravity pre-invocation"}]}}` +} + +// stubAgyOnPath prepends a directory containing a fake executable `agy` to +// PATH so the doctor check's binary-presence guard passes deterministically. +func stubAgyOnPath(t *testing.T) { + t.Helper() + binDir := t.TempDir() + stub := filepath.Join(binDir, "agy") + require.NoError(t, os.WriteFile(stub, []byte("#!/bin/sh\nexit 0\n"), 0o755)) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +// TestCheckAntigravityTitleTee_SilentWhenAgyNotInstalled stays quiet for +// developers who don't use agy at all: .agents/hooks.json is committable, so +// a teammate's checkout can have Antigravity hooks "installed" on a machine +// with no agy binary — warning there (and suggesting a repair that writes +// agy's global settings) is a false positive. +func TestCheckAntigravityTitleTee_SilentWhenAgyNotInstalled(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + + agentsDir := filepath.Join(dir, ".agents") + require.NoError(t, os.MkdirAll(agentsDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(agentsDir, "hooks.json"), + []byte(antigravityHooksJSON()), 0o600)) + t.Setenv("ENTIRE_ANTIGRAVITY_CONFIG_DIR", filepath.Join(t.TempDir(), "agy")) + t.Setenv("PATH", t.TempDir()) // no agy binary anywhere on PATH + + cmd, stdout := newTestCmd(t) + checkAntigravityTitleTee(cmd) + require.NotContains(t, stdout.String(), "Antigravity title-tee") +} + +// TestCheckAntigravityTitleTee_SilentWhenHooksNotInstalled stays quiet when +// the repo has no Antigravity hooks — nothing to check. +func TestCheckAntigravityTitleTee_SilentWhenHooksNotInstalled(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + t.Setenv("ENTIRE_ANTIGRAVITY_CONFIG_DIR", filepath.Join(t.TempDir(), "agy")) + + cmd, stdout := newTestCmd(t) + checkAntigravityTitleTee(cmd) + require.NotContains(t, stdout.String(), "Antigravity title-tee") +} + +// TestCheckAntigravityTitleTee_OKWhenConfigured reports OK when hooks are +// installed and agy's title slot routes through the title-tee shim. +func TestCheckAntigravityTitleTee_OKWhenConfigured(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + stubAgyOnPath(t) + + agentsDir := filepath.Join(dir, ".agents") + require.NoError(t, os.MkdirAll(agentsDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(agentsDir, "hooks.json"), + []byte(antigravityHooksJSON()), 0o600)) + + cfgDir := filepath.Join(t.TempDir(), "agy") + require.NoError(t, os.MkdirAll(cfgDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(cfgDir, "settings.json"), + []byte(`{"title":{"type":"command","command":"entire hooks antigravity title-tee"}}`), 0o600)) + t.Setenv("ENTIRE_ANTIGRAVITY_CONFIG_DIR", cfgDir) + + cmd, stdout := newTestCmd(t) + checkAntigravityTitleTee(cmd) + require.Contains(t, stdout.String(), "✓ Antigravity title-tee: OK") +} + +// TestCheckAntigravityTitleTee_WarnsWhenNotConfigured surfaces the missing +// token-usage surface when hooks are installed but the title slot is unclaimed. +func TestCheckAntigravityTitleTee_WarnsWhenNotConfigured(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + stubAgyOnPath(t) + + agentsDir := filepath.Join(dir, ".agents") + require.NoError(t, os.MkdirAll(agentsDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(agentsDir, "hooks.json"), + []byte(antigravityHooksJSON()), 0o600)) + + // Empty agy config dir — no title slot claimed. + t.Setenv("ENTIRE_ANTIGRAVITY_CONFIG_DIR", filepath.Join(t.TempDir(), "agy")) + + cmd, stdout := newTestCmd(t) + checkAntigravityTitleTee(cmd) + + out := stdout.String() + require.Contains(t, out, "Antigravity title-tee: NOT CONFIGURED") + require.Contains(t, out, "token counts") + require.Contains(t, out, "entire agent add antigravity") +} + // TestConfirmDoctorFix_CancelledContext verifies that a cancelled command // context makes the confirm prompt return (false, nil) rather than surfacing a // wrapped error — doctor fixes are skipped cleanly on interrupt. @@ -1480,6 +1577,90 @@ func TestCheckDisconnectedMetadata_Aligned_StaysQuiet(t *testing.T) { assert.NotContains(t, output, "DIVERGED") } +// stubAgyHooksProbeOnPath installs a fake agy that answers `--version` with +// version and `-p /hooks` with a JSON envelope listing hooksSource as an +// enabled "entire" entry (or no hooks when hooksSource is empty). +func stubAgyHooksProbeOnPath(t *testing.T, version, hooksSource string) { + t.Helper() + binDir := t.TempDir() + hooks := "[]" + if hooksSource != "" { + hooks = `[{"name":"entire","enabled":true,"source":"` + hooksSource + `"}]` + } + script := "#!/bin/sh\n" + + "case \"$1\" in\n" + + " --version) echo '" + version + "' ;;\n" + + " -p) printf '%s' '{\"status\":\"SUCCESS\",\"command\":{\"name\":\"hooks\",\"data\":{\"hooks\":" + hooks + "}}}' ;;\n" + + " *) exit 0 ;;\n" + + "esac\n" + require.NoError(t, os.WriteFile(filepath.Join(binDir, "agy"), []byte(script), 0o755)) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func writeAntigravityHooksForDoctor(t *testing.T, dir string) string { + t.Helper() + agentsDir := filepath.Join(dir, ".agents") + require.NoError(t, os.MkdirAll(agentsDir, 0o750)) + hooksPath := filepath.Join(agentsDir, "hooks.json") + require.NoError(t, os.WriteFile(hooksPath, []byte(antigravityHooksJSON()), 0o600)) + return hooksPath +} + +func TestCheckAntigravityHooksLoaded_OKWhenAgyListsWorkspaceHooks(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + t.Setenv(antigravity.DoctorProbeEnv, "1") + hooksPath := writeAntigravityHooksForDoctor(t, dir) + stubAgyHooksProbeOnPath(t, "1.1.22", hooksPath) + + cmd, stdout := newTestCmd(t) + checkAntigravityHooksLoaded(cmd) + require.Contains(t, stdout.String(), "✓ Antigravity hooks: LOADED by agy") +} + +func TestCheckAntigravityHooksLoaded_WarnsWhenAgyDoesNotLoadThem(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + t.Setenv(antigravity.DoctorProbeEnv, "1") + writeAntigravityHooksForDoctor(t, dir) + stubAgyHooksProbeOnPath(t, "1.1.22", "") + + cmd, stdout := newTestCmd(t) + checkAntigravityHooksLoaded(cmd) + require.Contains(t, stdout.String(), "Antigravity hooks: NOT LOADED by agy") + require.Contains(t, stdout.String(), "--add-dir") +} + +// TestCheckAntigravityHooksLoaded_SkipsOldAgy pins the quota guard: before +// 1.1.12, `agy -p "/hooks"` is a real model turn, so doctor must not run it. +func TestCheckAntigravityHooksLoaded_SkipsOldAgy(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + t.Setenv(antigravity.DoctorProbeEnv, "1") + hooksPath := writeAntigravityHooksForDoctor(t, dir) + stubAgyHooksProbeOnPath(t, "1.1.1", hooksPath) + + cmd, stdout := newTestCmd(t) + checkAntigravityHooksLoaded(cmd) + require.Contains(t, stdout.String(), "NOT VERIFIED") + require.Contains(t, stdout.String(), "agy update") + require.NotContains(t, stdout.String(), "LOADED by agy") +} + +func TestCheckAntigravityHooksLoaded_SilentWithoutHooksOrAgy(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + t.Setenv("PATH", t.TempDir()) + + cmd, stdout := newTestCmd(t) + checkAntigravityHooksLoaded(cmd) + require.Empty(t, stdout.String()) + + writeAntigravityHooksForDoctor(t, dir) // hooks present but no agy on PATH + checkAntigravityHooksLoaded(cmd) + require.Empty(t, stdout.String()) +} + // A symlinked agent directory arrives by clone and is invisible everywhere else: // enable refuses to write through it, then HookConfigFile.Exists() reports the // config as absent, so status says hooks are missing without saying why and @@ -1744,9 +1925,8 @@ func TestCheckAgentDirSymlinks_ReportsWrongTypedComponent(t *testing.T) { // TestAgentSymlinkCheckPaths_CoversLegacySubagentDir keeps .claude/agents/ in // the scan. removeLegacySearchSubagent deletes through it with // osroot.LstatNoSymlinks, which refuses a symlinked parent, so a link there is -// refused at enable and has to be diagnosable. .codex/agents and .gemini/agents -// were only ever covered as a side effect of the agent-help template living -// under them. +// refused at enable and has to be diagnosable. .codex/agents was only ever +// covered as a side effect of the agent-help template living under it. func TestAgentSymlinkCheckPaths_CoversLegacySubagentDir(t *testing.T) { t.Parallel() @@ -1955,3 +2135,71 @@ func TestCheckAgentDirSymlinks_VouchedLinkWithCleanTargetReportsOnlyTheLink(t *t assert.NotContains(t, got, "SYMLINKS PRESENT", "a clean target is not a fault") assert.NotContains(t, got, "NOT READABLE") } + +// The `/hooks` probe spends quota on at least one platform (agy 1.2.7 on +// Windows ran a full model turn for it), so a default doctor run must not +// invoke it at all — only ENTIRE_ANTIGRAVITY_DOCTOR_PROBE=1 does. +func TestCheckAntigravityHooksLoaded_ProbeIsOptIn(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + writeAntigravityHooksForDoctor(t, dir) + marker := filepath.Join(t.TempDir(), "probe-ran") + binDir := t.TempDir() + script := "#!/bin/sh\n" + + "case \"$1\" in\n" + + " --version) echo '1.2.7' ;;\n" + + " -p) : > '" + marker + "'; printf '%s' '{\"status\":\"SUCCESS\",\"command\":{\"name\":\"hooks\",\"data\":{\"hooks\":[]}}}' ;;\n" + + " *) exit 0 ;;\n" + + "esac\n" + require.NoError(t, os.WriteFile(filepath.Join(binDir, "agy"), []byte(script), 0o755)) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv(antigravity.DoctorProbeEnv, "") + + cmd, stdout := newTestCmd(t) + checkAntigravityHooksLoaded(cmd) + + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("doctor ran `agy -p /hooks` without the opt-in (stat err = %v)", err) + } + require.NotContains(t, stdout.String(), "LOADED by agy") + require.NotContains(t, stdout.String(), "NOT VERIFIED") +} + +// The zero-cost check that replaces the probe by default: an installed entry +// that is not what this host needs (here a bare command with no wrapper at all) +// is reported with the reinstall remedy, and a freshly installed one is not. +func TestCheckAntigravityHooksLoaded_ReportsAnEntryStaleForThisHost(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + t.Setenv("ENTIRE_ANTIGRAVITY_CONFIG_DIR", t.TempDir()) + stubAgyOnPath(t) + writeAntigravityHooksForDoctor(t, dir) + + cmd, stdout := newTestCmd(t) + checkAntigravityHooksLoaded(cmd) + require.Contains(t, stdout.String(), "Antigravity hooks: STALE FOR THIS HOST") + require.Contains(t, stdout.String(), "entire agent add antigravity") + + // A current install is silent. + _, err := (&antigravity.AntigravityAgent{}).InstallHooks(cmd.Context(), true) + require.NoError(t, err) + cmd, stdout = newTestCmd(t) + checkAntigravityHooksLoaded(cmd) + require.NotContains(t, stdout.String(), "STALE FOR THIS HOST") +} + +// A version string semver cannot parse is not "too old": the agy may be newer +// than the requirement, so the advice must not be `agy update`. +func TestCheckAntigravityHooksLoaded_UnparseableVersionSkipsProbeWithoutUpgradeAdvice(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + t.Setenv(antigravity.DoctorProbeEnv, "1") + hooksPath := writeAntigravityHooksForDoctor(t, dir) + stubAgyHooksProbeOnPath(t, "Antigravity CLI build 2026.09", hooksPath) + + cmd, stdout := newTestCmd(t) + checkAntigravityHooksLoaded(cmd) + require.Contains(t, stdout.String(), "could not determine the agy version") + require.NotContains(t, stdout.String(), "agy update") + require.NotContains(t, stdout.String(), "LOADED by agy") +} diff --git a/cmd/entire/cli/entiredir_guard_test.go b/cmd/entire/cli/entiredir_guard_test.go index 7447464f9f..ef2d4c7e38 100644 --- a/cmd/entire/cli/entiredir_guard_test.go +++ b/cmd/entire/cli/entiredir_guard_test.go @@ -48,6 +48,8 @@ var entireDirCheckExemptions = map[string]string{ "entire labs": "prints a static list of experimental workflows", "entire completion": "prints a shell script; users eval it from a shell rc, which a broken repo must not break", "entire doctor": "diagnostic; has to run ON a broken repo in order to report it", + "entire hooks antigravity title-tee": "captures agy's token counts into the per-user cache; touches no repo state, " + + "and agy fires it on every agent state change, so failing the guard would print the remedy and exit non-zero once per fire", } // collectExemptions walks the tree and returns every command path whose own diff --git a/cmd/entire/cli/experts_cmd.go b/cmd/entire/cli/experts_cmd.go index e56cd890c0..01fd57533d 100644 --- a/cmd/entire/cli/experts_cmd.go +++ b/cmd/entire/cli/experts_cmd.go @@ -395,16 +395,28 @@ func resolveExpertsRepo(ctx context.Context, override string) (string, error) { return owner + "/" + repo, nil } +// parseExpertsRepo normalizes --repo into the owner/repo pair the placement +// lookup takes, from either spelling the flag accepts: the bare pair, or a +// gh// triple. +// +// Only the triple names a forge, and only it may drop a trailing `.git`: the +// suffix is decoration on a mirror and part of the name on a native repo. The +// bare pair carries no forge token, so it goes through verbatim — which is also +// the spelling resolveExpertsRepo derives from a native origin, so `--repo` and +// the flagless run name one repository instead of two. func parseExpertsRepo(value string) (string, error) { trimmed := strings.Trim(strings.TrimSpace(value), "/") parts := strings.Split(trimmed, "/") - if len(parts) == 3 && parts[0] == "gh" { + if len(parts) == 3 && parts[0] == gitremote.ForgeGitHub { parts = parts[1:] + // Trimmed before the emptiness check below, so a name that was nothing + // but the suffix is refused rather than sent on as an empty repo. + parts[1] = strings.TrimSuffix(parts[1], mirrorGitDirSuffix) } if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return "", fmt.Errorf("invalid --repo %q (use owner/repo)", value) } - return parts[0] + "/" + strings.TrimSuffix(parts[1], gitDirSuffix), nil + return parts[0] + "/" + parts[1], nil } func expertsAPIPath(repoID string) string { diff --git a/cmd/entire/cli/experts_test.go b/cmd/entire/cli/experts_test.go index 684ef83102..f5983818ed 100644 --- a/cmd/entire/cli/experts_test.go +++ b/cmd/entire/cli/experts_test.go @@ -730,6 +730,83 @@ func TestExpertsCommandFailedPlacement(t *testing.T) { } } +// TestParseExpertsRepo pins which --repo spelling may drop a trailing `.git`. +// +// The suffix is decoration on a mirror and part of the name on a native repo, +// so only the gh/ triple — the one spelling that names its forge — may trim it. +// Trimming the bare pair too made the same command name two different +// repositories inside a clone of a native repo called `.git`; see +// TestResolveExpertsRepo_NativeOriginRoundTripsThroughRepoFlag. +func TestParseExpertsRepo(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + in string + want string + wantErr bool + }{ + {name: "bare pair passes through", in: "acme/widget", want: "acme/widget"}, + {name: "bare pair keeps a .git name", in: "audit1/foo.git", want: "audit1/foo.git"}, + {name: "gh triple drops the forge", in: "gh/acme/widget", want: "acme/widget"}, + {name: "gh triple drops the decoration", in: "gh/acme/widget.git", want: "acme/widget"}, + {name: "surrounding slashes are ignored", in: "/gh/acme/widget.git/", want: "acme/widget"}, + {name: "a gh name that is only the suffix is refused", in: "gh/acme/.git", wantErr: true}, + {name: "a non-gh triple is not a pair", in: "et/audit1/foo", wantErr: true}, + {name: "one segment is not a pair", in: "widget", wantErr: true}, + {name: "empty is refused", in: "", wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := parseExpertsRepo(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("parseExpertsRepo(%q) = %q, want an error", tc.in, got) + } + return + } + if err != nil { + t.Fatalf("parseExpertsRepo(%q): %v", tc.in, err) + } + if got != tc.want { + t.Errorf("parseExpertsRepo(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// TestResolveExpertsRepo_NativeOriginRoundTripsThroughRepoFlag is the +// divergence the trim caused, stated as the guarantee it broke: the pair +// `entire experts` derives from a native origin must survive being passed back +// as --repo. A repo genuinely named "foo.git" otherwise resolved as "foo" on +// the flag path and "foo.git" on the origin path: two repositories, one +// command. +// +// Not parallel: t.Chdir points ResolveRemoteRepo at the fixture repo. +func TestResolveExpertsRepo_NativeOriginRoundTripsThroughRepoFlag(t *testing.T) { + dir := t.TempDir() + runExpertsGit(t, dir, "init") + runExpertsGit(t, dir, "remote", "add", "origin", "entire://cell1.entire.io/et/audit1/foo.git") + t.Chdir(dir) + paths.ClearWorktreeRootCache() + t.Cleanup(paths.ClearWorktreeRootCache) + + fromOrigin, err := resolveExpertsRepo(context.Background(), "") + if err != nil { + t.Fatalf("resolveExpertsRepo from origin: %v", err) + } + if fromOrigin != "audit1/foo.git" { + t.Fatalf("origin resolved to %q, want %q", fromOrigin, "audit1/foo.git") + } + + fromFlag, err := resolveExpertsRepo(context.Background(), fromOrigin) + if err != nil { + t.Fatalf("resolveExpertsRepo from --repo %q: %v", fromOrigin, err) + } + if fromFlag != fromOrigin { + t.Errorf("--repo %q resolved to %q; the flag and the origin must name one repo", fromOrigin, fromFlag) + } +} + func runExpertsGit(t *testing.T, dir string, args ...string) { t.Helper() cmdArgs := append([]string{"-c", "commit.gpgsign=false"}, args...) diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index cefe7f544b..e928dc84b1 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -18,7 +18,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" "github.com/entireio/cli/cmd/entire/cli/agent/external" - "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" "github.com/entireio/cli/cmd/entire/cli/agent/opencode" "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/checkpoint" @@ -34,6 +33,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/trailers" "github.com/entireio/cli/cmd/entire/cli/transcript" transcriptcompact "github.com/entireio/cli/cmd/entire/cli/transcript/compact" + "github.com/entireio/cli/cmd/entire/cli/transcript/geminilegacy" "github.com/entireio/cli/cmd/entire/cli/tuiutil" "github.com/entireio/cli/redact" @@ -1253,7 +1253,7 @@ func formatCheckpointSummaryError(err error, attempt *summaryAttempt) (string, [ var claudeErr *claudecode.ClaudeError switch { case errors.As(err, &claudeErr): - switch claudeErr.Kind { //nolint:exhaustive // ClaudeErrorUnknown handled by default + switch claudeErr.Kind { case claudecode.ClaudeErrorAuth: label := "Claude authentication failed" rows := []explainRow{ @@ -1284,6 +1284,8 @@ func formatCheckpointSummaryError(err error, attempt *summaryAttempt) (string, [ case claudecode.ClaudeErrorCLIMissing: label := "Claude CLI is not installed or not on PATH" return label, nil, errors.New("Claude CLI is not installed or not on PATH") //nolint:staticcheck // ST1005 + case claudecode.ClaudeErrorUnknown: + fallthrough default: label := "Claude failed to generate the summary" suffix := formatClaudeErrorSuffix(claudeErr) @@ -1862,11 +1864,11 @@ func getAssociatedCommits(ctx context.Context, repo *git.Repository, checkpointI // scopeTranscriptForCheckpoint slices a transcript to include only the portion // relevant to a specific checkpoint, starting from the given offset. // For Claude Code (JSONL), the offset is a line number and we slice by line. -// For Gemini (single JSON blob), the offset is a message index and we slice by message. +// For historical Gemini CLI checkpoints (single JSON blob), the offset is a message index and we slice by message. func scopeTranscriptForCheckpoint(fullTranscript []byte, startOffset int, agentType types.AgentType) []byte { switch agentType { case agent.AgentTypeGemini: - scoped, err := geminicli.SliceFromMessage(fullTranscript, startOffset) + scoped, err := geminilegacy.SliceFromMessage(fullTranscript, startOffset) if err != nil { return nil } @@ -2161,7 +2163,7 @@ func appendTranscriptSection(sb *strings.Builder, verbose, full bool, fullTransc } // formatTranscriptBytes formats transcript bytes into a human-readable string. -// It parses the transcript (JSONL for Claude, JSON for Gemini) and formats it using the condensed format. +// It parses the transcript (JSONL for Claude, JSON for historical Gemini CLI checkpoints) and formats it using the condensed format. // The fallback is used for backwards compatibility when transcript parsing fails or is empty. func formatTranscriptBytes(transcriptBytes []byte, fallback string, agentType types.AgentType) string { if len(transcriptBytes) == 0 { @@ -3351,11 +3353,11 @@ func countLines(content []byte) int { } // transcriptOffset returns the appropriate offset for scoping a transcript. -// For Claude Code (JSONL), this is the line count. For Gemini (JSON), this is the message count. +// For Claude Code (JSONL), this is the line count. For historical Gemini CLI checkpoints (JSON), this is the message count. func transcriptOffset(transcriptBytes []byte, agentType types.AgentType) int { switch agentType { case agent.AgentTypeGemini: - t, err := geminicli.ParseTranscript(transcriptBytes) + t, err := geminilegacy.ParseTranscript(transcriptBytes) if err != nil { return 0 } diff --git a/cmd/entire/cli/explain_repo_test.go b/cmd/entire/cli/explain_repo_test.go index 39566eedbb..d172646de8 100644 --- a/cmd/entire/cli/explain_repo_test.go +++ b/cmd/entire/cli/explain_repo_test.go @@ -34,6 +34,10 @@ func TestParseExplainRepoFlag(t *testing.T) { {name: "native leading slash", in: "/et/acme/widgets", forge: "et", owner: "acme", repo: "widgets"}, {name: "native clone url", in: "entire://aws-us-east-2.entire.io/et/Acme/Widgets", forge: "et", owner: "acme", repo: "widgets"}, {name: "mirror clone url", in: "entire://aws-us-east-2.entire.io/gh/Acme/Widgets", forge: "gh", owner: "acme", repo: "widgets"}, + // `.git` is part of a native repo's name, not decoration, so it must + // survive parsing on both the bare-ref and clone-URL spellings. + {name: "native git suffix", in: "et/acme/widgets.git", forge: "et", owner: "acme", repo: "widgets.git"}, + {name: "native clone url git suffix", in: "entire://aws-us-east-2.entire.io/et/acme/widgets.git", forge: "et", owner: "acme", repo: "widgets.git"}, {name: "empty", in: "", wantErr: "--repo requires a value"}, {name: "missing forge", in: "acme/widgets", wantErr: "forge prefix is required"}, {name: "bare word", in: "widgets", wantErr: "forge prefix is required"}, @@ -107,6 +111,13 @@ func TestExplainRepoIsCurrent(t *testing.T) { assert.True(t, explainRepoIsCurrent(ctx, "et", "acme", "widgets")) assert.False(t, explainRepoIsCurrent(ctx, "gh", "acme", "widgets"), "same-named GitHub repo is distinct") + // `.git` is part of a native repo's name, not decoration: an origin named + // "widgets.git" must not match a --repo naming "widgets", and must match one + // that spells the suffix out. + setOrigin(t, "entire://aws-us-east-2.entire.io/et/acme/widgets.git") + assert.False(t, explainRepoIsCurrent(ctx, "et", "acme", "widgets"), "the suffix is part of the name, not decoration to strip") + assert.True(t, explainRepoIsCurrent(ctx, "et", "acme", "widgets.git")) + // A non-GitHub origin with a coincidentally matching owner/name must not // count as the current GitHub repo. setOrigin(t, "git@gitlab.com:acme/widgets.git") diff --git a/cmd/entire/cli/explain_summary_provider.go b/cmd/entire/cli/explain_summary_provider.go index 6e9a57e9c3..04aebab055 100644 --- a/cmd/entire/cli/explain_summary_provider.go +++ b/cmd/entire/cli/explain_summary_provider.go @@ -116,7 +116,7 @@ func resolveCheckpointSummaryProvider(ctx context.Context, w io.Writer) (*checkp // and "install claude-code" names nothing you can install. The mapping // lives in isSummaryCLIAvailable; deriving this needs that, not the // name list. - return nil, errors.New("no summary-capable provider is available; install claude, codex, gemini, pi, opencode, cursor, or copilot, install an external entire-agent-* plugin that declares text_generator, or set summary_generation.provider in settings") + return nil, errors.New("no summary-capable provider is available; install claude, codex, pi, opencode, cursor, or copilot, install an external entire-agent-* plugin that declares text_generator, or set summary_generation.provider in settings") case 1: return autoSelectSummaryProvider(ctx, w, candidates[0].Name, "non-interactive auto-select: single installed provider", selectionAutomatic) default: @@ -386,7 +386,7 @@ func buildCheckpointSummaryProviderWithEffectiveModel(name types.AgentName, effe // CLI binary is not on PATH. Checks the binary directly (via exec.LookPath) // rather than DetectPresence, because DetectPresence checks repo-level agent // configuration — a repo using Claude Code for development can still use Codex -// or Gemini for summary generation as long as the binary is installed. +// or Pi for summary generation as long as the binary is installed. func ensureSummaryProviderPresent(_ context.Context, name types.AgentName) error { ag, err := getSummaryAgent(name) if err != nil { diff --git a/cmd/entire/cli/explain_summary_provider_test.go b/cmd/entire/cli/explain_summary_provider_test.go index 0b26562663..fcd387ce61 100644 --- a/cmd/entire/cli/explain_summary_provider_test.go +++ b/cmd/entire/cli/explain_summary_provider_test.go @@ -221,7 +221,7 @@ func TestResolveDispatchSummaryProvider_ExplicitCodexUsesDefaultModelWithoutPers func TestResolveDispatchSummaryProvider_EmptyOverrideUsesConfiguredProviderAndModel(t *testing.T) { // Cannot use t.Parallel(): mutates package-level resolution seams. ctx := context.Background() - configured := &stubTextAgent{name: agent.AgentNameGemini, kind: agent.AgentTypeGemini} + configured := &stubTextAgent{name: agent.AgentNameCursor, kind: agent.AgentTypeCursor} originalLoad := loadSummarySettings originalGet := getSummaryAgent @@ -236,18 +236,18 @@ func TestResolveDispatchSummaryProvider_EmptyOverrideUsesConfiguredProviderAndMo loadSummarySettings = func(context.Context) (*settings.EntireSettings, error) { return &settings.EntireSettings{SummaryGeneration: &settings.SummaryGenerationSettings{ - Provider: string(agent.AgentNameGemini), - Model: "gemini-saved-model", + Provider: string(agent.AgentNameCursor), + Model: "cursor-saved-model", }}, nil } getSummaryAgent = func(name types.AgentName) (agent.Agent, error) { - if name != agent.AgentNameGemini { - t.Fatalf("getSummaryAgent(%q), want %q", name, agent.AgentNameGemini) + if name != agent.AgentNameCursor { + t.Fatalf("getSummaryAgent(%q), want %q", name, agent.AgentNameCursor) } return configured, nil } isSummaryCLIAvailable = func(name types.AgentName) bool { - return name == agent.AgentNameGemini + return name == agent.AgentNameCursor } discoverSummaryProvidersAlways = func(context.Context) { t.Fatal("configured registered provider should not trigger external discovery") @@ -257,10 +257,10 @@ func TestResolveDispatchSummaryProvider_EmptyOverrideUsesConfiguredProviderAndMo if err != nil { t.Fatalf("resolveDispatchSummaryProvider() error = %v", err) } - if provider.Name != agent.AgentNameGemini { - t.Fatalf("provider.Name = %q, want %q", provider.Name, agent.AgentNameGemini) + if provider.Name != agent.AgentNameCursor { + t.Fatalf("provider.Name = %q, want %q", provider.Name, agent.AgentNameCursor) } - if provider.Model != "gemini-saved-model" { + if provider.Model != "cursor-saved-model" { t.Fatalf("provider.Model = %q, want configured model", provider.Model) } if provider.TextGenerator != configured { @@ -734,7 +734,7 @@ func TestResolveCheckpointSummaryProvider_NonInteractiveMultiCandidatePicksFirst return &settings.EntireSettings{Enabled: true}, nil } listRegisteredAgents = func() []types.AgentName { - return []types.AgentName{agent.AgentNameCodex, agent.AgentNameGemini} + return []types.AgentName{agent.AgentNameCodex, agent.AgentNameCursor} } getSummaryAgent = func(name types.AgentName) (agent.Agent, error) { return &stubTextAgent{name: name, kind: agent.AgentTypeCodex}, nil @@ -1332,8 +1332,8 @@ func stubSummaryRegistry(t *testing.T, all []types.AgentName, capable ...types.A func TestUnsupportedSummaryProviderError_NamesTheCapableProviders(t *testing.T) { // Cannot use t.Parallel(): mutates package-level resolution seams. stubSummaryRegistry(t, - []types.AgentName{"codex", "gemini", "opencode"}, - "codex", "gemini") + []types.AgentName{"codex", "cursor", "opencode"}, + "codex", "cursor") err := unsupportedSummaryProviderError("opencode") if err == nil { @@ -1347,7 +1347,7 @@ func TestUnsupportedSummaryProviderError_NamesTheCapableProviders(t *testing.T) // One assertion covers both halves: the exact list, and that the rejected // provider is absent from it. Counting occurrences in the prose instead // would fail on any rewording that legitimately names the value twice. - if !strings.Contains(got, "supported agents: codex, gemini,") { + if !strings.Contains(got, "supported agents: codex, cursor,") { t.Errorf("error does not carry the capable list: %q", got) } } @@ -1375,7 +1375,7 @@ func TestSummaryCapableProviderNames_MatchesTheBuiltInAgents(t *testing.T) { // factoryai-droid is deliberately absent: it is a registered agent with no // GenerateText, and naming it is the fault this feature reports. - want := []string{"claude-code", "codex", "copilot-cli", "cursor", "gemini", "opencode", "pi"} + want := []string{"antigravity", "claude-code", "codex", "copilot-cli", "cursor", "opencode", "pi"} got := summaryCapableProviderNames() if !slices.Equal(got, want) { t.Errorf("summary-capable providers = %v, want %v\n"+ diff --git a/cmd/entire/cli/explain_test.go b/cmd/entire/cli/explain_test.go index 27f8a3ccec..2d7ba9bdb0 100644 --- a/cmd/entire/cli/explain_test.go +++ b/cmd/entire/cli/explain_test.go @@ -192,7 +192,7 @@ func TestFormatCheckpointSummaryError_DeadlineExceeded(t *testing.T) { } // Negative guards against regressions: // - Hardcoded "Claude" / "sonnet" / "Anthropic" would misdirect users of - // alternate summary providers (codex, gemini). + // alternate summary providers (codex, cursor). combined := label + "\n" + err.Error() var combinedSb194 strings.Builder for _, r := range rows { diff --git a/cmd/entire/cli/git_branch_validation_test.go b/cmd/entire/cli/git_branch_validation_test.go new file mode 100644 index 0000000000..ab79aa5f93 --- /dev/null +++ b/cmd/entire/cli/git_branch_validation_test.go @@ -0,0 +1,53 @@ +package cli + +import ( + "testing" + + "github.com/entireio/cli/cmd/entire/cli/execx" + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/entireio/cli/cmd/entire/cli/testutil/gitenv" + "github.com/stretchr/testify/require" +) + +// Pin literal branch-name behavior before replacing check-ref-format. Running +// outside a repository ensures these cases do not depend on checkout history. +func TestValidateBranchName_LiteralParity(t *testing.T) { + // Subtests change CWD and therefore cannot run in parallel. + for _, tc := range []struct { + name string + valid bool + }{ + {"main", true}, {"feature/topic", true}, {"café", true}, + {"refs/heads/HEAD", true}, {"@", true}, + {"", false}, {"HEAD", false}, {"-topic", false}, {"--all", false}, + {"a..b", false}, {"a@{b", false}, {"a b", false}, + {"a\\b", false}, {"a:b", false}, {"a?b", false}, {"a*b", false}, + {"a[b", false}, {"a~b", false}, {"a^b", false}, + {".topic", false}, {"topic.", false}, {"topic.lock", false}, + {"a/.hidden", false}, {"a.lock/b", false}, + {"a//b", false}, {"/topic", false}, {"topic/", false}, + {"a\nb", false}, {"a\tb", false}, {"a\x00b", false}, + } { + t.Run(tc.name, func(t *testing.T) { + gitenv.IsolateRepository(t) + t.Chdir(t.TempDir()) + oracle := execx.NonInteractive(t.Context(), "git", "check-ref-format", "--branch", tc.name) + oracle.Env = testutil.GitIsolatedEnv() + require.Equal(t, tc.valid, oracle.Run() == nil, "native Git baseline") + require.Equal(t, tc.valid, ValidateBranchName(t.Context(), tc.name) == nil) + }) + } +} + +// Native --branch expands checkout history. This is intentionally separate +// from literal validation: a future pure validator must make this compatibility +// decision explicitly rather than silently changing it in a mechanical port. +func TestValidateBranchName_PreviousCheckout(t *testing.T) { + gitenv.IsolateRepository(t) + dir := t.TempDir() + testutil.InitRepo(t, dir) + t.Chdir(dir) + testutil.RunGit(t, dir, "commit", "--allow-empty", "--no-gpg-sign", "-m", "initial") + testutil.RunGit(t, dir, "checkout", "-b", "other") + require.NoError(t, ValidateBranchName(t.Context(), "@{-1}")) +} diff --git a/cmd/entire/cli/gitexec/gitexec_test.go b/cmd/entire/cli/gitexec/gitexec_test.go new file mode 100644 index 0000000000..b9ec1e7b40 --- /dev/null +++ b/cmd/entire/cli/gitexec/gitexec_test.go @@ -0,0 +1,109 @@ +package gitexec_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/gitexec" + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/entireio/cli/cmd/entire/cli/testutil/gitenv" + "github.com/stretchr/testify/require" +) + +// Production HeadSHA inherits its environment; isolate it as well as fixture +// commands. These tests intentionally change process state and run serially. +func isolateHeadRead(t *testing.T) { + t.Helper() + gitenv.IsolateRepository(t) + t.Chdir(t.TempDir()) +} + +func TestHeadSHA_RepositoryLayouts(t *testing.T) { + for _, format := range []string{"sha1", "sha256"} { + for _, refs := range []string{"files", "reftable"} { + t.Run(format+"/"+refs, func(t *testing.T) { + isolateHeadRead(t) + dir := t.TempDir() + // Native init is needed to select the object and reference formats. + testutil.RunGit(t, dir, "init", "--object-format="+format, "--ref-format="+refs, "-b", "main") + testutil.RunGit(t, dir, "config", "user.name", "Test") + testutil.RunGit(t, dir, "config", "user.email", "test@example.com") + testutil.RunGit(t, dir, "config", "commit.gpgsign", "false") + testutil.RunGit(t, dir, "commit", "--allow-empty", "--no-gpg-sign", "-m", "initial") + want := strings.TrimSpace(testutil.RunGit(t, dir, "rev-parse", "HEAD")) + assertHeadSHA(t, dir, want) + + testutil.RunGit(t, dir, "pack-refs", "--all") + assertHeadSHA(t, dir, want) + testutil.RunGit(t, dir, "checkout", "--detach") + assertHeadSHA(t, dir, want) + + linked := filepath.Join(t.TempDir(), "linked") + testutil.RunGit(t, dir, "worktree", "add", "-b", "linked", linked) + testutil.RunGit(t, linked, "commit", "--allow-empty", "--no-gpg-sign", "-m", "linked only") + linkedHead := strings.TrimSpace(testutil.RunGit(t, linked, "rev-parse", "HEAD")) + require.NotEqual(t, want, linkedHead) + assertHeadSHA(t, linked, linkedHead) + assertHeadSHA(t, dir, want) + }) + } + } +} + +func assertHeadSHA(t *testing.T, dir, want string) { + t.Helper() + got, err := gitexec.HeadSHA(t.Context(), dir) + require.NoError(t, err) + require.Equal(t, want, got) +} + +func TestHeadSHA_Failures(t *testing.T) { + for _, state := range []string{"not a repository", "unborn", "malformed HEAD", "canceled"} { + t.Run(state, func(t *testing.T) { + isolateHeadRead(t) + dir := t.TempDir() + ctx := t.Context() + if state != "not a repository" { + testutil.InitRepo(t, dir) + } + switch state { + case "malformed HEAD": + require.NoError(t, os.WriteFile(filepath.Join(dir, ".git", "HEAD"), []byte("not a reference\n"), 0o600)) + case "canceled": + testutil.RunGit(t, dir, "commit", "--allow-empty", "--no-gpg-sign", "-m", "initial") + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + } + got, err := gitexec.HeadSHA(ctx, dir) + require.Error(t, err) + require.Empty(t, got, "failed reads must not leak partial Git stdout") + if state == "canceled" { + require.ErrorIs(t, err, context.Canceled) + } + }) + } +} + +func TestHeadSHA_ExplicitPathNotCWD(t *testing.T) { + isolateHeadRead(t) + first, second := t.TempDir(), t.TempDir() + for _, dir := range []string{first, second} { + testutil.InitRepo(t, dir) + testutil.RunGit(t, dir, "commit", "--allow-empty", "--no-gpg-sign", "-m", dir) + } + t.Chdir(first) + firstHead := strings.TrimSpace(testutil.RunGit(t, first, "rev-parse", "HEAD")) + secondHead := strings.TrimSpace(testutil.RunGit(t, second, "rev-parse", "HEAD")) + require.NotEqual(t, firstHead, secondHead) + assertHeadSHA(t, second, secondHead) + + // This user-command helper currently honors explicit native Git selectors. + // An OpenPath-only replacement would silently change the selected repository. + t.Setenv("GIT_DIR", filepath.Join(first, ".git")) + t.Setenv("GIT_WORK_TREE", first) + assertHeadSHA(t, second, firstHead) +} diff --git a/cmd/entire/cli/gitops/diff_layout_test.go b/cmd/entire/cli/gitops/diff_layout_test.go new file mode 100644 index 0000000000..e6335f3a41 --- /dev/null +++ b/cmd/entire/cli/gitops/diff_layout_test.go @@ -0,0 +1,54 @@ +package gitops + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/entireio/cli/cmd/entire/cli/testutil/gitenv" + "github.com/stretchr/testify/require" +) + +func TestDiffTreeFiles_RepositoryLayouts(t *testing.T) { + for _, format := range []string{"sha1", "sha256"} { + for _, refs := range []string{"files", "reftable"} { + t.Run(format+"/"+refs, func(t *testing.T) { + gitenv.IsolateRepository(t) + root := t.TempDir() + t.Chdir(t.TempDir()) + testutil.RunGit(t, root, "init", "--object-format="+format, "--ref-format="+refs, "-b", "main") + testutil.RunGit(t, root, "config", "user.name", "Test") + testutil.RunGit(t, root, "config", "user.email", "test@example.com") + testutil.RunGit(t, root, "config", "commit.gpgsign", "false") + testutil.WriteFile(t, root, "unchanged", "keep\n") + testutil.WriteFile(t, root, "changed", "before\n") + testutil.RunGit(t, root, "add", ".") + testutil.RunGit(t, root, "commit", "--no-gpg-sign", "-m", "initial") + before := strings.TrimSpace(testutil.RunGit(t, root, "rev-parse", "HEAD")) + testutil.WriteFile(t, root, "changed", "after\n") + testutil.RunGit(t, root, "add", ".") + testutil.RunGit(t, root, "commit", "--no-gpg-sign", "-m", "second") + after := strings.TrimSpace(testutil.RunGit(t, root, "rev-parse", "HEAD")) + testutil.RunGit(t, root, "pack-refs", "--all") + linked := filepath.Join(t.TempDir(), "linked") + testutil.RunGit(t, root, "worktree", "add", "--detach", linked, before) + shared := filepath.Join(t.TempDir(), "shared") + testutil.RunGit(t, root, "clone", "--shared", "--no-checkout", root, shared) + for _, dir := range []string{root, linked, shared} { + got, err := DiffTreeFileList(t.Context(), dir, before, after) + require.NoError(t, err) + require.Equal(t, []string{"changed"}, got, "objects resolve independently of checked-out HEAD: %s", dir) + initial, err := DiffTreeFileList(t.Context(), dir, "", before) + require.NoError(t, err) + require.ElementsMatch(t, []string{"changed", "unchanged"}, initial) + // --root only changes root-commit handling; it does not turn a + // non-root commit into a request to enumerate its entire tree. + nonRoot, err := DiffTreeFileList(t.Context(), dir, "", after) + require.NoError(t, err) + require.Equal(t, []string{"changed"}, nonRoot) + } + }) + } + } +} diff --git a/cmd/entire/cli/gitops/diff_parity_test.go b/cmd/entire/cli/gitops/diff_parity_test.go new file mode 100644 index 0000000000..767c7c271c --- /dev/null +++ b/cmd/entire/cli/gitops/diff_parity_test.go @@ -0,0 +1,114 @@ +package gitops + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/entireio/cli/cmd/entire/cli/testutil/gitenv" + "github.com/stretchr/testify/require" +) + +// Compare observable paths with an independent native name-only oracle, rather +// than reusing the raw-output parser that the production implementation uses. +func TestDiffTreeFiles_NativeParity(t *testing.T) { + for _, change := range []string{"mode", "symlink", "gitlink", "binary", "unusual paths", "rename"} { + t.Run(change, func(t *testing.T) { + gitenv.IsolateRepository(t) // production subprocesses also need isolation + dir := t.TempDir() + testutil.InitRepo(t, dir) + t.Chdir(dir) + testutil.WriteFile(t, dir, "original", "original content\n") + testutil.RunGit(t, dir, "add", "original") + testutil.RunGit(t, dir, "commit", "--no-gpg-sign", "-m", "initial") + before := strings.TrimSpace(testutil.RunGit(t, dir, "rev-parse", "HEAD")) + blob := strings.TrimSpace(testutil.RunGit(t, dir, "rev-parse", "HEAD:original")) + var expected []string + switch change { + case "mode": + testutil.RunGit(t, dir, "update-index", "--chmod=+x", "original") + expected = []string{"original"} + case "symlink": + // Index-only fixtures exercise symlink tree entries even on Windows. + testutil.RunGit(t, dir, "update-index", "--add", "--cacheinfo", "120000", blob, "link") + expected = []string{"link"} + case "gitlink": + testutil.RunGit(t, dir, "update-index", "--add", "--cacheinfo", "160000", before, "submodule") + expected = []string{"submodule"} + case "binary": + testutil.WriteFile(t, dir, "original", "binary\x00content") + testutil.RunGit(t, dir, "add", "original") + expected = []string{"original"} + case "unusual paths": + expected = []string{"space name", "café", "nested/file"} + if runtime.GOOS != "windows" { + expected = append(expected, "tab\tname", "line\nname") + } + for _, name := range expected { + testutil.WriteFile(t, dir, name, "new\n") + } + testutil.RunGit(t, dir, "add", ".") + case "rename": + testutil.RunGit(t, dir, "mv", "original", "renamed") + expected = []string{"original", "renamed"} + } + testutil.RunGit(t, dir, "commit", "--no-gpg-sign", "-m", change) + after := strings.TrimSpace(testutil.RunGit(t, dir, "rev-parse", "HEAD")) + oracle := testutil.RunGit(t, dir, "diff-tree", "--no-commit-id", "--no-renames", "--name-only", "-r", "-z", before, after) + want := strings.Split(strings.TrimSuffix(oracle, "\x00"), "\x00") + require.ElementsMatch(t, expected, want, "native baseline") + + indexPath := filepath.Join(dir, ".git", "index") + indexBefore, err := os.ReadFile(indexPath) + require.NoError(t, err) + list, err := DiffTreeFileList(t.Context(), dir, before, after) + require.NoError(t, err) + require.ElementsMatch(t, want, list) + set, err := DiffTreeFiles(t.Context(), dir, before, after) + require.NoError(t, err) + require.Len(t, set, len(want)) + for _, name := range want { + require.Contains(t, set, name) + } + indexAfter, err := os.ReadFile(indexPath) + require.NoError(t, err) + require.Equal(t, indexBefore, indexAfter, "tree reads must not rewrite the index") + require.Equal(t, after, strings.TrimSpace(testutil.RunGit(t, dir, "rev-parse", "HEAD"))) + }) + } +} + +func TestDiffTreeFiles_ReadFailures(t *testing.T) { + for _, state := range []string{"unknown revision", "missing object", "canceled"} { + t.Run(state, func(t *testing.T) { + gitenv.IsolateRepository(t) + dir := t.TempDir() + testutil.InitRepo(t, dir) + t.Chdir(dir) + testutil.RunGit(t, dir, "commit", "--allow-empty", "--no-gpg-sign", "-m", "initial") + head := strings.TrimSpace(testutil.RunGit(t, dir, "rev-parse", "HEAD")) + target := head + ctx := t.Context() + switch state { + case "unknown revision": + target = "refs/heads/does-not-exist" + case "missing object": + target = strings.Repeat("1", len(head)) + case "canceled": + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + } + files, err := DiffTreeFiles(ctx, dir, head, target) + require.Error(t, err, "failed reads must not masquerade as an empty diff") + require.Nil(t, files) + if state == "canceled" { + require.ErrorIs(t, err, context.Canceled) + } + }) + } +} diff --git a/cmd/entire/cli/gitremote/gitremote.go b/cmd/entire/cli/gitremote/gitremote.go index 2544f46a88..e61f3a1442 100644 --- a/cmd/entire/cli/gitremote/gitremote.go +++ b/cmd/entire/cli/gitremote/gitremote.go @@ -14,8 +14,15 @@ import ( ) const ( + // ProtocolSSH is the ssh transport, whichever of git's three spellings + // named it: ssh://, git+ssh://, or ssh+git:// (see normalizeProtocol). ProtocolSSH = "ssh" ProtocolHTTPS = "https" + // ProtocolHTTP and ProtocolGit are the remaining schemes whose host is the + // git host itself. ParseURL returns them for http:// and git:// remotes; + // nothing derives such a URL, so they exist to be recognized. + ProtocolHTTP = "http" + ProtocolGit = "git" // ProtocolEntire is the scheme of Entire's git remote helper (entire://). // These URLs carry a forge/namespace prefix before owner/repo. ProtocolEntire = "entire" @@ -44,7 +51,7 @@ type Info struct { // trails API. entire:// URLs carry the forge in the path instead and bypass // this map. var hostToForge = map[string]string{ - "github.com": "gh", + "github.com": ForgeGitHub, } // forgeToHost is the reverse of hostToForge: it maps a forge identifier back to @@ -58,6 +65,23 @@ var forgeToHost = func() map[string]string { return m }() +const ( + // ForgeGitHub is the entire:// path token for a GitHub mirror. + ForgeGitHub = "gh" + + // ForgeNative is the entire:// path token for an Entire-native repo. It is + // the one forge whose repo names may legitimately end in `.git`: GitHub + // rejects such a name outright, so on a /gh/ path the suffix can only be + // decoration, while entiredb permits an interior dot and the data plane + // resolves /et/ paths verbatim. Exported so callers holding a parsed forge + // can ask the question without a bare "et" literal. + ForgeNative = "et" +) + +// gitDirSuffix is the suffix git tools habitually append to a repo path. +// Dropped for every forge except ForgeNative — see splitOwnerRepo. +const gitDirSuffix = ".git" + // pathForges are the forge tokens Entire uses in an entire:// URL path // (`entire:////…`), mapped to the placeholder spelling of // the two segments that follow — a mirror is addressed by owner, a native repo @@ -73,8 +97,8 @@ var forgeToHost = func() map[string]string { // addressed internally by ULID and `entire repo clone` does not accept a /git/ // ref, so admitting it would have callers suggest a command that then fails. var pathForges = map[string]string{ - "gh": "/", - "et": "/", + ForgeGitHub: "/", + ForgeNative: "/", } // IsForgePathToken reports whether forge is one of the forge tokens Entire uses @@ -221,13 +245,16 @@ func ParseURL(rawURL string) (*Info, error) { host = hostPart } - pathPart := strings.TrimSuffix(parts[1], ".git") - owner, repo, err := splitOwnerRepo(pathPart) + // Forge first: splitOwnerRepo needs it to decide whether `.git` is + // decoration. An SCP-style URL never names a native repo (the map holds + // git hosts only), but reading it here keeps one rule in one place. + forge := hostToForge[host] + owner, repo, err := splitOwnerRepo(parts[1], forge) if err != nil { return nil, err } - return &Info{Protocol: ProtocolSSH, Host: host, Forge: hostToForge[host], Owner: owner, Repo: repo}, nil + return &Info{Protocol: ProtocolSSH, Host: host, Forge: forge, Owner: owner, Repo: repo}, nil } u, err := url.Parse(rawURL) @@ -244,12 +271,33 @@ func ParseURL(rawURL string) (*Info, error) { // entire:// URLs encode the forge as the first path segment. forge, pathPart = splitForgePrefix(pathPart) } - owner, repo, err := splitOwnerRepo(pathPart) + owner, repo, err := splitOwnerRepo(pathPart, forge) if err != nil { return nil, err } - return &Info{Protocol: u.Scheme, Host: u.Hostname(), Port: u.Port(), Forge: forge, Owner: owner, Repo: repo}, nil + return &Info{Protocol: normalizeProtocol(u.Scheme), Host: u.Hostname(), Port: u.Port(), Forge: forge, Owner: owner, Repo: repo}, nil +} + +// normalizeProtocol returns the transport git dials for scheme. +// +// Protocol answers how a remote is reached, not how it is spelled. git accepts +// git+ssh:// and ssh+git:// as aliases of ssh:// and dispatches all three to +// ssh, so a caller switching on Protocol must never see an alias as a scheme +// of its own: it would take a default branch, or refuse a remote it admits +// under another name. +// +// Every other scheme is returned unchanged. An unrecognized "+ssh" is a +// remote helper to git, not a transport, and must keep failing closed. ftps:// +// is absent deliberately: git accepts it, but it is read-only and cannot carry +// a push. +func normalizeProtocol(scheme string) string { + switch scheme { + case "git+ssh", "ssh+git": + return ProtocolSSH + default: + return scheme + } } // splitForgePrefix returns the leading forge/namespace segment of an entire:// @@ -313,8 +361,20 @@ func ResolveRemoteRepo(ctx context.Context, remoteName string) (forge, owner, re return info.Forge, info.Owner, info.Repo, nil } -func splitOwnerRepo(path string) (string, string, error) { - path = strings.TrimSuffix(path, ".git") +// splitOwnerRepo splits a remote path into owner and repo. +// +// A trailing `.git` is dropped for every forge except ForgeNative. GitHub +// rejects a name ending in it, so there the suffix is decoration; a native repo +// may genuinely be named "foo.git", and trimming it names a different +// repository. See COR-1892. The forge is known on every ParseURL branch before +// the split, so the choice needs no lookup and has no fallback. +// +// This is the only place the suffix is dropped: trimming again in ParseURL's +// SCP branch collapsed "repo.git.git" to "repo". +func splitOwnerRepo(path, forge string) (string, string, error) { + if forge != ForgeNative { + path = strings.TrimSuffix(path, gitDirSuffix) + } parts := strings.SplitN(path, "/", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return "", "", fmt.Errorf("cannot parse owner/repo from path: %s", path) @@ -328,5 +388,18 @@ func splitOwnerRepo(path string) (string, string, error) { if strings.IndexFunc(parts[0]+"/"+parts[1], unicode.IsControl) >= 0 { return "", "", errors.New("invalid control character in remote owner/repo") } + // A dot-only segment names nothing, and the trim above can MANUFACTURE one: + // "..git" becomes "." and "...git" becomes "..". Neither addresses a repo, + // and both are path-traversal shapes for any caller that joins them. The + // /gh/ ref grammar already refuses this (parseMirrorCloneRef); refuse it on + // the URL path too, which is what ResolveRemoteRepo reads. + if isDotOnly(parts[0]) || isDotOnly(parts[1]) { + return "", "", fmt.Errorf("owner and repo cannot be dot-only: %s", path) + } return parts[0], parts[1], nil } + +// isDotOnly reports whether s is non-empty and made only of '.' characters. +func isDotOnly(s string) bool { + return s != "" && strings.Trim(s, ".") == "" +} diff --git a/cmd/entire/cli/gitremote/gitremote_test.go b/cmd/entire/cli/gitremote/gitremote_test.go index c1d35dbb30..b845c472af 100644 --- a/cmd/entire/cli/gitremote/gitremote_test.go +++ b/cmd/entire/cli/gitremote/gitremote_test.go @@ -84,6 +84,27 @@ func TestParseURL(t *testing.T) { url: "git@gitlab.com:org/repo.git", wantInfo: &Info{Protocol: ProtocolSSH, Host: "gitlab.com", Owner: "org", Repo: "repo"}, }, + { + // git dispatches git+ssh:// and ssh+git:// to ssh (verified on git + // 2.54.0: GIT_SSH_COMMAND runs for both), so Protocol must report + // the transport rather than the spelling in .git/config. + name: "git+ssh alias reports the ssh transport", + url: "git+ssh://git@github.com/org/repo.git", + wantInfo: &Info{Protocol: ProtocolSSH, Host: "github.com", Forge: "gh", Owner: "org", Repo: "repo"}, + }, + { + name: "ssh+git alias reports the ssh transport", + url: "ssh+git://git@git.example.com:2222/org/repo.git", + wantInfo: &Info{Protocol: ProtocolSSH, Host: "git.example.com", Port: "2222", Owner: "org", Repo: "repo"}, + }, + { + // An unrecognized scheme is a remote helper to git (`git ls-remote + // bogus+ssh://…` → "remote helper 'bogus+ssh' aborted session"), + // so it stays itself and keeps failing closed downstream. + name: "unknown scheme alias is not normalized", + url: "bogus+ssh://git@github.com/org/repo.git", + wantInfo: &Info{Protocol: "bogus+ssh", Host: "github.com", Forge: "gh", Owner: "org", Repo: "repo"}, + }, { name: "empty string", url: "", @@ -111,6 +132,26 @@ func TestParseURL(t *testing.T) { url: "git@github.com:org/re\rpo", wantErr: true, }, + { + name: "entire:// native keeps a .git suffix as part of the name", + url: "entire://entirehost/et/audit1/foo.git", + wantInfo: &Info{Protocol: ProtocolEntire, Host: "entirehost", Forge: "et", Owner: "audit1", Repo: "foo.git"}, + }, + { + name: "entire:// native without a suffix is unchanged", + url: "entire://entirehost/et/audit1/foo", + wantInfo: &Info{Protocol: ProtocolEntire, Host: "entirehost", Forge: "et", Owner: "audit1", Repo: "foo"}, + }, + { + name: "entire:// native keeps a doubled suffix verbatim", + url: "entire://entirehost/et/audit1/foo.git.git", + wantInfo: &Info{Protocol: ProtocolEntire, Host: "entirehost", Forge: "et", Owner: "audit1", Repo: "foo.git.git"}, + }, + { + name: "entire:// mirror drops only one .git", + url: "entire://entirehost/gh/entireio/cli.git.git", + wantInfo: &Info{Protocol: ProtocolEntire, Host: "entirehost", Forge: "gh", Owner: "entireio", Repo: "cli.git"}, + }, } for _, tt := range tests { @@ -124,6 +165,7 @@ func TestParseURL(t *testing.T) { require.NoError(t, err) assert.Equal(t, tt.wantInfo.Protocol, info.Protocol) assert.Equal(t, tt.wantInfo.Host, info.Host) + assert.Equal(t, tt.wantInfo.Port, info.Port) assert.Equal(t, tt.wantInfo.Forge, info.Forge) assert.Equal(t, tt.wantInfo.Owner, info.Owner) assert.Equal(t, tt.wantInfo.Repo, info.Repo) @@ -292,3 +334,42 @@ func TestCanonicalHostIgnoresPathForges(t *testing.T) { mirror := &Info{Host: "aws-us-east-2.entire.io", Forge: "gh", Owner: "entireio", Repo: "cli"} assert.Equal(t, "github.com", mirror.CanonicalHost()) } + +// TestParseURL_NativeSuffixDoesNotBypassControlCharGuard pins that making the +// .git strip forge-aware did not move the shared control-character chokepoint. +// A literal control character in the raw URL never gets this far: net/url.Parse +// scans the still-encoded string up front and rejects it before ParseURL sees a +// path at all. Percent-encoding is the bypass — url.Parse only inspects the raw +// bytes, so "%0A" sails through and only becomes a real newline once u.Path is +// decoded, after the forge (here ForgeNative) is already known and the "don't +// trim .git for et" branch has run. splitOwnerRepo's guard is the only thing +// stopping that decoded escape from reaching owner/repo and, from there, +// plain-text consumers like `entire agent-help`. +func TestParseURL_NativeSuffixDoesNotBypassControlCharGuard(t *testing.T) { + t.Parallel() + _, err := ParseURL("entire://entirehost/et/audit1/foo%0A.git") + require.Error(t, err) + require.Contains(t, err.Error(), "control character") +} + +// TestParseURL_RejectsDotOnlySegments pins that stripping cannot MANUFACTURE a +// dot-only name: "..git" trims to "." and "...git" trims to "..", neither of +// which addresses a repo and both of which are path-traversal shapes if a +// caller ever joins them. The /gh/ ref grammar guards this case already +// (parseMirrorCloneRef's gitHubDotOnlyRe); this is the URL half, which +// ResolveRemoteRepo actually uses. +func TestParseURL_RejectsDotOnlySegments(t *testing.T) { + t.Parallel() + for _, rawURL := range []string{ + "entire://entirehost/gh/acme/..git", // trims to "." + "entire://entirehost/gh/acme/...git", // trims to ".." + "entire://entirehost/gh/../app", // typed, not manufactured + "entire://entirehost/et/acme/..", // native: never trimmed, still refused + } { + t.Run(rawURL, func(t *testing.T) { + t.Parallel() + _, err := ParseURL(rawURL) + require.Error(t, err) + }) + } +} diff --git a/cmd/entire/cli/gitrepo/env.go b/cmd/entire/cli/gitrepo/env.go index 2be31ef85a..ed5dc5cfb3 100644 --- a/cmd/entire/cli/gitrepo/env.go +++ b/cmd/entire/cli/gitrepo/env.go @@ -5,28 +5,29 @@ import ( "strings" ) -// repoOverrideEnvVars are git's repo-selector environment variables. Git -// exports them to its hooks, and they take precedence over a child process's -// working directory — so `exec.Command("git", ...)` with cmd.Dir set still -// resolves the *hook's* repository, not the directory named, and -// GIT_INDEX_FILE redirects index reads and writes to a different file -// entirely. +// Inherited repository selectors can redirect Git even when cmd.Dir is explicit. +// GIT_COMMON_DIR redirects shared repository data, including configuration; +// GIT_INDEX_FILE redirects index reads and writes. var repoOverrideEnvVars = []string{ "GIT_DIR=", + "GIT_COMMON_DIR=", "GIT_WORK_TREE=", "GIT_INDEX_FILE=", } // EnvWithoutRepoOverrides returns the current environment minus git's -// repo-selector variables (GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE), so a git -// subprocess resolves its repository from cmd.Dir as the call site intends. +// repo-selector variables (GIT_DIR, GIT_COMMON_DIR, GIT_WORK_TREE, GIT_INDEX_FILE), +// so a git subprocess resolves its repository from cmd.Dir as the call site intends. // -// Use this for any git subprocess that can run inside a git hook and that -// names its target with cmd.Dir or `-C`. Inheriting these variables makes the +// Use this for git subprocesses that must resolve their target independently +// of the enclosing hook, using cmd.Dir or `-C`. Inheriting these variables makes the // child silently operate on the hook's repository instead: `git -C // rev-parse` reports the hook's repo, and an index-touching command reads and // writes whatever GIT_INDEX_FILE names. // +// Commands inspecting the commit being prepared must retain Git's temporary +// GIT_INDEX_FILE rather than use this helper. +// // Deliberately not applied to user-invoked commands that operate on the // current directory (`entire status`, `entire doctor`, `entire review`): there // a GIT_DIR the user exported in their own shell is an instruction, not diff --git a/cmd/entire/cli/gitrepo/env_isolation_test.go b/cmd/entire/cli/gitrepo/env_isolation_test.go new file mode 100644 index 0000000000..bd6dcb7f5e --- /dev/null +++ b/cmd/entire/cli/gitrepo/env_isolation_test.go @@ -0,0 +1,32 @@ +package gitrepo_test + +import ( + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/gitrepo" + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/stretchr/testify/require" +) + +func TestEnvWithoutRepoOverrides_IsolatesRepositoryConfig(t *testing.T) { + // Cannot run in parallel: Git selectors are process-global. + target, decoy := t.TempDir(), t.TempDir() + testutil.InitRepo(t, target) + testutil.InitRepo(t, decoy) + targetHooks := filepath.Join(target, "target-hooks") + decoyHooks := filepath.Join(decoy, "decoy-hooks") + testutil.RunGit(t, target, "config", "core.hooksPath", targetHooks) + testutil.RunGit(t, decoy, "config", "core.hooksPath", decoyHooks) + + t.Setenv("GIT_COMMON_DIR", filepath.Join(decoy, ".git")) + + cmd := exec.CommandContext(t.Context(), "git", "rev-parse", "--git-path", "hooks") + cmd.Dir = target + cmd.Env = gitrepo.EnvWithoutRepoOverrides() + output, err := cmd.Output() + require.NoError(t, err) + require.Equal(t, targetHooks, strings.TrimSpace(string(output))) +} diff --git a/cmd/entire/cli/gitrepo/env_test.go b/cmd/entire/cli/gitrepo/env_test.go index bf679488f5..ebd9a18d6a 100644 --- a/cmd/entire/cli/gitrepo/env_test.go +++ b/cmd/entire/cli/gitrepo/env_test.go @@ -9,13 +9,14 @@ import ( func TestEnvWithoutRepoOverrides_StripsRepoSelectors(t *testing.T) { // Cannot be parallel: t.Setenv is process-global. t.Setenv("GIT_DIR", "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/decoy/.git") + t.Setenv("GIT_COMMON_DIR", "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/decoy/.git") t.Setenv("GIT_WORK_TREE", "/decoy") t.Setenv("GIT_INDEX_FILE", "/decoy/.git/index") t.Setenv("ENTIRE_ENV_TEST_KEEP", "keep-me") env := EnvWithoutRepoOverrides() - for _, banned := range []string{"GIT_DIR=", "GIT_WORK_TREE=", "GIT_INDEX_FILE="} { + for _, banned := range []string{"GIT_DIR=", "GIT_COMMON_DIR=", "GIT_WORK_TREE=", "GIT_INDEX_FILE="} { if slices.ContainsFunc(env, func(kv string) bool { return strings.HasPrefix(kv, banned) }) { t.Errorf("EnvWithoutRepoOverrides kept %s", strings.TrimSuffix(banned, "=")) } diff --git a/cmd/entire/cli/gitrepo/ref_cas.go b/cmd/entire/cli/gitrepo/ref_cas.go index 76ce35e60a..3a75ecd497 100644 --- a/cmd/entire/cli/gitrepo/ref_cas.go +++ b/cmd/entire/cli/gitrepo/ref_cas.go @@ -47,10 +47,12 @@ func CompareAndSwapRef( return errors.Join(err, tx.abort()) } if symbolic { - return errors.Join( - fmt.Errorf("ref %s points to %s: %w", refName, target, ErrRefSymbolic), - tx.abort(), - ) + symbolicErr := fmt.Errorf("ref %s points to %s: %w", refName, target, ErrRefSymbolic) + if abortErr := tx.abort(); abortErr != nil { + // Cleanup diagnostics must not make symbolic-ref rejection retryable. + return fmt.Errorf("%w (abort transaction: %s)", symbolicErr, abortErr.Error()) + } + return symbolicErr } return tx.commit() } diff --git a/cmd/entire/cli/gitrepo/ref_cas_test.go b/cmd/entire/cli/gitrepo/ref_cas_test.go index 1a0f6365ad..1866feb02a 100644 --- a/cmd/entire/cli/gitrepo/ref_cas_test.go +++ b/cmd/entire/cli/gitrepo/ref_cas_test.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" "time" @@ -90,6 +91,40 @@ func TestCompareAndSwapRef_RejectsSymbolicRef(t *testing.T) { } } +func TestCompareAndSwapRef_SymbolicRefAbortFailure(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("abort failure injection requires POSIX signals") + } + hooksDir := t.TempDir() + // Fail the owned Git process after it releases the prepared ref lock. + hook := `#!/bin/sh +if [ "$1" = aborted ]; then + echo 'fatal: cannot lock references' >&2 + kill -TERM "$PPID" +fi +` + require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "reference-transaction"), []byte(hook), 0o755)) + for _, backend := range refCASBackends() { + t.Run(backend.name, func(t *testing.T) { + t.Parallel() + repoDir, initial, replacement := backend.init(t) + gitenv.Run(t, repoDir, "config", "core.hooksPath", hooksDir) + + err := CompareAndSwapRef(t.Context(), repoDir, plumbing.HEAD, plumbing.NewHash(initial), plumbing.NewHash(replacement)) + require.ErrorIs(t, err, ErrRefSymbolic) + require.ErrorContains(t, err, "cannot lock references", "retain the abort failure diagnosis") + require.NotErrorIs(t, err, ErrRefLocked, "cleanup must not make symbolic-ref rejection retryable") + require.NotErrorIs(t, err, ErrRefCASConflict) + require.Equal(t, "refs/heads/main", strings.TrimSpace(gitenv.Run(t, repoDir, "symbolic-ref", "HEAD"))) + require.Equal(t, replacement, strings.TrimSpace(gitenv.Run(t, repoDir, "rev-parse", "HEAD"))) + + require.NoError(t, CompareAndSwapRef(t.Context(), repoDir, plumbing.NewBranchReferenceName("main"), plumbing.NewHash(initial), plumbing.NewHash(replacement))) + require.Equal(t, initial, strings.TrimSpace(gitenv.Run(t, repoDir, "rev-parse", "HEAD"))) + }) + } +} + func TestPreparedRefCASPreventsConcurrentSymbolicConversion(t *testing.T) { t.Parallel() for _, tt := range refCASBackends() { diff --git a/cmd/entire/cli/global_test.go b/cmd/entire/cli/global_test.go index 26f86cde5f..1a4c65a79b 100644 --- a/cmd/entire/cli/global_test.go +++ b/cmd/entire/cli/global_test.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/cmd/entire/cli/testutil/gitenv" "os" "path/filepath" "testing" @@ -47,6 +48,18 @@ func TestMain(m *testing.M) { // forgets from poisoning the rest of the run. os.Unsetenv(contexts.EnvContextVar) + // The ConfigLoader plugin below only isolates go-git's IN-PROCESS config + // reads. Production code under test also shells out to git (checkpoint + // remote fetches, hooks), and those children read the developer's + // ~/.gitconfig unless the whole process is isolated. That is not cosmetic: + // a host with transfer.fsckObjects set makes `git fetch` hand the objects + // to index-pack instead of unpack-objects, so the fetched commit lands in + // a new packfile that the already-open go-git repository never indexes — + // the checkpoint-remote heal then reports "object not found" and silently + // keeps the empty orphan (ENCLI-378). Set process-wide (not per-test) so + // it covers spawned binaries and git hooks. Mirrors the e2e TestMains. + gitenv.IsolateMain() + // ENTIRE_TOKEN is isolated by ABSENCE, not by a redirected path, so it is // not in the block above. Left set, it outranks every stored context in // resolveEntireIdentityProfile, so a test driving the production identity diff --git a/cmd/entire/cli/grant.go b/cmd/entire/cli/grant.go index e0603cb81d..4b69f6979f 100644 --- a/cmd/entire/cli/grant.go +++ b/cmd/entire/cli/grant.go @@ -2,12 +2,16 @@ package cli import ( "context" + "errors" "fmt" "slices" "strings" + "charm.land/huh/v2" "github.com/spf13/cobra" + "github.com/entireio/cli/cmd/entire/cli/interactive" + "github.com/entireio/cli/cmd/entire/cli/uiform" "github.com/entireio/cli/internal/coreapi" ) @@ -19,9 +23,10 @@ import ( // the command shape and the shared plumbing; a grantTarget owns the typed calls. // // Grantees are addressed by a provider-qualified handle (e.g. github:alice), -// which the CLI resolves to the provider account behind the scenes. `remove` -// also accepts an account ULID where the API has a typed-id route (project and -// repo). A user account is the only grantee kind the API grants to today. +// which the CLI resolves to the provider account behind the scenes, and by +// nothing else: ensureGranteeIsHandle refuses an account ULID on all three +// targets, before any lookup, on add and remove alike. A user account is the +// only grantee kind the API grants to today. // grantTarget describes one resource kind the shared ` grant` subtree // manages. Row is the wire type of one listing entry. @@ -30,6 +35,7 @@ type grantTarget[Row any] struct { refUsage string // how a target is addressed, for Long: "name or ULID"; reads after "addressed by" exampleRef string // a target ref for the Example lines roles []string // accepted --role values, in help order + leastRole string // the least-privileged of roles; help order runs least-first for access but most-first for org, so it is named rather than indexed defaultRole string // "" means --role is required; else the server default applied when --role is omitted columns []string row func(Row) []string @@ -40,12 +46,55 @@ type grantTarget[Row any] struct { // grant gives the provider account the role on the resolved target and // returns the effective role: the server's, when role was left to default. grant func(ctx context.Context, c *coreapi.Client, id, provider, providerUserID, role string) (granted string, wire any, err error) - list func(ctx context.Context, c *coreapi.Client, id string, pageToken coreapi.OptString) ([]Row, string, error) + list func(ctx context.Context, c *coreapi.Client, id string, pageToken coreapi.OptString) ([]Row, coreapi.OptString, error) revokeByProvider func(ctx context.Context, c *coreapi.Client, id, provider, providerUserID string) error - // revokeByID is the typed-id route for an account ULID grantee. nil when - // the target has none (org), so a ULID grantee falls through to - // resolveGranteeProvider and is refused with the handle form named. + // revokeByID revokes by the grantee ULID a listing row carries, which needs + // no handle lookup and survives a rename. Only the remove picker reaches + // it — a typed ULID is refused — so it is nil on org, whose rows are + // addressed by handle because org membership has no such route. revokeByID func(ctx context.Context, c *coreapi.Client, id, granteeID string) error + // candidates lists who could be granted this target for the interactive + // picker: members of the owning org with no direct grant on it, plus the + // counts an empty pool needs to say why. nil where no pool is enumerable + // (org), which is what leaves `org grant add` exactly as it was. + candidates func(ctx context.Context, c *coreapi.Client, id string) (memberPool, error) + // holders lists the grants on this target that revoking would actually + // remove, for the remove picker, and reports whether the listing stopped at + // the fetch budget with more left. Set on all three targets: the members to + // remove from an org ARE an enumerable list, which is what the add side + // lacks. + holders func(ctx context.Context, c *coreapi.Client, id string) ([]grantCandidate, listWindow, error) + // ownerNotOrg phrases an ownerNotOrgError for this target, given the name of + // the account-owned project. The repo wording has to name the project + // standing between the repo and the missing org, so one shared sentence + // cannot serve both. + ownerNotOrg func(pt grantPickerTarget, project string) string + + // unwritableRef reports a ref that names something this target's `add` and + // `remove` cannot write — the repo target's GitHub mirrors, whose access is + // the upstream repository's. Checked before required flags are validated or + // anything is dialed, so the answer is about the repo the user named rather + // than a missing --role they would then supply for nothing. nil where every + // ref a target parses is writable (org, project). + unwritableRef func(ref string) error + + // listBranch is the second reading `list` gives a target ref, for a target + // whose refs do not all name the same thing: a repo is an Entire repository + // or a GitHub mirror, and only the first has grants. nil for org and + // project, which have one kind of target each. + listBranch *grantListBranch +} + +// grantListBranch is the second answer a `list` leaf can give: claims reports +// which refs it answers for, leaving every other ref to the target's own +// resolver, and list answers one of them. long and example become the leaf's +// help, because a leaf taking two kinds of ref is the only one with more to say +// than its Short. +type grantListBranch struct { + long string + example string + claims func(ref string) bool + list func(cmd *cobra.Command, ref string) error } func newOrgGrantCmd() *cobra.Command { return newGrantSubtreeCmd(orgGrantTarget) } @@ -68,117 +117,467 @@ func newGrantAddCmd[Row any](t grantTarget[Row]) *cobra.Command { roleHelp := "Role: one of " + strings.Join(t.roles, ", ") if required { example += " --role " + t.roles[0] - roleHelp += " (required)" + roleHelp += " (required; asked for if omitted on a terminal)" } else { roleHelp += " (default " + t.defaultRole + ")" } + // A target with no candidate pool (org) keeps the two-argument shape, so + // cobra reports a missing grantee exactly as it always has. + use := fmt.Sprintf("add <%s> ", t.noun) + long := fmt.Sprintf("Grant a user (addressed as provider:handle, e.g. github:alice) %s access. The %s is addressed by %s.", t.noun, t.noun, t.refUsage) + args := cobra.ExactArgs(2) + if t.candidates != nil { + use = fmt.Sprintf("add <%s> [grantee]", t.noun) + long += fmt.Sprintf(" Omit the grantee on a terminal to choose from the members of the owning org who do not have %s access yet, and set a role for each.", t.noun) + args = cobra.RangeArgs(1, 2) + } cmd := &cobra.Command{ - Use: fmt.Sprintf("add <%s> ", t.noun), + Use: use, Short: fmt.Sprintf("Grant a user %s access", t.noun), - Long: fmt.Sprintf("Grant a user (addressed as provider:handle, e.g. github:alice) %s access. The %s is addressed by %s.", t.noun, t.noun, t.refUsage), + Long: long, Example: example, - Args: cobra.ExactArgs(2), + Args: args, + PreRunE: refuseUnwritableRef(t), RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true // A role the user typed is always checked, an explicit `--role=` - // included: markRequired asks only whether the flag was given, and - // on org an empty value is not the same as leaving the flag out. - // Only an omitted --role means the server default, which exists - // only where required is false. + // included: an empty value is not the same as leaving the flag out. + // An omitted --role means the server default where the target has + // one, and otherwise is resolved per grantee below. if cmd.Flags().Changed("role") { if err := validateRole(role, t.roles); err != nil { - cmd.SilenceUsage = true return err } } - return runCoreMutation(cmd, func(ctx context.Context, c *coreapi.Client) (string, any, error) { - id, err := t.resolve(ctx, c, args[0]) - if err != nil { - return "", nil, err + pt := grantPickerTarget{noun: t.noun, ref: args[0], roles: t.roles, least: t.leastRole} + grantee := "" + if len(args) == 2 { + grantee = args[1] + if err := ensureGranteeIsHandle(grantee); err != nil { + return err } - provider, providerUserID, err := resolveGranteeProvider(ctx, c, args[1]) + } + // Both refusals a non-interactive run can hit are decided from the + // command line alone, so they are settled before any request: an + // unanswerable prompt must not cost a lookup, and an omitted --role + // must not reach the API — the property cobra's required-flag check + // used to provide. + if !interactive.CanPromptInteractively() { + if grantee == "" { + return pickerUnavailable(pt, "no grantee given") + } + if role == "" && required { + return missingRoleErr(t.roles) + } + } + return runCore(cmd, func(ctx context.Context, c *coreapi.Client) error { + id, err := t.resolve(ctx, c, args[0]) if err != nil { - return "", nil, err + return err } - granted, wire, err := t.grant(ctx, c, id, provider, providerUserID, role) + picked, err := resolveGrantSelections(ctx, cmd, c, t, pt, grantee, id, role, required) if err != nil { - return "", nil, err + return err } - return fmt.Sprintf("✓ Granted %s %s access to %s %s", args[1], granted, t.noun, args[0]), wire, nil + return grantEach(ctx, cmd, c, t, pt, id, picked, grantee != "") }) }, } cmd.Flags().StringVar(&role, "role", "", roleHelp) - if required { - markRequired(cmd, "role") - } addJSONFlag(cmd) return cmd } +// resolveGrantSelections turns the command line into the grantee/role pairs to +// grant. A grantee argument is one pair, taking --role or the prompt; an omitted +// grantee opens the picker. +// +// --role is deliberately NOT cobra-required, even where the target has no server +// default: cobra enforces required flags before RunE, which would make a role +// impossible to prompt for. The property that used to guarantee — an omitted +// role never reaching validation, a lookup, or the API — is kept here instead, +// by resolving one before anything is granted. +func resolveGrantSelections[Row any](ctx context.Context, cmd *cobra.Command, c *coreapi.Client, t grantTarget[Row], pt grantPickerTarget, grantee, id, role string, roleRequired bool) ([]grantSelection, error) { + if grantee != "" { + // A grantee with no --role where the target has no server default still + // needs one; prompting for it is the one-row version of the picker's + // second screen. The non-interactive case was refused before any request. + if role == "" && roleRequired { + return grantPicker(cmd, pt, nil, []string{grantee}, "") + } + return []grantSelection{{handle: grantee, role: role}}, nil + } + pool, err := t.candidates(ctx, c, id) + if err != nil { + var notOrg *ownerNotOrgError + if errors.As(err, ¬Org) { + return nil, pickerUnavailable(pt, t.ownerNotOrg(pt, notOrg.project)) + } + return nil, err + } + if len(pool.candidates) == 0 { + // Every sentence below is a statement about the WHOLE org, and a + // truncated walk read the first N of it — so on a partial pool none of + // them can be said, and this is the one empty pool that is not a clean + // success: the state the user wanted may not hold at all, it simply was + // not looked for past the budget. Name the way through instead. + if pool.window.partial { + return nil, pickerUnavailable(pt, fmt.Sprintf("none of the first %d members of the org owning %s can be added here, and it has more", pool.window.scanned, pt.describe())) + } + // An empty pool is otherwise not a failure. Nothing went wrong, nothing + // is left for the user to fix, and in the common case the state they + // wanted already holds — the same reasoning that makes revoking an + // already-revoked grant a success rather than a 404. So this reports + // and stops, and the command exits 0. + // + // Three different answers to "who can I add?", so three messages. The + // last is not "already has access": a member holding the target only + // through its project is still offered, so reaching it means every one + // of them holds a grant on this target itself. + var reason string + switch { + case pool.window.scanned == 0: + reason = pt.describe() + " has no org members to choose from" + case pool.addressable == 0: + reason = fmt.Sprintf("no member of the org owning %s can be granted access here", pt.describe()) + default: + reason = fmt.Sprintf("every member of the org owning %s already has a grant on it", pt.describe()) + } + reportNothingToAdd(cmd, reason) + return nil, nil + } + if pool.window.partial { + pt.poolNote = partialPoolNote(pool.window, "members of the org owning "+pt.describe()) + } + return grantPicker(cmd, pt, pool.candidates, nil, role) +} + +// reportNothingToAdd says why a pool came back empty. The reason is the human +// output; with --json the stdout shape has to stay parseable, so it moves to +// stderr and grantEach puts the empty array that "no grants were made" means +// on stdout instead. +func reportNothingToAdd(cmd *cobra.Command, reason string) { + w := cmd.OutOrStdout() + if jsonRequested(cmd) { + w = cmd.ErrOrStderr() + } + fmt.Fprintln(w, reason) +} + +// grantEach grants every pair in turn. On a failure it stops and returns, +// having reported the grants that already landed: those are real, and the CLI +// cannot undo them, so the user needs to know which ones to skip on a retry. +func grantEach[Row any](ctx context.Context, cmd *cobra.Command, c *coreapi.Client, t grantTarget[Row], pt grantPickerTarget, id string, picked []grantSelection, single bool) error { + wires := make([]any, 0, len(picked)) + for _, p := range picked { + provider, providerUserID, err := resolveGranteeProvider(ctx, c, p.handle) + if err != nil { + return errors.Join(err, emitGrantJSON(cmd, wires, single)) + } + granted, wire, err := t.grant(ctx, c, id, provider, providerUserID, p.role) + if err != nil { + return errors.Join(err, emitGrantJSON(cmd, wires, single)) + } + wires = append(wires, wire) + if !jsonRequested(cmd) { + fmt.Fprintf(cmd.OutOrStdout(), "✓ Granted %s %s access to %s\n", p.handle, granted, pt.describe()) + } + } + return emitGrantJSON(cmd, wires, single) +} + +// emitGrantJSON writes the wire objects for --json; text mode has already +// printed a line per grant as it went. +// +// The shape follows the INVOCATION, not the outcome. A grantee named on the +// command line is one mutation and emits the bare wire object, which is what +// every other mutation's --json emits (`org create`, `project create`, and what +// `grant add` itself emitted through runCoreMutation before the picker existed) +// — so the scripted form neither breaks nor makes this the one command in the +// CLI answering a mutation with an array. The picker grants a set and emits an +// array, one entry per grant that landed, including none, so a caller reading +// it never has to branch. +// +// Deciding on the OUTCOME instead — an array only once more than one landed — +// is the version to avoid: it makes a picker run that granted one person +// indistinguishable from a typed one, so the shape depends on what the user +// happened to click. +func emitGrantJSON(cmd *cobra.Command, wires []any, single bool) error { + if !jsonRequested(cmd) { + return nil + } + if single { + // A failed single grant emits nothing, as runCoreMutation always did. + if len(wires) == 0 { + return nil + } + return printJSON(cmd.OutOrStdout(), wires[0]) + } + return printJSON(cmd.OutOrStdout(), wires) +} + +// missingRoleErr replaces cobra's required-flag message for --role, which no +// longer marks it required (see resolveGrantSelections). +func missingRoleErr(roles []string) error { + return fmt.Errorf("--role is required: one of %s", strings.Join(roles, ", ")) +} + func newGrantListCmd[Row any](t grantTarget[Row]) *cobra.Command { cmd := &cobra.Command{ Use: fmt.Sprintf("list <%s>", t.noun), Short: fmt.Sprintf("List who has %s access", t.noun), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if t.listBranch != nil && t.listBranch.claims(args[0]) { + return t.listBranch.list(cmd, args[0]) + } return runCoreList(cmd, "No grants found.", t.columns, t.row, func(ctx context.Context, c *coreapi.Client) ([]Row, error) { id, err := t.resolve(ctx, c, args[0]) if err != nil { return nil, err } - return fetchAllPages(ctx, func(ctx context.Context, cursor string) ([]Row, string, error) { - var pageToken coreapi.OptString - if cursor != "" { - pageToken = coreapi.NewOptString(cursor) - } + return pagedList(ctx, func(ctx context.Context, pageToken coreapi.OptString) ([]Row, coreapi.OptString, error) { return t.list(ctx, c, id, pageToken) }) }) }, } addJSONFlag(cmd) + if t.listBranch != nil { + cmd.Long, cmd.Example = t.listBranch.long, t.listBranch.example + } return cmd } func newGrantRemoveCmd[Row any](t grantTarget[Row]) *cobra.Command { - grantee := "a provider-qualified handle (e.g. github:alice)" - if t.revokeByID != nil { - grantee += " or an account ULID" - } - return &cobra.Command{ - Use: fmt.Sprintf("remove <%s> ", t.noun), - Short: fmt.Sprintf("Revoke a user's %s access", t.noun), - Long: fmt.Sprintf("Revoke a grantee's %s access. The %s is addressed by %s; the grantee is %s.", t.noun, t.noun, t.refUsage, grantee), + cmd := &cobra.Command{ + Use: fmt.Sprintf("remove <%s> [grantee]", t.noun), + Short: fmt.Sprintf("Revoke a user's %s access", t.noun), + Long: fmt.Sprintf("Revoke a grantee's %s access. The %s is addressed by %s; the grantee is a "+ + "provider-qualified handle (e.g. github:alice). Omit the grantee on a terminal to choose "+ + "from who holds %s access now.", t.noun, t.noun, t.refUsage, t.noun), Example: fmt.Sprintf(" entire %s grant remove %s github:alice", t.noun, t.exampleRef), - Args: cobra.ExactArgs(2), + Args: cobra.RangeArgs(1, 2), + PreRunE: refuseUnwritableRef(t), RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceUsage = true + pt := grantPickerTarget{noun: t.noun, ref: args[0], roles: t.roles, least: t.leastRole} + if len(args) == 2 { + if err := ensureGranteeIsHandle(args[1]); err != nil { + return err + } + } + // Decided before any request, like the add side: without a prompt + // the list of who holds the target has no use here. + if len(args) == 1 && !interactive.CanPromptInteractively() { + return granteeRequiredErr(pt) + } return runCore(cmd, func(ctx context.Context, c *coreapi.Client) error { id, err := t.resolve(ctx, c, args[0]) if err != nil { return err } - target := t.noun + " " + args[0] - if t.revokeByID != nil && looksLikeULID(args[1]) { - return revokeGrant(cmd, "account "+args[1]+" from "+target, func() error { - return t.revokeByID(ctx, c, id, args[1]) - }) - } - provider, providerUserID, err := resolveGranteeProvider(ctx, c, args[1]) - if err != nil { + // Either way the set is grantCandidates: a typed grantee is + // its own ref and label, while a picked row is reported by the + // name the picker showed rather than the id it acts on. + typed := len(args) == 2 + picked := []grantCandidate{} + if typed { + picked = append(picked, handleCandidate(args[1])) + } else if picked, err = pickGrantsToRevoke(ctx, cmd, c, t, pt, id); err != nil { return err } - return revokeGrant(cmd, args[1]+" from "+target, func() error { - return t.revokeByProvider(ctx, c, id, provider, providerUserID) - }) + // Nobody chosen: nothing to confirm and nothing to revoke. + // Confirming anyway asked "Revoke 0 grants on …?". + if len(picked) == 0 { + return nil + } + // Only the picker confirms, once for the whole set it chose. + // + // A typed grantee is already an explicit instruction naming who + // to revoke, and it is the form a script uses: `entire + // grant remove github:alice` revoked without asking before + // this command grew a picker, and still does — whether or not a + // terminal happens to be attached, so a redirected stdin cannot + // turn it into a prompt nobody answers. What a confirmation is + // for is a set clicked off a list, where the user chose rows + // rather than names. Gating only that path is also what leaves + // nothing to bypass: no caller who wanted --force ever meets it. + if !typed { + proceed, err := revokeConfirmed(cmd, pt, picked) + if err != nil { + return err + } + if !proceed { + // Declined. revokeConfirmed said so where the prompt + // was and nothing has been revoked, so this is a clean + // stop — kept a branch of its own, because folding it + // into the one above returns a known-nil err to mean + // success and hides which outcome this is. + return nil + } + } + for _, p := range picked { + if err := revokeOne(ctx, cmd, c, t, pt, id, p); err != nil { + return err + } + } + return nil }) }, } + return cmd +} + +// revokeConfirmed is the seam the confirmation sits behind, matching +// removePicker's role for the picker: the form needs a terminal, which `go +// test` does not have, so a test answers it here instead. +// +// It is the picker's own prompt rather than confirmControlPlaneDeletion's. +// That gate exists to refuse without a terminal and offers --force to get past +// the refusal; this one has neither, because it only ever runs behind the +// picker. And it must be read where the picker was shown: runPromptForm puts +// the question on a writer the user can see, where huh's accessible mode would +// otherwise print it to stdout, in among the `✓ Revoked` lines. +var revokeConfirmed = func(cmd *cobra.Command, pt grantPickerTarget, picked []grantCandidate) (bool, error) { + if err := revocationInterrupted(cmd); err != nil { + return false, err + } + label, detail := revokeConfirmation(pt, picked) + confirmed := false + prompt := huh.NewConfirm().Title("Revoke " + label + "?").Value(&confirmed) + if detail != "" { + prompt = prompt.Description(detail) + } + render, err := runPromptForm(cmd, NewAccessibleForm(huh.NewGroup(prompt))) + // Before the form error is looked at, because handleFormCancellation treats + // context.Canceled as a clean abort and would report a signal as an answer. + if ierr := revocationInterrupted(cmd); ierr != nil { + return false, ierr + } + if err != nil { + // An abort at the prompt IS an answer: Esc or Ctrl+C inside the form is + // the user saying no, which is a decision rather than a failure. + if cerr := handleFormCancellation(render, "Revocation", err); cerr != nil { + return false, cerr + } + return false, nil + } + if !confirmed { + fmt.Fprintln(render, "Revocation cancelled.") + return false, nil + } + return true, nil +} + +// revocationInterrupted reports a command context that has been cancelled out +// from under the confirmation, which is an interruption and not an answer. +// +// (false, nil) means the user declined, and nothing else may borrow it: the +// caller exits 0 on it. Wrapping ctx.Err() instead is what lets main.go match +// the signal it recorded and exit the way every other Ctrl+C in this CLI does — +// quietly, 130, breaking an enclosing shell loop. plugin_confirm.go is the +// shape this follows, checking either side of its form for the same reason; +// confirmControlPlaneDeletion's nilerr skip is the outlier, and carries the +// same bug for `delete`. +// +// Checked before the form as well as after, because huh opens the TTY during +// startup regardless of context state. +func revocationInterrupted(cmd *cobra.Command) error { + if err := cmd.Context().Err(); err != nil { + return fmt.Errorf("revocation cancelled: %w", err) + } + return nil +} + +// revokeConfirmation describes what is about to be revoked. A single grantee +// reads as one sentence; several are counted in the title and listed under it, +// so the prompt never hides who is in the set behind a number. +func revokeConfirmation(pt grantPickerTarget, picked []grantCandidate) (label, detail string) { + if len(picked) == 1 { + return picked[0].option() + " from " + pt.describe(), "" + } + var b strings.Builder + for _, p := range picked { + fmt.Fprintf(&b, "%s%s\n", uiform.SelectOptionIndent, p.option()) + } + return fmt.Sprintf("%d grants on %s", len(picked), pt.describe()), b.String() +} + +// pickGrantsToRevoke offers who holds the target now. An empty pool is an +// error rather than a silent success: the user asked to revoke something and +// nothing was revoked. +func pickGrantsToRevoke[Row any](ctx context.Context, cmd *cobra.Command, c *coreapi.Client, t grantTarget[Row], pt grantPickerTarget, id string) ([]grantCandidate, error) { + holders, window, err := t.holders(ctx, c, id) + if err != nil { + return nil, err + } + if len(holders) == 0 { + // "has no grants" is a statement about the target; a truncated walk + // only read the first N rows on it, which is a different claim and a + // different remedy. + if window.partial { + return nil, revokeUnavailable(pt, fmt.Sprintf("none of the first %d grants on %s can be revoked here, and it has more", window.scanned, pt.describe())) + } + return nil, fmt.Errorf("%s has no grants that can be revoked here", pt.describe()) + } + if window.partial { + pt.poolNote = partialPoolNote(window, "grants on "+pt.describe()) + } + // Whole rows, so the confirmation can name what was shown rather than the + // id it acts on. + return removePicker(cmd, pt, holders) +} + +// revokeOne revokes a single grantee. It routes on g.byID — set by the pool +// that built the row, never sniffed from the ref's shape (see +// grantCandidate.byID) — so a picked project or repo row goes by ULID, while a +// typed grantee, which is always a handle, resolves its provider identity first. +func revokeOne[Row any](ctx context.Context, cmd *cobra.Command, c *coreapi.Client, t grantTarget[Row], pt grantPickerTarget, id string, g grantCandidate) error { + if g.byID { + return revokeGrant(cmd, g.label+" from "+pt.describe(), func() error { + return t.revokeByID(ctx, c, id, g.ref) + }) + } + provider, providerUserID, err := resolveGranteeProvider(ctx, c, g.ref) + if err != nil { + return err + } + return revokeGrant(cmd, g.label+" from "+pt.describe(), func() error { + return t.revokeByProvider(ctx, c, id, provider, providerUserID) + }) +} + +// granteeRequiredErr is the no-terminal case of revokeUnavailable: nothing to +// choose on, so the grantee has to be named. One message serves all three +// targets, because all three accept the same single form. +func granteeRequiredErr(pt grantPickerTarget) error { + return revokeUnavailable(pt, "no grantee given") +} + +// refuseUnwritableRef is the write verbs' first question: does this ref name +// something the target can write at all? Cobra runs PreRunE before it validates +// required flags, which is the point — `repo grant add /gh/acme/widget alice` +// is answered with what is wrong (a mirror's access lives on GitHub) rather +// than sending the user to add a --role that changes nothing. nil for a target +// with no such ref, which leaves the hook off the command entirely. +func refuseUnwritableRef[Row any](t grantTarget[Row]) func(*cobra.Command, []string) error { + if t.unwritableRef == nil { + return nil + } + return func(cmd *cobra.Command, args []string) error { + if err := t.unwritableRef(args[0]); err != nil { + cmd.SilenceUsage = true + return err + } + return nil + } } // validateRole rejects a --role outside the target's set at the CLI boundary // so the user gets a clear message instead of a server 422. The generated -// bodies use a distinct enum type per target that shares these values, so the -// targets cast the validated string to whichever type they need. +// bodies type their role field as an enum, so the targets cast the validated +// string to whichever type they need. func validateRole(role string, allowed []string) error { if slices.Contains(allowed, role) { return nil @@ -210,8 +609,8 @@ func revokeGrant(cmd *cobra.Command, subject string, revoke func() error) error // so they add SOURCE and TYPE. No table prints an internal id: the grantee // ULID is in the --json output for anyone who needs it. var ( - orgMemberColumns = []string{"GRANTEE", colHeaderRole, colHeaderStatus} - grantColumns = []string{"GRANTEE", colHeaderRole, "SOURCE", "TYPE"} + orgMemberColumns = []string{colHeaderGrantee, colHeaderRole, colHeaderStatus} + grantColumns = []string{colHeaderGrantee, colHeaderRole, "SOURCE", "TYPE"} ) func orgMemberRow(m coreapi.Membership) []string { @@ -242,9 +641,28 @@ func granteeName(name coreapi.OptString, granteeID string) string { const granteeTypeAccount = "account" // accessRoles are the project and repo grant roles; the two targets share the -// set because the server's SpiceDB relations are the same for both. +// set because the server's SpiceDB relations are the same for both. They are +// listed least-privileged first, which leastAccessRole names so nothing has to +// rely on that order holding. var accessRoles = []string{"reader", "writer", "admin"} +const leastAccessRole = "reader" + +// orgRoleMember is the org's least-privileged role and the server's default. +const orgRoleMember = "member" + +// grantAccessBody builds the request body the project and repo grant routes +// share. Provider and providerUserId are optional on the wire because the +// route also accepts an accountId; the CLI always addresses a grantee by +// provider handle, so it always sends the pair. +func grantAccessBody(provider, providerUserID, role string) *coreapi.GrantAccessBody { + return &coreapi.GrantAccessBody{ + Provider: coreapi.NewOptString(provider), + ProviderUserId: coreapi.NewOptString(providerUserID), + Role: coreapi.GrantAccessBodyRole(role), + } +} + // orgGrantTarget is org membership: roles owner/admin/member with member as // the server default, a target addressed by name or ULID, and no typed-id // revoke route — members are removed by their provider identity. @@ -252,8 +670,9 @@ var orgGrantTarget = grantTarget[coreapi.Membership]{ noun: cmdOrg, refUsage: "name or ULID", exampleRef: "acme", - roles: []string{"owner", "admin", "member"}, - defaultRole: "member", + roles: []string{"owner", "admin", orgRoleMember}, + leastRole: orgRoleMember, + defaultRole: orgRoleMember, columns: orgMemberColumns, row: orgMemberRow, resolve: resolveOrgRef, @@ -268,16 +687,17 @@ var orgGrantTarget = grantTarget[coreapi.Membership]{ } return m.Role, m, nil }, - list: func(ctx context.Context, c *coreapi.Client, id string, pageToken coreapi.OptString) ([]coreapi.Membership, string, error) { + list: func(ctx context.Context, c *coreapi.Client, id string, pageToken coreapi.OptString) ([]coreapi.Membership, coreapi.OptString, error) { out, err := c.ListOrgMembers(ctx, coreapi.ListOrgMembersParams{OrgId: id, PageToken: pageToken}) if err != nil { - return nil, "", err + return nil, coreapi.OptString{}, err } - return out.Members, out.NextPageToken.Or(""), nil + return out.Members, out.NextPageToken, nil }, revokeByProvider: func(ctx context.Context, c *coreapi.Client, id, provider, providerUserID string) error { return c.RemoveOrgMember(ctx, coreapi.RemoveOrgMemberParams{OrgId: id, Provider: provider, ProviderUserId: providerUserID}) }, + holders: orgMemberHolders, } // projectGrantTarget is project access: roles reader/writer/admin, required, @@ -287,28 +707,32 @@ var projectGrantTarget = grantTarget[coreapi.ProjectGrant]{ refUsage: "name or ULID", exampleRef: "widgets", roles: accessRoles, + leastRole: leastAccessRole, columns: grantColumns, row: projectGrantRow, resolve: func(ctx context.Context, c *coreapi.Client, ref string) (string, error) { return resolveProjectRef(ctx, c, ref) }, + candidates: projectGrantCandidates, + holders: projectGrantHolders, + ownerNotOrg: func(pt grantPickerTarget, _ string) string { + // The project the user named IS the account-owned one, so its name is + // already in pt.ref and the error's copy would just repeat it. + return pt.describe() + " is owned by an account, so it has no member list to choose from" + }, grant: func(ctx context.Context, c *coreapi.Client, id, provider, providerUserID, role string) (string, any, error) { - out, err := c.GrantProjectAccess(ctx, &coreapi.GrantProjectAccessInputBody{ - Provider: provider, - ProviderUserId: providerUserID, - Role: coreapi.GrantProjectAccessInputBodyRole(role), - }, coreapi.GrantProjectAccessParams{ProjectId: id}) + out, err := c.GrantProjectAccess(ctx, grantAccessBody(provider, providerUserID, role), coreapi.GrantProjectAccessParams{ProjectId: id}) if err != nil { return "", nil, err } return role, out, nil }, - list: func(ctx context.Context, c *coreapi.Client, id string, pageToken coreapi.OptString) ([]coreapi.ProjectGrant, string, error) { + list: func(ctx context.Context, c *coreapi.Client, id string, pageToken coreapi.OptString) ([]coreapi.ProjectGrant, coreapi.OptString, error) { out, err := c.ListProjectMembers(ctx, coreapi.ListProjectMembersParams{ProjectId: id, PageToken: pageToken}) if err != nil { - return nil, "", err + return nil, coreapi.OptString{}, err } - return out.Members, out.NextPageToken.Or(""), nil + return out.Members, out.NextPageToken, nil }, revokeByProvider: func(ctx context.Context, c *coreapi.Client, id, provider, providerUserID string) error { return c.RevokeProjectAccessByProvider(ctx, coreapi.RevokeProjectAccessByProviderParams{ProjectId: id, Provider: provider, ProviderUserId: providerUserID}) @@ -319,34 +743,42 @@ var projectGrantTarget = grantTarget[coreapi.ProjectGrant]{ } // repoGrantTarget is repo access: roles reader/writer/admin, required, on a -// repo addressed by its /et// path and nothing else. +// repo addressed by its /et// path and nothing else. `list` +// alone also answers a GitHub mirror ref, from the upstream collaborators the +// placement materializes — see mirrorGrantListing. var repoGrantTarget = grantTarget[coreapi.RepoGrant]{ - noun: cmdRepo, - refUsage: "its /" + nativeCloneForge + "// path", - exampleRef: "/" + nativeCloneForge + "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/acme/web", - roles: accessRoles, - columns: grantColumns, - row: repoGrantRow, + noun: cmdRepo, + refUsage: "its /" + nativeCloneForge + "// path", + exampleRef: "/" + nativeCloneForge + "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/acme/web", + roles: accessRoles, + leastRole: leastAccessRole, + columns: grantColumns, + row: repoGrantRow, + listBranch: mirrorGrantListing, + unwritableRef: mirrorGrantsAreUpstream, resolve: func(ctx context.Context, c *coreapi.Client, ref string) (string, error) { return resolveRepoPath(ctx, c, ref) }, + candidates: repoGrantCandidates, + holders: repoGrantHolders, + ownerNotOrg: func(pt grantPickerTarget, project string) string { + // A repo's pool comes from its project's org, so the refusal names the + // project in between rather than leaving the user to find it. + return fmt.Sprintf("%s is in project %s, which is owned by an account, so it has no member list to choose from", pt.describe(), project) + }, grant: func(ctx context.Context, c *coreapi.Client, id, provider, providerUserID, role string) (string, any, error) { - out, err := c.GrantRepoAccess(ctx, &coreapi.GrantRepoAccessInputBody{ - Provider: provider, - ProviderUserId: providerUserID, - Role: coreapi.GrantRepoAccessInputBodyRole(role), - }, coreapi.GrantRepoAccessParams{RepoId: id}) + out, err := c.GrantRepoAccess(ctx, grantAccessBody(provider, providerUserID, role), coreapi.GrantRepoAccessParams{RepoId: id}) if err != nil { return "", nil, err } return role, out, nil }, - list: func(ctx context.Context, c *coreapi.Client, id string, pageToken coreapi.OptString) ([]coreapi.RepoGrant, string, error) { + list: func(ctx context.Context, c *coreapi.Client, id string, pageToken coreapi.OptString) ([]coreapi.RepoGrant, coreapi.OptString, error) { out, err := c.ListRepoGrants(ctx, coreapi.ListRepoGrantsParams{RepoId: id, PageToken: pageToken}) if err != nil { - return nil, "", err + return nil, coreapi.OptString{}, err } - return out.Grants, out.NextPageToken.Or(""), nil + return out.Grants, out.NextPageToken, nil }, revokeByProvider: func(ctx context.Context, c *coreapi.Client, id, provider, providerUserID string) error { return c.RevokeRepoAccessByProvider(ctx, coreapi.RevokeRepoAccessByProviderParams{RepoId: id, Provider: provider, ProviderUserId: providerUserID}) diff --git a/cmd/entire/cli/grant_picker.go b/cmd/entire/cli/grant_picker.go new file mode 100644 index 0000000000..b502e8fe03 --- /dev/null +++ b/cmd/entire/cli/grant_picker.go @@ -0,0 +1,703 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "charm.land/huh/v2" + "github.com/spf13/cobra" + + "github.com/entireio/cli/cmd/entire/cli/uiform" + "github.com/entireio/cli/internal/coreapi" +) + +// The interactive half of ` grant add`: when the grantee argument is +// omitted, offer the people who could be granted the target and collect a role +// for each. +// +// The pool is the owning org's membership minus whoever holds a DIRECT grant on +// the target. Org membership does not itself grant project or repo access — the +// server's authz schema gives an org member `view` but neither `read` nor +// `write` — so every org member is a real candidate until they hold one. +// +// The `project:` rows `ListRepoGrants` returns alongside the direct ones +// are deliberately NOT subtracted. Project access does reach the project's +// repos, but a member holding a repo only that way has no grant on the repo +// itself, so granting one is a real action: it pins the role here rather than +// following the project's. Subtracting them emptied the pool on any repo whose +// project already covered the org — see orgMemberCandidates, which is where +// this rule is argued out in full. +// +// Org membership is also the only pool whose entries can be granted directly. +// Project and repo grant rows carry a grantee ULID and no provider identity, +// and there is no reverse lookup from one to the other, whereas a Membership +// carries the provider-qualified handle that resolveGranteeProvider already +// takes. So a selection is a handle string and rejoins the typed path every +// other grantee takes. + +// grantCandidate is one row a picker offers. ref is how the command addresses +// that grantee — the same spelling a user could have typed — so a selection +// rejoins the typed path instead of needing one of its own. label is what the +// picker shows. +// +// The two differ only when removing: a project or repo grant is revoked by +// account ULID through a typed route, which needs no handle lookup and cannot +// be defeated by a handle that has since been renamed, while the row still +// shows the friendly name the server resolved. When adding, and for org +// membership either way, ref is the provider-qualified handle and the two are +// the same string. +type grantCandidate struct { + ref string + label string + // role is what the grantee holds on the target today, for the remove pools: + // revoking is destructive and the row is the last thing the user reads + // before confirming, so it says what is being taken away and not only from + // whom. The add pools leave it empty — nobody in that pool holds anything + // on the target yet — and nothing ever acts on it. + role string + // byID routes the revoke through the typed-id route rather than resolving + // the ref as a handle. Set only by the remove pools, which read the grantee + // ULID off a listing row; it is never shown and never typed. Sniffing the + // ref's shape instead would both mistake a non-ULID grantee id for a handle + // and let a user reach the typed-id route by pasting one, which the CLI no + // longer accepts — a grantee is a provider-qualified handle and nothing else. + byID bool +} + +// handleCandidate is a candidate addressed and shown by its handle. +func handleCandidate(handle string) grantCandidate { + return grantCandidate{ref: handle, label: handle} +} + +// option is the row as a picker shows it and as a prompt names it. label is the +// identity and is what every message about the grant says; the role is +// parenthesised after it where one is known, so a destructive choice is made +// and confirmed with the access in view. +// +// Parenthesised rather than spaced into a column because the labels are +// handles of every length, so no separator aligns them — and the single-grantee +// confirmation puts this inside a sentence ("Revoke github:alice (admin) from +// project widgets?"), where a column gap reads as a typo. +// +// Source is deliberately absent: the remove pools offer direct grants only, so +// it would be the same word on every row. +func (c grantCandidate) option() string { + if c.role == "" { + return c.label + } + return c.label + " (" + c.role + ")" +} + +// grantSelection pairs a chosen grantee with the role to grant them. Roles are +// per grantee rather than one role for the whole selection, so a single run can +// add a reader and an admin together. +type grantSelection struct { + handle string + role string +} + +// pagedList walks a control-plane listing to the end. Every one of these +// listings takes an OptString cursor and returns an OptString next token, so +// the conversion at both ends is the same three times over; only the call in +// the middle differs. +func pagedList[T any](ctx context.Context, fetch func(ctx context.Context, pageToken coreapi.OptString) (items []T, next coreapi.OptString, err error)) ([]T, error) { + items, _, err := boundedList(ctx, 0, fetch) + return items, err +} + +// boundedList is pagedList with a fetch budget, reporting whether the walk +// stopped with entries left behind. +// +// A listing that IS a pool is fetched this way: it is read before a single row +// can be shown, so on a large org an unbounded walk costs one round trip per +// page before the picker appears, and a pool longer than the budget is past +// being choosable from anyway. A listing used as a FILTER — the grants +// subtracted from the add pool — stays unbounded, because a partial filter +// would offer someone the target they already hold. +func boundedList[T any](ctx context.Context, budget int, fetch func(ctx context.Context, pageToken coreapi.OptString) (items []T, next coreapi.OptString, err error)) ([]T, bool, error) { + return fetchPagesBounded(ctx, budget, func(ctx context.Context, cursor string) ([]T, string, error) { + var pageToken coreapi.OptString + if cursor != "" { + pageToken = coreapi.NewOptString(cursor) + } + items, next, err := fetch(ctx, pageToken) + if err != nil { + return nil, "", err + } + return items, next.Or(""), nil + }) +} + +// listWindow is what a bounded walk knows about its own completeness: how many +// listing rows it READ, and whether more were left behind. +// +// scanned counts rows read, never rows kept. Every use of it is a statement +// about how much of a listing something covers, and the pools drop rows — an +// org member who already holds the target, a grant the owner row carries — so +// "the first 800 members" would name a number that was never the first +// anything. It is also what makes an empty pool sayable: with more rows +// unread, "every member already has a grant" is a claim about an org this +// never looked at. +type listWindow struct { + scanned int + partial bool +} + +// partialPoolNote is what a truncated pool has to say for itself. A picker is a +// convenience over a listing the control plane cannot filter or sort, so a +// truncated one is still useful — what it must not do is look complete. +// +// It is text for the picker to SHOW, not a line to print before it. The form +// may be rendering on the controlling terminal precisely because stderr is +// redirected (see runPromptForm), so a caveat written to a stream is one the +// person reading the list never sees — and it would be missing in exactly the +// case the routing exists for. Carried to the screen on grantPickerTarget. +func partialPoolNote(w listWindow, what string) string { + return fmt.Sprintf("Only the first %d %s were read — cancel and name the grantee if the one you want is not listed.", w.scanned, what) +} + +// ownerNotOrgError reports that a project is owned by an account rather than an +// org, so no membership list exists to draw candidates from. It carries the +// project's name because the target phrases the refusal itself: a repo's message +// has to name the project standing between it and the missing org. +type ownerNotOrgError struct{ project string } + +func (e *ownerNotOrgError) Error() string { + return fmt.Sprintf("project %s is owned by an account, not an org", e.project) +} + +// owningOrgOf returns the ULID of the org owning projectID, or errOwnerNotOrg +// when an account owns it. +func owningOrgOf(ctx context.Context, c *coreapi.Client, projectID string) (string, error) { + p, err := c.GetProject(ctx, coreapi.GetProjectParams{ProjectId: projectID}) + if err != nil { + return "", err + } + if p.OwnerType != coreapi.ProjectOwnerTypeOrg || p.OwnerId == "" { + return "", &ownerNotOrgError{project: p.Name} + } + return p.OwnerId, nil +} + +// memberPool is the add picker's candidate set plus the counts needed to say +// why it is empty: an org with no members, one whose members cannot be +// addressed, and one where everyone already holds a grant are three different +// answers to "who can I add?". +type memberPool struct { + candidates []grantCandidate + window listWindow // how much of the org's membership this read + addressable int // of the members read, the ones that can be granted at all +} + +// orgMemberCandidates lists the owning org's members who can be granted the +// target. +// +// **A member with a DIRECT grant on the target is left out; one who only holds +// it through the project is offered.** Those are different situations, and +// conflating them is what made two earlier versions of this pool wrong in +// opposite directions. Someone with a direct grant has nothing to add here — +// `grant add --role` changes their role, which is the typed +// form's job. Someone whose access comes from the project has no grant on THIS +// target, so granting one is a real action: it pins the role on this repo +// rather than following the project's. Subtracting them too emptied the pool on +// any repo whose project already covered the org. +// +// Every candidate is shown as its plain handle. An earlier version annotated +// the inherited ones with the role and project they came from, which is true +// but is detail about a grant the user is not editing, in a list whose question +// is only "who". `grant list` is where the current state is read. +// +// Members that are not active, or that carry no handle, are dropped whatever +// they hold: neither can be resolved to the (provider, providerUserId) pair the +// grant routes need, so offering one would produce a selection that fails at +// the grant. +func orgMemberCandidates(ctx context.Context, c *coreapi.Client, orgID string, directHolders map[string]bool) (memberPool, error) { + members, partial, err := boundedList(ctx, coreListFetchBudget, listOrgMembers(c, orgID)) + if err != nil { + return memberPool{}, err + } + pool := memberPool{window: listWindow{scanned: len(members), partial: partial}} + for _, m := range members { + handle, ok := grantableMember(m) + if !ok { + continue + } + pool.addressable++ + if directHolders[m.AccountId] { + continue + } + pool.candidates = append(pool.candidates, handleCandidate(handle)) + } + return pool, nil +} + +// orgMembershipActive is the Membership.Status of a member who has joined. Any +// other status (invited, pending) may have no resolvable provider identity yet. +const orgMembershipActive = "active" + +// listOrgMembers is the one org-membership page call, shared by the pool that +// offers members for adding and the pool that offers them for removing. +func listOrgMembers(c *coreapi.Client, orgID string) func(context.Context, coreapi.OptString) ([]coreapi.Membership, coreapi.OptString, error) { + return func(ctx context.Context, pageToken coreapi.OptString) ([]coreapi.Membership, coreapi.OptString, error) { + out, err := c.ListOrgMembers(ctx, coreapi.ListOrgMembersParams{OrgId: orgID, PageToken: pageToken}) + if err != nil { + return nil, coreapi.OptString{}, err + } + return out.Members, out.NextPageToken, nil + } +} + +// grantableMember reports whether a membership row is worth offering in a +// picker. Both pools apply it, so the two agree on who is addressable — and on +// the remove side that matters twice over, because revoking walks the chosen +// set and returns on the first error, so one unaddressable row would strand +// every row after it. +// +// The handle is the load-bearing half: every org route goes through the +// (provider, providerUserId) pair resolveGranteeProvider derives from it, so a +// row without one cannot be acted on at all. +// +// The status half is a deliberately conservative guess, NOT an established +// server behaviour. Membership.status is an unconstrained string in +// core.openapi.json — no enum — and every membership observable from here (47 +// rows across four orgs) is "active" WITH a handle, so nothing proves that a +// non-active row would fail to resolve, or even that one can carry a handle. +// What makes the guess safe to keep is that it only hides a row from a +// PICKER: ` grant remove provider:handle` still addresses anyone +// the server will resolve. Verify against the control plane's real status +// vocabulary before relying on this as a rule, or before extending it to a +// path where being filtered out is the end of the road. +func grantableMember(m coreapi.Membership) (handle string, ok bool) { + handle = strings.TrimSpace(m.Handle.Or("")) + return handle, handle != "" && m.Status == orgMembershipActive +} + +// listProjectGrants and listRepoGrants are the one page call per target, +// shared by the pool that subtracts these rows and the pool that offers them. +func listProjectGrants(c *coreapi.Client, projectID string) func(context.Context, coreapi.OptString) ([]coreapi.ProjectGrant, coreapi.OptString, error) { + return func(ctx context.Context, pageToken coreapi.OptString) ([]coreapi.ProjectGrant, coreapi.OptString, error) { + out, err := c.ListProjectMembers(ctx, coreapi.ListProjectMembersParams{ProjectId: projectID, PageToken: pageToken}) + if err != nil { + return nil, coreapi.OptString{}, err + } + return out.Members, out.NextPageToken, nil + } +} + +func listRepoGrants(c *coreapi.Client, repoID string) func(context.Context, coreapi.OptString) ([]coreapi.RepoGrant, coreapi.OptString, error) { + return func(ctx context.Context, pageToken coreapi.OptString) ([]coreapi.RepoGrant, coreapi.OptString, error) { + out, err := c.ListRepoGrants(ctx, coreapi.ListRepoGrantsParams{RepoId: repoID, PageToken: pageToken}) + if err != nil { + return nil, coreapi.OptString{}, err + } + return out.Grants, out.NextPageToken, nil + } +} + +func projectGrantCandidates(ctx context.Context, c *coreapi.Client, projectID string) (memberPool, error) { + orgID, err := owningOrgOf(ctx, c, projectID) + if err != nil { + return memberPool{}, err + } + // Unbounded: these rows are the filter, not the pool. A partial one would + // offer a member the project access they already hold. + grants, err := pagedList(ctx, listProjectGrants(c, projectID)) + if err != nil { + return memberPool{}, err + } + return orgMemberCandidates(ctx, c, orgID, directHolders(mapRows(grants, projectGrantRowOf))) +} + +// grantRow is one project or repo listing row reduced to what the pools read. +// ProjectGrant and RepoGrant carry the same five fields under two types that +// share no interface, so each is mapped once here rather than threaded through +// both pools as a handful of accessors apiece. +type grantRow struct { + granteeID string + granteeType string + source string + name string + role string +} + +func projectGrantRowOf(g coreapi.ProjectGrant) grantRow { + return grantRow{granteeID: g.GranteeId, granteeType: g.GranteeType, source: g.Source, name: g.GranteeName.Or(""), role: g.Role} +} + +func repoGrantRowOf(g coreapi.RepoGrant) grantRow { + return grantRow{granteeID: g.GranteeId, granteeType: g.GranteeType, source: g.Source, name: g.GranteeName.Or(""), role: g.Role} +} + +// directHolders is the set of accounts holding a grant written on the resource +// itself. A row inherited from the project is deliberately not in it: that is +// access to the project, not a grant on this target, so it neither blocks a +// grant here nor could be revoked here. +func directHolders(rows []grantRow) map[string]bool { + held := make(map[string]bool, len(rows)) + for _, r := range rows { + if r.source == grantSourceDirect { + held[r.granteeID] = true + } + } + return held +} + +// mapRows converts a fetched listing page set into grantRows. +func mapRows[Row any](rows []Row, to func(Row) grantRow) []grantRow { + out := make([]grantRow, len(rows)) + for i, r := range rows { + out[i] = to(r) + } + return out +} + +// repoGrantCandidates offers the members of the org owning the repo's project +// who hold no DIRECT grant on the repo. ListRepoGrants also returns the rows +// the repo inherits from its project; those are not subtracted, because +// granting on the repo itself is a real action for someone who only holds it +// through the project. +func repoGrantCandidates(ctx context.Context, c *coreapi.Client, repoID string) (memberPool, error) { + repo, err := c.GetRepo(ctx, coreapi.GetRepoParams{RepoId: repoID}) + if err != nil { + return memberPool{}, err + } + orgID, err := owningOrgOf(ctx, c, repo.OwningProjectId) + if err != nil { + return memberPool{}, err + } + // Unbounded, for the reason projectGrantCandidates gives. + grants, err := pagedList(ctx, listRepoGrants(c, repoID)) + if err != nil { + return memberPool{}, err + } + // A repo lists its own grants and its project's; only the former counts. + return orgMemberCandidates(ctx, c, orgID, directHolders(mapRows(grants, repoGrantRowOf))) +} + +// grantPicker is the single seam the picker's forms sit behind. Production +// wiring is runGrantPicker; command-level tests swap it, because the forms are +// unreachable under `go test` (CanPromptInteractively is false there). One seam +// rather than one per screen, so a test states the whole outcome in one place. +var grantPicker = runGrantPicker + +// runGrantPicker collects the grantee/role pairs. known non-empty means the +// grantee came from the command line and only roles are wanted, which is the +// one-row version of the same second screen. fixedRole non-empty means --role +// was given: the role rows are then displayed rather than offered, so the user +// still sees what each grantee will receive but cannot change it. +func runGrantPicker(cmd *cobra.Command, t grantPickerTarget, candidates []grantCandidate, known []string, fixedRole string) ([]grantSelection, error) { + chosen := make([]grantCandidate, 0, len(known)) + for _, h := range known { + chosen = append(chosen, handleCandidate(h)) + } + if len(chosen) == 0 { + picked, err := pickGrantees(cmd, t, grantAction, "Select grantees for "+t.describe(), candidates) + if err != nil { + return nil, err + } + chosen = picked + // Nobody chosen is a decision not to grant anything, so stop here. + // Falling through asked for a role per grantee over an empty list, + // which with --role is a note about nothing and without it hands huh a + // group with no fields — and huh indexes its first field unguarded, so + // that panics rather than merely looking odd. + if len(chosen) == 0 { + return nil, nil + } + } + return pickRoles(cmd, t, chosen, fixedRole) +} + +// grantPickerTarget is what the picker needs to know about the target it is +// granting: the noun and ref for its titles, the roles to offer, and which of +// them a row starts on. +type grantPickerTarget struct { + noun string + ref string + roles []string + // least is the least-privileged role, which is what an unanswered row + // should grant and what an example in a refusal should suggest. It is named + // rather than taken as roles[0], because help order runs in opposite + // directions: reader/writer/admin is least first, owner/admin/member last. + least string + // poolNote qualifies the rows on offer — today, that the listing behind + // them was truncated. Empty when the pool is everything there is. It rides + // here rather than being printed by the caller so that it reaches whatever + // writer the form does; see partialPoolNote. + poolNote string +} + +func (t grantPickerTarget) describe() string { return t.noun + " " + t.ref } + +// runPickerScreen runs one of the picker's screens and reports a cancellation +// as the command's own error. +// +// The screen must not go to stdout: huh writes to stderr in its TUI mode but to +// STDOUT in accessible mode, and these commands can be asked for --json, so +// under ACCESSIBLE the prompts would land in the middle of the JSON a caller is +// parsing. Nor can it simply be pinned to stderr, which is redirected often +// enough (`grant add /et/acme/web 2>log`) that doing so renders an invisible +// prompt on an apparently hung command. runPromptForm resolves both, and hands +// back the writer it used so the cancellation lands where the user was looking. +func runPickerScreen(cmd *cobra.Command, action string, groups ...*huh.Group) error { + render, err := runPromptForm(cmd, NewAccessibleForm(groups...)) + if err != nil { + return cancelledPicker(render, action, err) + } + return nil +} + +// The two things a picker screen can be backed out of. The grantee multi-select +// serves both flows, so what a cancelled one is called has to come from the +// caller; revokeAction matches the wording revokeConfirmed prints one screen +// later, so cancelling either half of a revoke reads the same. +const ( + grantAction = "Grant" + revokeAction = "Revocation" +) + +// pickGrantees runs the multi-select over the offered candidates and returns +// the chosen rows. +// +// Rows rather than the refs huh binds to: every caller wants the whole +// candidate back — to show what each grantee holds, or to name it in the +// confirmation — so returning refs meant both callers rebuilding the same map +// afterwards, and an unchecked lookup on each. Recovering the row here makes +// that one lookup, and it is the same one that checks the answer was on offer. +func pickGrantees(cmd *cobra.Command, t grantPickerTarget, action, title string, candidates []grantCandidate) ([]grantCandidate, error) { + offered := make(map[string]grantCandidate, len(candidates)) + options := make([]huh.Option[string], len(candidates)) + for i, c := range candidates { + offered[c.ref] = c + options[i] = huh.NewOption(c.option(), c.ref) + } + var selected []string + // Filterable because the pool is a whole org's membership: at a few hundred + // people an unfiltered list is an arrow-key scroll with no way to search. + // The caveat goes in the TITLE, not in Description: huh's accessible mode + // renders a field's title and its options and nothing else, so a + // Description would be dropped for exactly the readers who cannot see the + // styled form. Its own line, so neither mode runs the two together. + if t.poolNote != "" { + title += "\n" + t.poolNote + } + if err := runPickerScreen(cmd, action, huh.NewGroup( + huh.NewMultiSelect[string](). + Title(title). + Options(options...). + Filterable(true). + Height(uiform.SingleLineMultiSelectHeight(len(options))). + Value(&selected), + )); err != nil { + return nil, err + } + // Confirming an empty selection is a decision not to grant anything, not a + // failure — and it exited 1 with no message at all, which is the worst of + // both. Nothing chosen, nothing done, exit 0. + if len(selected) == 0 { + return nil, nil + } + picked := make([]grantCandidate, 0, len(selected)) + for _, ref := range selected { + // The form returned a value that was not on offer. Nothing has been + // printed, so this must not be silent, or the command would exit + // non-zero with no message — and taking the zero-valued row instead + // would revoke against an empty ref and report it against an empty + // label. + c, ok := offered[ref] + if !ok { + return nil, fmt.Errorf("grantee %q was not among the %d offered", ref, len(candidates)) + } + picked = append(picked, c) + } + return picked, nil +} + +// pickRoles collects a role per grantee. With fixedRole set the rows are a note +// instead of selects: huh notes are non-focusable, so the roles are shown as +// already decided and cannot be edited. +func pickRoles(cmd *cobra.Command, t grantPickerTarget, chosen []grantCandidate, fixedRole string) ([]grantSelection, error) { + if fixedRole != "" { + var b strings.Builder + for _, g := range chosen { + fmt.Fprintf(&b, "%s%s %s\n", uiform.SelectOptionIndent, g.label, fixedRole) + } + if err := runPickerScreen(cmd, grantAction, + huh.NewGroup( + huh.NewNote(). + Title(fmt.Sprintf("Role for each grantee (set by --role %s)", fixedRole)). + Description(b.String()), + ), + ); err != nil { + return nil, err + } + out := make([]grantSelection, len(chosen)) + for i, g := range chosen { + out[i] = grantSelection{handle: g.ref, role: fixedRole} + } + return out, nil + } + + options := make([]huh.Option[string], len(t.roles)) + for i, r := range t.roles { + options[i] = huh.NewOption(r, r) + } + // Each row binds its own element, so the selections stay independent. + roles := make([]string, len(chosen)) + fields := make([]huh.Field, len(chosen)) + for i, g := range chosen { + roles[i] = t.least + fields[i] = huh.NewSelect[string](). + Title(g.label). + Options(options...). + Inline(true). + Value(&roles[i]) + } + if err := runPickerScreen(cmd, grantAction, huh.NewGroup(fields...).Title("Role for each grantee")); err != nil { + return nil, err + } + out := make([]grantSelection, len(chosen)) + for i, g := range chosen { + if err := validateRole(roles[i], t.roles); err != nil { + return nil, err + } + out[i] = grantSelection{handle: g.ref, role: roles[i]} + } + return out, nil +} + +// cancelledPicker converts a form error into the command's. A Ctrl+C becomes a +// SilentError so the caller stops without main.go reprinting the "cancelled." +// line handleFormCancellation already wrote; a real form failure propagates. +// +// render is the writer the form was shown on, not the command's stderr: when +// the prompt fell back to the controlling terminal, stderr is by definition not +// visible, and explaining an outcome into a stream the user is not reading is +// the same bug as prompting into one. +func cancelledPicker(render io.Writer, action string, err error) error { + if cerr := handleFormCancellation(render, action, err); cerr != nil { + return cerr + } + return NewSilentError(errors.New(strings.ToLower(action) + " cancelled")) +} + +// pickerUnavailable explains why no picker can run, always naming the explicit +// grantee form so the command stays usable. A grantee never has to be an org +// member — the pool is a convenience, not the set of legal grantees. +func pickerUnavailable(t grantPickerTarget, reason string) error { + example := fmt.Sprintf("entire %s grant add %s github:alice --role %s", t.noun, t.ref, t.least) + return fmt.Errorf("%s; pass a grantee as provider:handle, e.g. %s", reason, example) +} + +// revokeUnavailable is pickerUnavailable's remove-worded half: whatever stopped +// the picker, naming the grantee is the way through. +func revokeUnavailable(t grantPickerTarget, reason string) error { + example := fmt.Sprintf("entire %s grant remove %s github:alice", t.noun, t.ref) + return fmt.Errorf("%s; pass the grantee as provider:handle, e.g. %s", reason, example) +} + +// The remove half. Its pool is the inverse of add's — who holds the target +// now — and unlike add it exists for all three targets: org membership cannot +// be offered for adding, because everyone eligible is by definition absent from +// the only list there is, but the members to REMOVE are exactly that list. +// +// A row is offered only when revoking it would actually do something. Two +// filters, each for a condition observed on a real listing: +// +// - granteeType must be an account. The `owner` row is the owning org itself +// and holds the target through the authz schema's owner relation rather +// than a grant, so there is nothing to revoke; the typed revoke route sends +// granteeType=account and could not address it anyway. +// - source must be direct. A repo listing also carries the project's grants +// as `project:` rows, and those are held through the project, not the +// repo: revoking one at the repo level answers "no such grant; nothing to +// revoke", so offering it would be offering a no-op. Removing that access +// means removing the project grant, which `project grant remove` does. + +// grantHolders lists the account grants that can be revoked on a project or +// repo, addressed by ULID so no handle has to resolve, labelled by the friendly +// name the server resolved and by the role the grant carries. +// +// A row the server could not name falls back to its grantee ULID rather than +// being dropped: that ULID is then the only identity the grant has, and a row +// left out of the only pool there is would be a grant this command cannot +// revoke at all. +func grantHolders(rows []grantRow) []grantCandidate { + holders := make([]grantCandidate, 0, len(rows)) + for _, r := range rows { + if r.granteeType != granteeTypeAccount || r.source != grantSourceDirect || r.granteeID == "" { + continue + } + holders = append(holders, grantCandidate{ + ref: r.granteeID, + label: granteeName(coreapi.NewOptString(r.name), r.granteeID), + role: r.role, + byID: true, + }) + } + return holders +} + +// fetchGrantHolders is the whole project/repo remove pool: walk the target's +// grant listing within the fetch budget, map the rows, keep the revocable ones. +// The two targets differ only in the call and the row type. +func fetchGrantHolders[Row any]( + ctx context.Context, + fetch func(context.Context, coreapi.OptString) ([]Row, coreapi.OptString, error), + to func(Row) grantRow, +) ([]grantCandidate, listWindow, error) { + rows, partial, err := boundedList(ctx, coreListFetchBudget, fetch) + if err != nil { + return nil, listWindow{}, err + } + return grantHolders(mapRows(rows, to)), listWindow{scanned: len(rows), partial: partial}, nil +} + +// grantSourceDirect is the source of a grant written on the resource itself, +// as opposed to one inherited from its project or implied by its owner. +const grantSourceDirect = "direct" + +func projectGrantHolders(ctx context.Context, c *coreapi.Client, projectID string) ([]grantCandidate, listWindow, error) { + return fetchGrantHolders(ctx, listProjectGrants(c, projectID), projectGrantRowOf) +} + +func repoGrantHolders(ctx context.Context, c *coreapi.Client, repoID string) ([]grantCandidate, listWindow, error) { + return fetchGrantHolders(ctx, listRepoGrants(c, repoID), repoGrantRowOf) +} + +// orgMemberHolders lists the org's members for removal. They are addressed by +// handle, not ULID: org membership has no typed-id revoke route, so removal +// goes through the same provider identity a grant does — which is why this pool +// applies grantableMember, exactly as the add pool does. A member the routes +// cannot address is left out rather than offered and then refused, and here +// that matters twice over: revoking walks the chosen set and returns on the +// first error, so one unrevocable row would strand every selection after it. +// +// The role comes along for the row, so removing an owner does not look like +// removing anyone else. +func orgMemberHolders(ctx context.Context, c *coreapi.Client, orgID string) ([]grantCandidate, listWindow, error) { + members, partial, err := boundedList(ctx, coreListFetchBudget, listOrgMembers(c, orgID)) + if err != nil { + return nil, listWindow{}, err + } + holders := make([]grantCandidate, 0, len(members)) + for _, m := range members { + handle, ok := grantableMember(m) + if !ok { + continue + } + h := handleCandidate(handle) + h.role = m.Role + holders = append(holders, h) + } + return holders, listWindow{scanned: len(members), partial: partial}, nil +} + +// removePicker is the seam the remove flow's form sits behind, matching +// grantPicker's role for add. +var removePicker = func(cmd *cobra.Command, t grantPickerTarget, candidates []grantCandidate) ([]grantCandidate, error) { + return pickGrantees(cmd, t, revokeAction, "Select grants to revoke on "+t.describe(), candidates) +} diff --git a/cmd/entire/cli/grant_picker_form_test.go b/cmd/entire/cli/grant_picker_form_test.go new file mode 100644 index 0000000000..1653ecf1a4 --- /dev/null +++ b/cmd/entire/cli/grant_picker_form_test.go @@ -0,0 +1,256 @@ +package cli + +import ( + "bytes" + "errors" + "io" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +// These exercise the real huh forms, which the seam-based tests in +// grant_picker_test.go deliberately bypass. Accessible mode is what makes that +// possible without a terminal: huh then reads os.Stdin and writes os.Stdout as +// plain prompts (cmp.Or(f.input, os.Stdin) in its form runner) instead of +// opening a TTY and negotiating terminal capabilities, so the fields can be +// driven from a pipe. Nothing in the production path changes — the swap is of +// the process's own streams, which is why these tests are not parallel. +// +// Only ONE answer can be scripted per run: huh builds a fresh bufio.Scanner for +// every prompt (PromptString in its accessibility package), so the first prompt +// drains the whole pipe and later ones read EOF. A prompt at EOF falls back to +// its default, which the role test below turns into the assertion rather than +// working around. + +// runAccessibleForm runs fn with ACCESSIBLE set and the process's stdin fed +// from input, returning what the form printed to the command's stderr. +// +// Only stdin is a process-global swap; the prompts are captured from the cobra +// command, because the picker renders to cmd.ErrOrStderr() whenever that is +// where the user can see them. +func runAccessibleForm(t *testing.T, input string, fn func(cmd *cobra.Command)) string { + t.Helper() + t.Setenv("ACCESSIBLE", "1") + stubPromptTerminal(t) + + inR, inW, err := os.Pipe() + require.NoError(t, err) + oldIn := os.Stdin + os.Stdin = inR + t.Cleanup(func() { os.Stdin = oldIn }) + + // The answers are buffered ahead of the run so the form never blocks. + _, err = inW.WriteString(input) + require.NoError(t, err) + require.NoError(t, inW.Close()) + + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + var prompts bytes.Buffer + cmd.SetErr(&prompts) + fn(cmd) + return prompts.String() +} + +// TestPickRoles_EachRowKeepsItsOwnRole is the wiring this feature turns on: one +// row per grantee, each bound to its own value. A single shared binding would +// still compile and still produce a plausible-looking form, so the two rows +// have to end up different to prove they are separate. +// +// Only the first row is answered — admin, option 3 in the target's help order. +// The second reads EOF and takes its default, which is the least-privileged +// role the picker pre-selects. Rows that moved together could not land on two +// different roles, and the default being reader is itself worth pinning. +// +// Not parallel: swaps the process's stdin and stdout. +func TestPickRoles_EachRowKeepsItsOwnRole(t *testing.T) { + pt := grantPickerTarget{noun: "project", ref: "widgets", roles: accessRoles, least: leastAccessRole} + var got []grantSelection + out := runAccessibleForm(t, "3\n", func(cmd *cobra.Command) { + var err error + got, err = pickRoles(cmd, pt, []grantCandidate{handleCandidate("github:alice"), handleCandidate("github:bob")}, "") + require.NoError(t, err) + }) + + require.Equal(t, []grantSelection{ + {handle: "github:alice", role: "admin"}, + {handle: "github:bob", role: "reader"}, + }, got) + // Each row is titled with the grantee it sets, so they can be told apart. + require.Contains(t, out, "github:alice") + require.Contains(t, out, "github:bob") +} + +// TestPickRoles_FixedRoleIsShownAndNotAsked: with --role given the rows are a +// note rather than selects, so every grantee is shown carrying the role it will +// receive and nothing is asked. huh skips a note by default and un-skips one +// that is alone in its group, which is what keeps the display on screen. +// +// Not parallel: swaps the process's stdin and stdout. +func TestPickRoles_FixedRoleIsShownAndNotAsked(t *testing.T) { + pt := grantPickerTarget{noun: "project", ref: "widgets", roles: accessRoles, least: leastAccessRole} + var got []grantSelection + // No answers at all: a form that asked anything would block or fail here. + out := runAccessibleForm(t, "", func(cmd *cobra.Command) { + var err error + got, err = pickRoles(cmd, pt, []grantCandidate{handleCandidate("github:alice"), handleCandidate("github:bob")}, "writer") + require.NoError(t, err) + }) + + require.Equal(t, []grantSelection{ + {handle: "github:alice", role: "writer"}, + {handle: "github:bob", role: "writer"}, + }, got) + require.Contains(t, out, "github:alice") + require.Contains(t, out, "github:bob") + require.Contains(t, out, "writer") + // The roles were stated, not offered. + require.NotContains(t, out, "Enter a number") +} + +// TestPickRoles_PromptsStayOffStdout: these commands can be asked for --json, +// and huh writes to stdout in accessible mode, so prompts would land inside the +// JSON a caller is parsing. The picker pins form output to stderr for exactly +// that reason; this fails if a form is ever built without it. +// +// Not parallel: swaps the process's stdin. +func TestPickRoles_PromptsStayOffStdout(t *testing.T) { + t.Setenv("ACCESSIBLE", "1") + stubPromptTerminal(t) + + inR, inW, err := os.Pipe() + require.NoError(t, err) + oldIn := os.Stdin + os.Stdin = inR + t.Cleanup(func() { os.Stdin = oldIn }) + require.NoError(t, inW.Close()) + + outR, outW, err := os.Pipe() + require.NoError(t, err) + oldOut := os.Stdout + os.Stdout = outW + t.Cleanup(func() { os.Stdout = oldOut }) + + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + var prompts, stdout bytes.Buffer + cmd.SetErr(&prompts) + cmd.SetOut(&stdout) + + pt := grantPickerTarget{noun: "project", ref: "widgets", roles: accessRoles, least: leastAccessRole} + _, err = pickRoles(cmd, pt, []grantCandidate{handleCandidate("github:alice")}, "writer") + require.NoError(t, err) + + require.NoError(t, outW.Close()) + os.Stdout = oldOut + leaked, err := io.ReadAll(outR) + require.NoError(t, err) + + require.Contains(t, prompts.String(), "github:alice", "the prompt goes to stderr") + require.Empty(t, string(leaked), "nothing may reach the process's stdout") + require.Empty(t, stdout.String(), "nor the command's stdout, which carries --json") +} + +// TestRemovePicker_CancellingReadsAsARevoke drives the real remove screen +// rather than the seam. The grantee multi-select is shared with `grant add`, so +// whoever opens it has to say which action it belongs to; this fails if +// removePicker hands it the grant wording. +// +// The failure it provokes is the terminal not opening, because that is the one +// path into cancelledPicker reachable with no terminal at all — huh's +// accessible mode neither aborts on a cancelled context nor errors at EOF. +// +// Not parallel: swaps the terminal opener. +func TestRemovePicker_CancellingReadsAsARevoke(t *testing.T) { + prev := openPromptTerminal + openPromptTerminal = func() (promptTerminal, error) { + return promptTerminal{}, errors.New("no controlling terminal") + } + t.Cleanup(func() { openPromptTerminal = prev }) + + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + cmd.SetErr(&bytes.Buffer{}) // not a terminal, so the fallback is attempted + + pt := grantPickerTarget{noun: "project", ref: "widgets", roles: accessRoles, least: leastAccessRole} + _, err := removePicker(cmd, pt, []grantCandidate{{ref: "01HZX7Q", label: "github:alice", role: "admin", byID: true}}) + require.ErrorContains(t, err, "Revocation prompt failed") + require.NotContains(t, err.Error(), "Grant", "a revoke never reports itself as a grant") +} + +// stubPromptTerminal keeps a form on the command's own streams. A test's stderr +// is a buffer, never a terminal, so runPromptForm would otherwise fall back to +// the controlling terminal and read the developer's keyboard. The zero value +// leaves input and output alone, which is the branch these tests are about. +func stubPromptTerminal(t *testing.T) { + t.Helper() + prev := openPromptTerminal + openPromptTerminal = func() (promptTerminal, error) { return promptTerminal{}, nil } + t.Cleanup(func() { openPromptTerminal = prev }) +} + +// TestPromptForm_FallsBackToTheControllingTerminal is the routing the picker's +// screens depend on. Stderr is redirected often enough (`grant add … 2>log`) +// that pinning a prompt to it fails silently rather than loudly: Bubble Tea +// sets ttyOutput only for a terminal writer, then cannot query the window size +// and renders into a 0x0 viewport while stdin is in raw mode — an invisible +// prompt on an apparently hung command. +// +// So a non-terminal stderr falls back to the controlling terminal for BOTH +// halves. A test's stderr is a buffer, so this is the branch that runs here; +// the stub stands in for /dev/tty. +// +// Not parallel: sets ACCESSIBLE and swaps the terminal opener. +func TestPromptForm_FallsBackToTheControllingTerminal(t *testing.T) { + t.Setenv("ACCESSIBLE", "1") + + var terminal bytes.Buffer + prev := openPromptTerminal + openPromptTerminal = func() (promptTerminal, error) { + return promptTerminal{in: strings.NewReader("2\n"), out: &terminal}, nil + } + t.Cleanup(func() { openPromptTerminal = prev }) + + cmd := &cobra.Command{} + cmd.SetContext(t.Context()) + var stderr, stdout bytes.Buffer + cmd.SetErr(&stderr) + cmd.SetOut(&stdout) + + pt := grantPickerTarget{noun: "project", ref: "widgets", roles: accessRoles, least: leastAccessRole} + got, err := pickRoles(cmd, pt, []grantCandidate{handleCandidate("github:alice")}, "") + require.NoError(t, err) + // Answered on the terminal's own input: option 2 of reader/writer/admin. + require.Equal(t, []grantSelection{{handle: "github:alice", role: "writer"}}, got) + + require.Contains(t, terminal.String(), "github:alice", "the prompt goes where it can be seen") + require.Empty(t, stderr.String(), "not to a stderr that is not a terminal") + require.Empty(t, stdout.String(), "and never to stdout, which carries --json") +} + +// TestPickGrantees_ShowsThePoolCaveatWithTheRows drives the real multi-select +// and checks the truncation caveat is rendered as part of it. Carrying the note +// on grantPickerTarget only helps if the screen actually prints it — and the +// whole reason it travels that way is that a line sent to stderr can be +// invisible exactly when the form is not. +// +// Not parallel: swaps the process's stdin and the terminal opener. +func TestPickGrantees_ShowsThePoolCaveatWithTheRows(t *testing.T) { + const caveat = "Only the first 1200 members of the org owning project widgets were read" + pt := grantPickerTarget{ + noun: "project", ref: "widgets", roles: accessRoles, least: leastAccessRole, + poolNote: caveat, + } + out := runAccessibleForm(t, "\n", func(cmd *cobra.Command) { + _, err := pickGrantees(cmd, pt, grantAction, "Select grantees for project widgets", + []grantCandidate{handleCandidate("github:alice"), handleCandidate("github:bob")}) + require.NoError(t, err) + }) + + require.Contains(t, out, caveat, "the caveat is shown with the rows it qualifies") + require.Contains(t, out, "github:alice", "and the rows are still there") +} diff --git a/cmd/entire/cli/grant_picker_test.go b/cmd/entire/cli/grant_picker_test.go new file mode 100644 index 0000000000..7c3aa9ea93 --- /dev/null +++ b/cmd/entire/cli/grant_picker_test.go @@ -0,0 +1,1309 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "charm.land/huh/v2" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + "github.com/entireio/cli/internal/coreapi" +) + +// The picker's pool is the owning org's membership minus whoever already holds +// the target. These tests drive it through cobra against an httptest control +// plane, with the form seam swapped: `go test` is non-interactive, so the real +// forms are unreachable and the refusing paths are what run by default. + +const ( + pickerOrgULID = "01HZX7QABCDEFGHJKMNPQRSTW0" + pickerProjULID = "01HZX7QABCDEFGHJKMNPQRSTW1" + pickerRepoULID = "01HZX7QABCDEFGHJKMNPQRSTW2" +) + +// Grantee ids are ULID-shaped because the real ones are, and the remove flow +// routes a ULID ref to the typed-id revoke route. A placeholder like "acct-a" +// would quietly take the by-handle path instead and test the wrong thing. +var ( + acctAlice = holder{"01HZX7QABCDEFGHJKMNPQRSTA1", "github:alice"} + acctBob = holder{"01HZX7QABCDEFGHJKMNPQRSTB2", "github:bob"} +) + +// holder is one account that already holds a target, in the fixture. +type holder struct { + id string + handle string +} + +// pickerFixture is one control plane's answers: the org behind the target, its +// members, and who already holds the target. +type pickerFixture struct { + ownerType coreapi.ProjectOwnerType + members []coreapi.Membership + held []holder // accounts holding the target directly + // viaProject holds the grantees a repo carries through its project. Listing + // returns them alongside the direct rows, and the add pool must NOT subtract + // them: they hold no grant on the repo itself, so granting one here is a + // real action. Only the direct rows are subtracted. + viaProject []holder + // withOwnerRow adds the synthetic row for the owning org, which every real + // listing carries and neither picker may offer. + withOwnerRow bool +} + +// inactive is a member who has not joined, so no provider identity resolves for +// them and they cannot be granted anything. +func inactive(handle, accountID string) coreapi.Membership { + m := member(handle, accountID) + m.Status = "invited" + return m +} + +func member(handle, accountID string) coreapi.Membership { + return coreapi.Membership{ + AccountId: accountID, + Handle: coreapi.NewOptString(handle), + Provider: coreapi.NewOptString(providerGitHub), + Role: "member", + Status: "active", + } +} + +func (f pickerFixture) projectGrants() []coreapi.ProjectGrant { + rows := make([]coreapi.ProjectGrant, 0, len(f.held)) + for _, h := range f.held { + rows = append(rows, coreapi.ProjectGrant{GranteeId: h.id, GranteeType: granteeTypeAccount, GranteeName: coreapi.NewOptString(h.handle), Role: "writer", Source: "direct"}) + } + if f.withOwnerRow { + rows = append(rows, coreapi.ProjectGrant{GranteeId: pickerOrgULID, GranteeType: "org", GranteeName: coreapi.NewOptString("acme"), Role: "owner", Source: "owner"}) + } + return rows +} + +func (f pickerFixture) repoGrants() []coreapi.RepoGrant { + rows := make([]coreapi.RepoGrant, 0, len(f.held)+len(f.viaProject)) + for _, h := range f.held { + rows = append(rows, coreapi.RepoGrant{GranteeId: h.id, GranteeType: granteeTypeAccount, GranteeName: coreapi.NewOptString(h.handle), Role: "writer", Source: "direct"}) + } + for _, h := range f.viaProject { + rows = append(rows, coreapi.RepoGrant{GranteeId: h.id, GranteeType: granteeTypeAccount, GranteeName: coreapi.NewOptString(h.handle), Role: "writer", Source: "project:widgets"}) + } + if f.withOwnerRow { + rows = append(rows, coreapi.RepoGrant{GranteeId: pickerOrgULID, GranteeType: "org", GranteeName: coreapi.NewOptString("acme"), Role: "owner", Source: "owner"}) + } + return rows +} + +// pickerServer serves every lookup the picker makes, and records grant POSTs as +// " ". grantStatus, when set, chooses the status +// code for the nth grant so a mid-walk failure can be staged. +// +// Failures are reported with t.Errorf rather than require, which must not be +// called from a handler goroutine. +func pickerServer(t *testing.T, f pickerFixture, grants *[]string, grantStatus func(i int) int) *httptest.Server { + t.Helper() + ownerType := f.ownerType + if ownerType == "" { + ownerType = coreapi.ProjectOwnerTypeOrg + } + write := func(w http.ResponseWriter, code int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + if err := printJSON(w, payload); err != nil { + t.Errorf("encode response: %v", err) + } + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // A /et// ref resolves through repos/resolve, which is a + // POST like the grant routes are — so it is answered before them rather + // than recorded as a grant. The requested name is echoed back, because + // the resolver matches on it and this fixture answers for whatever repo + // path a test addresses. + if strings.HasSuffix(r.URL.Path, "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/repos/resolve") { + var body coreapi.ResolveReposInputBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.Repositories) == 0 { + t.Errorf("decode resolve body: %v", err) + return + } + write(w, http.StatusOK, nativeResolution(body.Repositories[0].FullName, pickerRepoULID)) + return + } + if r.Method == http.MethodPost { + var body struct { + ProviderUserID string `json:"providerUserId"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode grant body: %v", err) + return + } + i := len(*grants) + *grants = append(*grants, r.URL.Path+" "+body.ProviderUserID+" "+body.Role) + // Anything from 400 up is the staged failure; every other answer + // is the ordinary 201 the grant routes return. + if grantStatus != nil { + if code := grantStatus(i); code >= http.StatusBadRequest { + w.WriteHeader(code) + return + } + } + write(w, http.StatusCreated, map[string]string{"status": "ok"}) + return + } + if r.Method == http.MethodDelete { + *grants = append(*grants, "DELETE "+r.URL.Path) + w.WriteHeader(http.StatusNoContent) + return + } + path := r.URL.Path + switch { + case strings.Contains(path, "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/identity/handles/"): + // The provider user id is derived from the handle so a test can + // tell the grants apart by who they were for. + seg := strings.Split(strings.Trim(path, "/"), "/") + handle := seg[len(seg)-1] + write(w, http.StatusOK, &coreapi.ResolvedIdentity{ + AccountId: "acct-" + handle, Provider: providerGitHub, + Handle: handle, ProviderUserId: "uid-" + handle, + }) + case strings.HasSuffix(path, "/projects"): + // The by-name project lookup behind a /et// ref. + write(w, http.StatusOK, &coreapi.ListProjectsOutputBody{Project: coreapi.NewOptProject(coreapi.Project{ + ID: pickerProjULID, Name: "widgets", OwnerId: pickerOrgULID, OwnerType: ownerType, + })}) + case strings.HasSuffix(path, "/repos"): + write(w, http.StatusOK, &coreapi.ListProjectReposOutputBody{Repo: coreapi.NewOptRepo(coreapi.Repo{ + ID: pickerRepoULID, Name: "web", OwningProjectId: pickerProjULID, + })}) + case strings.HasSuffix(path, "/repos/"+pickerRepoULID): + write(w, http.StatusOK, &coreapi.Repo{ID: pickerRepoULID, Name: "web", OwningProjectId: pickerProjULID}) + case strings.HasSuffix(path, "/projects/"+pickerProjULID): + write(w, http.StatusOK, &coreapi.Project{ID: pickerProjULID, Name: "widgets", OwnerId: pickerOrgULID, OwnerType: ownerType}) + case strings.HasSuffix(path, "/members") && strings.Contains(path, "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/orgs/"): + write(w, http.StatusOK, &coreapi.ListOrgMembersOutputBody{Members: f.members}) + case strings.HasSuffix(path, "/members"): + write(w, http.StatusOK, &coreapi.ListProjectMembersOutputBody{Members: f.projectGrants()}) + case strings.HasSuffix(path, "/grants"): + write(w, http.StatusOK, &coreapi.ListRepoGrantsOutputBody{Grants: f.repoGrants()}) + default: + t.Errorf("unexpected GET %s", path) + } + })) +} + +// capturePicker puts the command on the interactive path and swaps the form +// seam, recording what was offered and answering with the given selections. +// +// ENTIRE_TEST_TTY=1 is what gets past the gate: `go test` is non-interactive, so +// without it every one of these commands refuses before reaching a picker. The +// forms themselves never run, because the seam replaces them. +func capturePicker(t *testing.T, answer func(offered []grantCandidate, known []string, fixedRole string) ([]grantSelection, error)) *[]grantCandidate { + t.Helper() + t.Setenv("ENTIRE_TEST_TTY", "1") + var offered []grantCandidate + prev := grantPicker + grantPicker = func(_ *cobra.Command, _ grantPickerTarget, candidates []grantCandidate, known []string, fixedRole string) ([]grantSelection, error) { + offered = candidates + return answer(candidates, known, fixedRole) + } + t.Cleanup(func() { grantPicker = prev }) + return &offered +} + +// captureRemovePicker puts the command on the interactive path and swaps the +// remove form seam, recording what was offered and answering with the rows to +// revoke. +// +// ENTIRE_TEST_TTY=1 puts the confirmation in play as well, so it stubs that too +// and answers yes; a test that cares about declining says so itself. +func captureRemovePicker(t *testing.T, answer func(offered []grantCandidate) []grantCandidate) *[]grantCandidate { + t.Helper() + t.Setenv("ENTIRE_TEST_TTY", "1") + var offered []grantCandidate + prev := removePicker + removePicker = func(_ *cobra.Command, _ grantPickerTarget, candidates []grantCandidate) ([]grantCandidate, error) { + offered = candidates + return answer(candidates), nil + } + prevConfirm := revokeConfirmed + revokeConfirmed = func(*cobra.Command, grantPickerTarget, []grantCandidate) (bool, error) { + return true, nil + } + t.Cleanup(func() { removePicker, revokeConfirmed = prev, prevConfirm }) + return &offered +} + +// labels is what the picker shows, as opposed to what it acts on. +func labels(cs []grantCandidate) []string { + out := make([]string, len(cs)) + for i, c := range cs { + out[i] = c.label + } + return out +} + +func handles(cs []grantCandidate) []string { + out := make([]string, len(cs)) + for i, c := range cs { + out[i] = c.ref + } + return out +} + +// TestGrantPicker_ExcludesDirectHoldersButOffersInheritedOnes is the pool's +// rule, and the two halves were each wrong on their own in an earlier version. +// +// A member with a DIRECT grant on the target has nothing to add here, so they +// are left out; changing their role is the typed form's job. A member who holds +// the target only through its PROJECT has no grant on this target at all, so +// granting one is a real action — it pins the role on this repo instead of +// following the project's — and leaving them out emptied the pool on every repo +// whose project already covered the org. +// +// Not parallel: swaps the activeCoreClient and grantPicker seams. +func TestGrantPicker_ExcludesDirectHoldersButOffersInheritedOnes(t *testing.T) { + members := []coreapi.Membership{ + member("github:alice", "acct-a"), + member("github:bob", "acct-b"), + member("github:carol", "acct-c"), + } + for name, tc := range map[string]struct { + newCmd func() *cobra.Command + ref string + fixture pickerFixture + want []string + }{ + "project/a direct holder is left out": { + newProjectGrantCmd, pickerProjULID, + pickerFixture{members: members, held: []holder{{"acct-b", "github:bob"}}}, + []string{"github:alice", "github:carol"}, + }, + "repo/a direct holder is left out": { + newRepoGrantCmd, wiringRepoPath, + pickerFixture{members: members, held: []holder{{"acct-a", "github:alice"}}}, + []string{"github:bob", "github:carol"}, + }, + "repo/an inherited holder is offered": { + newRepoGrantCmd, wiringRepoPath, + pickerFixture{members: members, viaProject: []holder{{"acct-c", "github:carol"}}}, + []string{"github:alice", "github:bob", "github:carol"}, + }, + "repo/a direct grant wins over the inherited row for the same account": { + newRepoGrantCmd, wiringRepoPath, + pickerFixture{ + members: members, + held: []holder{{"acct-a", "github:alice"}}, + viaProject: []holder{{"acct-a", "github:alice"}}, + }, + []string{"github:bob", "github:carol"}, + }, + } { + t.Run(name, func(t *testing.T) { + var grants []string + srv := pickerServer(t, tc.fixture, &grants, nil) + t.Cleanup(srv.Close) + offered := capturePicker(t, func(cs []grantCandidate, _ []string, _ string) ([]grantSelection, error) { + return []grantSelection{{handle: cs[0].ref, role: "reader"}}, nil + }) + + _, _, err := runPickerCmd(t, tc.newCmd, srv.URL, tc.ref) + require.NoError(t, err) + // Members holding nothing come first: adding is the common case. + require.Equal(t, tc.want, labels(*offered)) + }) + } +} + +// TestGrantPicker_AnEmptyPoolSucceeds: having nobody to add is not a failure. +// Nothing went wrong, nothing is left for the user to fix, and in the common +// case the state they wanted already holds — the same reasoning that makes +// revoking an already-revoked grant a success rather than a 404. So each of +// these reports and exits 0. +// +// The message still distinguishes them, since "everyone already has a grant", +// "this org has no members" and "none of its members can be addressed" are +// different answers to "who can I add?". +// +// Not parallel: swaps the activeCoreClient and grantPicker seams. +func TestGrantPicker_AnEmptyPoolSucceeds(t *testing.T) { + for name, tc := range map[string]struct { + fixture pickerFixture + want string + }{ + "everyone already holds a direct grant": { + pickerFixture{ + members: []coreapi.Membership{member("github:alice", "acct-a")}, + held: []holder{{"acct-a", "github:alice"}}, + }, + "every member of the org owning project " + pickerProjULID + " already has a grant on it", + }, + "the org has no members": { + pickerFixture{}, + "project " + pickerProjULID + " has no org members to choose from", + }, + "no member can be addressed": { + pickerFixture{members: []coreapi.Membership{inactive("github:alice", "acct-a")}}, + "no member of the org owning project " + pickerProjULID + " can be granted access here", + }, + } { + t.Run(name, func(t *testing.T) { + var grants []string + srv := pickerServer(t, tc.fixture, &grants, nil) + t.Cleanup(srv.Close) + capturePicker(t, func([]grantCandidate, []string, string) ([]grantSelection, error) { + t.Error("the picker must not open with nobody to add") + return nil, nil + }) + + out, _, err := runPickerCmd(t, newProjectGrantCmd, srv.URL, pickerProjULID) + require.NoError(t, err, "an empty pool is not an error") + require.Contains(t, out, tc.want) + require.Empty(t, grants) + }) + } +} + +// TestGrantPicker_EmptyPoolWithJSONStaysParseable: the reason is human output, +// so under --json it moves to stderr and stdout gets the empty array that "no +// grants were made" means there. Printing the sentence on stdout would hand a +// caller something it cannot parse. +// +// Not parallel: swaps the activeCoreClient and grantPicker seams. +func TestGrantPicker_EmptyPoolWithJSONStaysParseable(t *testing.T) { + var grants []string + srv := pickerServer(t, pickerFixture{ + members: []coreapi.Membership{member("github:alice", "acct-a")}, + held: []holder{{"acct-a", "github:alice"}}, + }, &grants, nil) + t.Cleanup(srv.Close) + capturePicker(t, func([]grantCandidate, []string, string) ([]grantSelection, error) { + t.Error("the picker must not open with nobody to add") + return nil, nil + }) + + out, errOut, err := runPickerCmd(t, newProjectGrantCmd, srv.URL, pickerProjULID, "--json") + require.NoError(t, err) + var arr []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &arr)) + require.Empty(t, arr) + require.Contains(t, errOut, "already has a grant on it") +} + +// TestGrantPicker_SelectingNobodyIsACleanStop: confirming an empty selection is +// a decision not to grant anything. It used to exit 1 with no message at all, +// which is the worst of both. +// +// Not parallel: swaps the activeCoreClient and grantPicker seams. +func TestGrantPicker_SelectingNobodyIsACleanStop(t *testing.T) { + var grants []string + srv := pickerServer(t, pickerFixture{ + members: []coreapi.Membership{member("github:alice", "acct-a")}, + }, &grants, nil) + t.Cleanup(srv.Close) + capturePicker(t, func([]grantCandidate, []string, string) ([]grantSelection, error) { + return nil, nil + }) + + out, _, err := runPickerCmd(t, newProjectGrantCmd, srv.URL, pickerProjULID) + require.NoError(t, err) + require.Empty(t, grants) + require.NotContains(t, out, "✓") +} + +// TestGrantPicker_UngrantableMembersAreDropped: a member with no handle or one +// who has not joined cannot be resolved to the (provider, providerUserId) pair +// the grant routes need, so offering them would produce a selection that fails +// at the grant. +// +// Not parallel: swaps the activeCoreClient and grantPicker seams. +func TestGrantPicker_UngrantableMembersAreDropped(t *testing.T) { + noHandle := member("", "acct-x") + noHandle.Handle = coreapi.OptString{} + invited := member("github:pending", "acct-y") + invited.Status = "invited" + + var grants []string + srv := pickerServer(t, pickerFixture{members: []coreapi.Membership{ + member("github:alice", "acct-a"), noHandle, invited, + }}, &grants, nil) + t.Cleanup(srv.Close) + offered := capturePicker(t, func(cs []grantCandidate, _ []string, _ string) ([]grantSelection, error) { + return []grantSelection{{handle: cs[0].ref, role: "reader"}}, nil + }) + + _, _, err := runPickerCmd(t, newProjectGrantCmd, srv.URL, pickerProjULID) + require.NoError(t, err) + require.Equal(t, []string{"github:alice"}, handles(*offered)) +} + +// TestGrantPicker_PerGranteeRoles pins that each selection carries its own role +// rather than one role being applied to the set, and that the grants are issued +// in the order chosen. +// +// Not parallel: swaps the activeCoreClient and grantPicker seams. +func TestGrantPicker_PerGranteeRoles(t *testing.T) { + var grants []string + srv := pickerServer(t, pickerFixture{members: []coreapi.Membership{ + member("github:alice", "acct-a"), member("github:bob", "acct-b"), + }}, &grants, nil) + t.Cleanup(srv.Close) + capturePicker(t, func(_ []grantCandidate, _ []string, _ string) ([]grantSelection, error) { + return []grantSelection{ + {handle: "github:alice", role: "reader"}, + {handle: "github:bob", role: "admin"}, + }, nil + }) + + out, _, err := runPickerCmd(t, newProjectGrantCmd, srv.URL, pickerProjULID) + require.NoError(t, err) + require.Equal(t, []string{ + "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/api/v1/projects/" + pickerProjULID + "/grants uid-alice reader", + "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/api/v1/projects/" + pickerProjULID + "/grants uid-bob admin", + }, grants) + require.Contains(t, out, "✓ Granted github:alice reader access to project "+pickerProjULID) + require.Contains(t, out, "✓ Granted github:bob admin access to project "+pickerProjULID) +} + +// TestGrantPicker_FixedRoleIsNotPrompted: --role decides the roles, so the +// picker is handed it rather than asked for one, and every grant carries it. +// +// Not parallel: swaps the activeCoreClient and grantPicker seams. +func TestGrantPicker_FixedRoleIsNotPrompted(t *testing.T) { + var grants []string + srv := pickerServer(t, pickerFixture{members: []coreapi.Membership{ + member("github:alice", "acct-a"), member("github:bob", "acct-b"), + }}, &grants, nil) + t.Cleanup(srv.Close) + var gotFixed string + capturePicker(t, func(cs []grantCandidate, _ []string, fixedRole string) ([]grantSelection, error) { + gotFixed = fixedRole + out := make([]grantSelection, len(cs)) + for i, c := range cs { + out[i] = grantSelection{handle: c.ref, role: fixedRole} + } + return out, nil + }) + + _, _, err := runPickerCmd(t, newProjectGrantCmd, srv.URL, pickerProjULID, "--role", "admin") + require.NoError(t, err) + require.Equal(t, "admin", gotFixed) + require.Equal(t, []string{ + "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/api/v1/projects/" + pickerProjULID + "/grants uid-alice admin", + "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/api/v1/projects/" + pickerProjULID + "/grants uid-bob admin", + }, grants) +} + +// TestGrantPicker_PartialFailureStopsAndReports: a failure part-way through +// leaves the earlier grants in place and says so, rather than rolling back a +// server-side change the CLI cannot undo or swallowing the error. +// +// Not parallel: swaps the activeCoreClient and grantPicker seams. +func TestGrantPicker_PartialFailureStopsAndReports(t *testing.T) { + var grants []string + srv := pickerServer(t, pickerFixture{members: []coreapi.Membership{ + member("github:alice", "acct-a"), member("github:bob", "acct-b"), member("github:carol", "acct-c"), + }}, &grants, func(i int) int { + if i == 1 { + return http.StatusInternalServerError + } + return http.StatusOK + }) + t.Cleanup(srv.Close) + capturePicker(t, func(cs []grantCandidate, _ []string, _ string) ([]grantSelection, error) { + out := make([]grantSelection, len(cs)) + for i, c := range cs { + out[i] = grantSelection{handle: c.ref, role: "reader"} + } + return out, nil + }) + + out, _, err := runPickerCmd(t, newProjectGrantCmd, srv.URL, pickerProjULID) + require.Error(t, err) + require.Contains(t, out, "github:alice") + require.NotContains(t, out, "github:carol", "the walk must stop at the failure, not carry on") + require.Len(t, grants, 2, "the third grant is never attempted") +} + +// TestGrantPicker_PartialFailureIsReportedInJSONToo: --json replaces the +// confirmation lines, so without this the grants that landed before a failure +// would be invisible to exactly the callers parsing the output. The shape stays +// the array a picked set always produces, carrying only what succeeded. +// +// Not parallel: swaps the activeCoreClient and grantPicker seams. +func TestGrantPicker_PartialFailureIsReportedInJSONToo(t *testing.T) { + var grants []string + srv := pickerServer(t, pickerFixture{members: []coreapi.Membership{ + member("github:alice", "acct-a"), member("github:bob", "acct-b"), + }}, &grants, func(i int) int { + if i == 1 { + return http.StatusInternalServerError + } + return http.StatusCreated + }) + t.Cleanup(srv.Close) + capturePicker(t, func(cs []grantCandidate, _ []string, _ string) ([]grantSelection, error) { + out := make([]grantSelection, len(cs)) + for i, c := range cs { + out[i] = grantSelection{handle: c.ref, role: "reader"} + } + return out, nil + }) + + out, _, err := runPickerCmd(t, newProjectGrantCmd, srv.URL, pickerProjULID, "--json") + require.Error(t, err) + var arr []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &arr)) + require.Len(t, arr, 1, "the one grant that landed is reported, the failed one is not") +} + +// TestGrantPicker_NoPoolExistsIsAnError covers the case an empty pool is not: +// a target owned by an account has no membership list anywhere, so the picker +// cannot run at all and the user has to name a grantee. That is a different +// thing from an org whose members are all granted already, which succeeds — see +// TestGrantPicker_AnEmptyPoolSucceeds. +// +// Not parallel: swaps the activeCoreClient and grantPicker seams. +func TestGrantPicker_NoPoolExistsIsAnError(t *testing.T) { + for name, tc := range map[string]struct { + newCmd func() *cobra.Command + ref string + fixture pickerFixture + want string + }{ + "project owned by an account": { + newProjectGrantCmd, pickerProjULID, + pickerFixture{ownerType: coreapi.ProjectOwnerTypeAccount}, + "project " + pickerProjULID + " is owned by an account, so it has no member list to choose from", + }, + "repo whose project is owned by an account": { + newRepoGrantCmd, wiringRepoPath, + pickerFixture{ownerType: coreapi.ProjectOwnerTypeAccount}, + "repo " + wiringRepoPath + " is in project widgets, which is owned by an account", + }, + } { + t.Run(name, func(t *testing.T) { + var grants []string + srv := pickerServer(t, tc.fixture, &grants, nil) + t.Cleanup(srv.Close) + capturePicker(t, func([]grantCandidate, []string, string) ([]grantSelection, error) { + t.Error("picker must not open when there is nobody to offer") + return nil, nil + }) + + _, _, err := runPickerCmd(t, tc.newCmd, srv.URL, tc.ref) + require.ErrorContains(t, err, tc.want) + // Every refusal still names the form that always works, because a + // grantee never has to be an org member. + require.ErrorContains(t, err, "pass a grantee as provider:handle") + require.Empty(t, grants) + }) + } +} + +// TestGrantAdd_NoGranteeIsRefusedBeforeAnyRequest: without a terminal the +// candidate list has no use, so the refusal is decided from the command line +// alone and costs no lookup. +// +// Not parallel: runCoreCmd swaps the package-level activeCoreClient seam. +func TestGrantAdd_NoGranteeIsRefusedBeforeAnyRequest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + })) + t.Cleanup(srv.Close) + + for name, tc := range map[string]struct { + newCmd func() *cobra.Command + ref string + }{ + "project": {newProjectGrantCmd, wiringProjULID}, + "repo": {newRepoGrantCmd, wiringRepoPath}, + } { + t.Run(name, func(t *testing.T) { + _, _, err := runCoreCmd(t, tc.newCmd, srv.URL, "add", tc.ref) + require.ErrorContains(t, err, "no grantee given; pass a grantee as provider:handle") + }) + } +} + +// TestOrgGrantAdd_HasNoPicker: org membership has no enumerable pool of +// candidates — everyone not already a member is, by definition, absent from the +// only list there is — so `org grant add` still requires both arguments. +// +// Not parallel: runCoreCmd swaps the package-level activeCoreClient seam. +func TestOrgGrantAdd_HasNoPicker(t *testing.T) { + require.Nil(t, orgGrantTarget.candidates) + + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + })) + t.Cleanup(srv.Close) + + _, _, err := runCoreCmd(t, newOrgGrantCmd, srv.URL, "add", wiringOrgULID) + require.ErrorContains(t, err, "accepts 2 arg(s), received 1") +} + +// runPickerCmd runs `add [flags]` — the grantee-omitted form — against srv. +func runPickerCmd(t *testing.T, newCmd func() *cobra.Command, srvURL, ref string, extra ...string) (stdout, stderr string, err error) { + t.Helper() + return runCoreCmd(t, newCmd, srvURL, append([]string{"add", ref}, extra...)...) +} + +// TestGrantPicker_SoleCandidateIsStillOffered: a picker elsewhere in the CLI +// auto-picks when only one choice exists (selectPlacement returns the lone +// cluster without prompting), and that is right for choosing where to read +// from. This one writes access, so the single eligible person is still shown +// and still has to be chosen. +// +// Not parallel: swaps the activeCoreClient and grantPicker seams. +func TestGrantPicker_SoleCandidateIsStillOffered(t *testing.T) { + var grants []string + srv := pickerServer(t, pickerFixture{members: []coreapi.Membership{member("github:alice", "acct-a")}}, &grants, nil) + t.Cleanup(srv.Close) + opened := false + capturePicker(t, func(cs []grantCandidate, _ []string, _ string) ([]grantSelection, error) { + opened = true + require.Len(t, cs, 1) + return []grantSelection{{handle: cs[0].ref, role: "reader"}}, nil + }) + + _, _, err := runPickerCmd(t, newProjectGrantCmd, srv.URL, pickerProjULID) + require.NoError(t, err) + require.True(t, opened, "the lone candidate must be chosen, not assumed") +} + +// TestGrantAdd_JSONShapeFollowsTheInvocation: a grantee named on the command +// line is one mutation and emits the bare wire object, the shape every other +// mutation's --json emits and the one `grant add` emitted before the picker +// existed — so the scripted form neither breaks nor leaves this the only +// command in the CLI answering a mutation with an array. The picker grants a +// set and emits an array, including when it is empty, so a caller reading it +// never has to branch. +// +// Deciding on the outcome instead would make a picker run that granted one +// person indistinguishable from a typed one. +// +// Not parallel: swaps the activeCoreClient and grantPicker seams. +func TestGrantAdd_JSONShapeFollowsTheInvocation(t *testing.T) { + t.Run("a grantee named on the command line is an object", func(t *testing.T) { + var grants []string + srv := pickerServer(t, pickerFixture{}, &grants, nil) + t.Cleanup(srv.Close) + + out, _, err := runCoreCmd(t, newProjectGrantCmd, srv.URL, + "add", pickerProjULID, "github:alice", "--role", "reader", "--json") + require.NoError(t, err) + var obj map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &obj)) + require.NotEmpty(t, grants) + }) + + t.Run("a picked set is an array", func(t *testing.T) { + var grants []string + srv := pickerServer(t, pickerFixture{members: []coreapi.Membership{ + member("github:alice", "acct-a"), member("github:bob", "acct-b"), + }}, &grants, nil) + t.Cleanup(srv.Close) + capturePicker(t, func(cs []grantCandidate, _ []string, _ string) ([]grantSelection, error) { + out := make([]grantSelection, len(cs)) + for i, c := range cs { + out[i] = grantSelection{handle: c.ref, role: "reader"} + } + return out, nil + }) + + out, _, err := runPickerCmd(t, newProjectGrantCmd, srv.URL, pickerProjULID, "--json") + require.NoError(t, err) + var arr []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &arr)) + require.Len(t, arr, 2) + require.NotContains(t, out, "✓", "--json replaces the confirmation lines") + }) + + t.Run("a picker that granted one is still an array", func(t *testing.T) { + var grants []string + srv := pickerServer(t, pickerFixture{members: []coreapi.Membership{ + member("github:alice", "acct-a"), + }}, &grants, nil) + t.Cleanup(srv.Close) + capturePicker(t, func(cs []grantCandidate, _ []string, _ string) ([]grantSelection, error) { + return []grantSelection{{handle: cs[0].ref, role: "reader"}}, nil + }) + + out, _, err := runPickerCmd(t, newProjectGrantCmd, srv.URL, pickerProjULID, "--json") + require.NoError(t, err) + var arr []map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &arr)) + require.Len(t, arr, 1, "the shape is the invocation's, not the outcome's") + }) +} + +// TestRemovePicker_OrgHasAPoolToo: the add side cannot offer anything for an +// org, because everyone eligible is absent from the only list there is. Remove +// is the opposite — the members to remove ARE that list — so org gets a picker +// where add does not. +// +// Not parallel: swaps the activeCoreClient and removePicker seams. +func TestRemovePicker_OrgHasAPoolToo(t *testing.T) { + require.NotNil(t, orgGrantTarget.holders, "remove has a pool where add has none") + + var grants []string + srv := pickerServer(t, pickerFixture{members: []coreapi.Membership{ + member("github:alice", "acct-a"), member("github:bob", "acct-b"), + }}, &grants, nil) + t.Cleanup(srv.Close) + offered := captureRemovePicker(t, func(cs []grantCandidate) []grantCandidate { return []grantCandidate{cs[1]} }) + + out, _, err := runCoreCmd(t, newOrgGrantCmd, srv.URL, "remove", pickerOrgULID) + require.NoError(t, err) + require.Equal(t, []string{"github:alice", "github:bob"}, labels(*offered)) + // Org members are addressed by handle: there is no typed-id revoke route. + require.Equal(t, "github:bob", (*offered)[1].ref) + require.Contains(t, out, "✓ Revoked github:bob from org "+pickerOrgULID) +} + +// TestRemovePicker_ReportsTheNameItShowed: a project or repo row is revoked by +// account ULID, which needs no handle lookup and survives a rename, but the +// user chose a name off a list and the confirmation has to say that name back. +// +// Not parallel: swaps the activeCoreClient and removePicker seams. +func TestRemovePicker_ReportsTheNameItShowed(t *testing.T) { + var grants []string + srv := pickerServer(t, pickerFixture{held: []holder{acctAlice}}, &grants, nil) + t.Cleanup(srv.Close) + captureRemovePicker(t, func(cs []grantCandidate) []grantCandidate { return []grantCandidate{cs[0]} }) + + out, _, err := runCoreCmd(t, newProjectGrantCmd, srv.URL, "remove", pickerProjULID) + require.NoError(t, err) + require.Contains(t, out, "✓ Revoked github:alice from project "+pickerProjULID) + require.NotContains(t, out, "acct-a", "the id it acted on is not what the user picked") +} + +// TestRemovePicker_EmptyPoolIsAnError: the user asked to revoke something and +// nothing was revoked, so this is not a quiet success. +// +// Not parallel: swaps the activeCoreClient and removePicker seams. +func TestRemovePicker_EmptyPoolIsAnError(t *testing.T) { + var grants []string + srv := pickerServer(t, pickerFixture{withOwnerRow: true}, &grants, nil) + t.Cleanup(srv.Close) + captureRemovePicker(t, func([]grantCandidate) []grantCandidate { + t.Error("the picker must not open with nothing to offer") + return nil + }) + + _, _, err := runCoreCmd(t, newProjectGrantCmd, srv.URL, "remove", pickerProjULID) + require.ErrorContains(t, err, "project "+pickerProjULID+" has no grants that can be revoked here") +} + +// TestGrantRemove_NoGranteeIsRefusedBeforeAnyRequest: without a terminal the +// list of holders has no use, so the refusal costs no lookup and names the one +// form a grantee takes. +// +// Not parallel: runCoreCmd swaps the package-level activeCoreClient seam. +func TestGrantRemove_NoGranteeIsRefusedBeforeAnyRequest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + })) + t.Cleanup(srv.Close) + + for name, tc := range map[string]struct { + newCmd func() *cobra.Command + ref string + want string + }{ + "org": {newOrgGrantCmd, wiringOrgULID, "entire org grant remove " + wiringOrgULID + " github:alice"}, + "project": {newProjectGrantCmd, wiringProjULID, "entire project grant remove " + wiringProjULID + " github:alice"}, + "repo": {newRepoGrantCmd, wiringRepoPath, "entire repo grant remove " + wiringRepoPath + " github:alice"}, + } { + t.Run(name, func(t *testing.T) { + _, _, err := runCoreCmd(t, tc.newCmd, srv.URL, "remove", tc.ref) + require.ErrorContains(t, err, "no grantee given; ") + require.ErrorContains(t, err, tc.want) + }) + } +} + +// TestGrantRemove_NeedsNoConfirmationBypass pins the shape of the confirmation: +// it belongs to the picker, so a typed grantee revokes unprompted and there is +// no flag to bypass anything. `delete` refuses instead, because a deleted +// resource is gone, while a revoked grant is one command from being restored. +// +// Not parallel: runCoreCmd swaps the package-level activeCoreClient seam. +func TestGrantRemove_NeedsNoConfirmationBypass(t *testing.T) { + var revoked string + srv := httptest.NewServer(grantWiringHandler(t, + func(_, path string) { revoked = path }, + func(w http.ResponseWriter) { w.WriteHeader(http.StatusNoContent) }, + )) + t.Cleanup(srv.Close) + + out, _, err := runCoreCmd(t, newProjectGrantCmd, srv.URL, "remove", wiringProjULID, "github:alice") + require.NoError(t, err) + require.Contains(t, revoked, "/grants/account/github/12345") + require.Contains(t, out, "✓ Revoked github:alice") + + // No bypass exists, on any of the three, because nothing needs bypassing. + for name, newCmd := range map[string]func() *cobra.Command{ + "org": newOrgGrantCmd, "project": newProjectGrantCmd, "repo": newRepoGrantCmd, + } { + t.Run(name+" has no --force", func(t *testing.T) { + require.Nil(t, newCmd().Commands()[2].Flags().Lookup("force")) + }) + } +} + +// TestRevokeConfirmation_NamesEveryGrantee: a prompt that summarised several +// revokes as a count alone would hide who is in the set, so the count is the +// title and the names are listed under it. One grantee needs no list and reads +// as a sentence. +func TestRevokeConfirmation_NamesEveryGrantee(t *testing.T) { + t.Parallel() + pt := grantPickerTarget{noun: "project", ref: "widgets", roles: accessRoles, least: leastAccessRole} + + label, detail := revokeConfirmation(pt, []grantCandidate{{ref: "x", label: "github:alice"}}) + require.Equal(t, "github:alice from project widgets", label) + require.Empty(t, detail, "a single grantee needs no list under it") + + label, detail = revokeConfirmation(pt, []grantCandidate{ + {ref: "x", label: "github:alice"}, + {ref: "y", label: "github:bob"}, + }) + require.Equal(t, "2 grants on project widgets", label) + require.Contains(t, detail, "github:alice") + require.Contains(t, detail, "github:bob") + + // The single-grantee title is read as a sentence — "Revoke