diff --git a/.github/workflows/zero-action-smoke.yml b/.github/workflows/zero-action-smoke.yml new file mode 100644 index 000000000..4fc42d80a --- /dev/null +++ b/.github/workflows/zero-action-smoke.yml @@ -0,0 +1,89 @@ +name: ZERO Action Smoke + +on: + pull_request: + paths: + - action.yml + - .github/workflows/zero-action-smoke.yml + push: + branches: + - main + paths: + - action.yml + - .github/workflows/zero-action-smoke.yml + +jobs: + validate: + name: Validate action.yml + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Parse action.yml and assert documented inputs + shell: bash + run: | + python3 - <<'PY' + import sys, yaml + + with open("action.yml", "r", encoding="utf-8") as handle: + doc = yaml.safe_load(handle) + + assert isinstance(doc, dict), "action.yml must be a mapping" + assert doc.get("name"), "action must declare a name" + assert doc.get("description"), "action must declare a description" + + runs = doc.get("runs") or {} + assert runs.get("using") == "composite", "action must be a composite action" + steps = runs.get("steps") or [] + assert len(steps) >= 2, "composite action must define steps" + + inputs = doc.get("inputs") or {} + expected_inputs = [ + "prompt", "prompt-file", "provider", "api-key", "api-key-env", + "model", "mode", "auto", "self-correct", "add-dir", "worktree", + "output-format", "post-to", "slack-webhook-url", "github-token", + "working-directory", "zero-version", "zero-repo", + ] + missing = [name for name in expected_inputs if name not in inputs] + assert not missing, f"action.yml is missing inputs: {missing}" + + # Conservative CI default: autonomy must default to 'low'. + assert inputs["auto"].get("default") == "low", "auto must default to low" + # stream-json is the documented default capture format. + assert inputs["output-format"].get("default") == "stream-json", \ + "output-format must default to stream-json" + + outputs = doc.get("outputs") or {} + for name in ("exit-code", "output-file", "summary"): + assert name in outputs, f"action.yml is missing output: {name}" + + # The action must never enable unsafe mode implicitly. + serialized = yaml.safe_dump(doc) + assert "--skip-permissions-unsafe" not in serialized, \ + "action must never pass --skip-permissions-unsafe" + + print(f"action.yml OK: {len(inputs)} inputs, {len(outputs)} outputs, {len(steps)} steps") + PY + + - name: Shell-syntax-check embedded run blocks + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import yaml + with open("action.yml", "r", encoding="utf-8") as handle: + doc = yaml.safe_load(handle) + steps = (doc.get("runs") or {}).get("steps") or [] + for index, step in enumerate(steps): + run = step.get("run") + if run: + with open(f"/tmp/zero_step_{index}.sh", "w", encoding="utf-8") as out: + out.write(run) + PY + for script in /tmp/zero_step_*.sh; do + echo "bash -n ${script}" + bash -n "${script}" + done diff --git a/action.yml b/action.yml new file mode 100644 index 000000000..36b7df44e --- /dev/null +++ b/action.yml @@ -0,0 +1,297 @@ +name: ZERO +description: >- + Run the ZERO coding agent headlessly in a GitHub workflow. ZERO is + model- and provider-agnostic: you supply the provider and API key as inputs. +author: Gitlawb + +branding: + icon: terminal + color: green + +inputs: + prompt: + description: The instruction for ZERO to execute. Mutually exclusive with prompt-file. + required: false + default: "" + prompt-file: + description: Path (relative to working-directory) to a file whose contents are the prompt. + required: false + default: "" + provider: + description: >- + Provider id to activate (for example openai, anthropic, gemini, ollama, or + any configured OpenAI-/Anthropic-compatible endpoint). Optional when the + repository already carries a .zero/config.json with an active provider. + required: false + default: "" + api-key: + description: >- + The provider API key. Pass it from a repository or organization secret; + it is exported into the step environment and never written to the log. + required: false + default: "" + api-key-env: + description: >- + Name of the environment variable the chosen provider reads its key from + (for example OPENAI_API_KEY or ANTHROPIC_API_KEY). When set together with + api-key, that variable is exported for the run. Leave the action + provider-agnostic by passing the env name your provider expects rather + than hardcoding one here. + required: false + default: "" + model: + description: Model id to use for the run. Defaults to the resolved provider's default. + required: false + default: "" + mode: + description: Run mode passed to `zero exec --mode` (for example smart, deep, or fast). + required: false + default: "" + auto: + description: >- + Autonomy ceiling passed to `zero exec --auto` (low, medium, or high). + Defaults to the conservative `low` so an unattended CI run cannot take + high-impact actions implicitly. + required: false + default: low + self-correct: + description: >- + When true, allow ZERO to request a stronger model mid-run if it hits a + wall (maps to `zero exec --allow-escalation`). + required: false + default: "false" + add-dir: + description: >- + Newline- or comma-separated extra write roots granted to the run (each + passed as `zero exec --add-dir`). The checked-out repository is always + writable; everything else stays read-only unless listed here. + required: false + default: "" + worktree: + description: When true, run inside an isolated git worktree (`zero exec --worktree`). + required: false + default: "false" + output-format: + description: >- + Output format for `zero exec` (text, json, or stream-json). Defaults to + stream-json, which is captured to a file and exposed as the output-file + output and uploaded by the caller if desired. + required: false + default: stream-json + post-to: + description: >- + Optional destination for a run summary after ZERO finishes: `pr-comment` + posts to the triggering pull request using github-token, `slack` posts to + slack-webhook-url, or `none` (default) posts nowhere. + required: false + default: none + slack-webhook-url: + description: >- + Slack incoming-webhook (or generic webhook) URL used when post-to is + slack. Pass it from a secret; it is never written to the log. + required: false + default: "" + github-token: + description: >- + Token used to post a PR comment when post-to is pr-comment. Defaults to + the workflow's GITHUB_TOKEN; the workflow must grant pull-requests: write. + required: false + default: ${{ github.token }} + working-directory: + description: Directory to run ZERO in. Defaults to the workspace root. + required: false + default: ${{ github.workspace }} + zero-version: + description: >- + ZERO release version/tag to install (for example v1.2.3 or latest). + Defaults to the ref this action was resolved at, falling back to latest. + required: false + default: "" + zero-repo: + description: Repository to install the ZERO release from (owner/repo). + required: false + default: Gitlawb/zero + +outputs: + exit-code: + description: The exit code ZERO returned (0 success, 2 usage, 3 provider, non-zero otherwise). + value: ${{ steps.run.outputs.exit-code }} + output-file: + description: Path to the captured ZERO stdout (the raw output-format stream). + value: ${{ steps.run.outputs.output-file }} + summary: + description: A short human-readable summary line parsed from the run, when available. + value: ${{ steps.run.outputs.summary }} + +runs: + using: composite + steps: + - name: Install ZERO + shell: bash + env: + ZERO_REPO: ${{ inputs.zero-repo }} + ZERO_VERSION_INPUT: ${{ inputs.zero-version }} + ACTION_REF: ${{ github.action_ref }} + ACTION_PATH: ${{ github.action_path }} + RUNNER_TEMP: ${{ runner.temp }} + run: | + set -euo pipefail + if [ "${RUNNER_OS}" = "Windows" ]; then + echo "::error::The ZERO action currently supports Linux and macOS runners only." >&2 + exit 1 + fi + # Resolve the version to install: explicit input wins; otherwise the ref + # this action was checked out at (a tag); otherwise the latest release. + version="${ZERO_VERSION_INPUT}" + if [ -z "${version}" ]; then + version="${ACTION_REF}" + fi + # A commit-SHA pin (github.action_ref is the 40-hex SHA) is not a release + # tag, so fall back to latest rather than fetching releases/download/v. + case "${version}" in + ""|refs/*|*/*) version="latest" ;; + esac + if printf '%s' "${version}" | grep -qiE '^[0-9a-f]{40}$'; then + version="latest" + fi + install_dir="${RUNNER_TEMP}/zero-bin" + mkdir -p "${install_dir}" + # Reuse ZERO's own checksum-verifying installer, shipped with the action. + ZERO_REPO="${ZERO_REPO}" ZERO_VERSION="${version}" ZERO_INSTALL_DIR="${install_dir}" \ + bash "${ACTION_PATH}/scripts/install.sh" + echo "${install_dir}" >> "${GITHUB_PATH}" + + - name: Run ZERO + id: run + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + # Secrets are passed via the environment and never echoed. `set +x` and + # the absence of any `echo` of these values keep them out of the log. + ZERO_INPUT_API_KEY: ${{ inputs.api-key }} + ZERO_INPUT_API_KEY_ENV: ${{ inputs.api-key-env }} + ZERO_INPUT_PROVIDER: ${{ inputs.provider }} + ZERO_INPUT_PROMPT: ${{ inputs.prompt }} + ZERO_INPUT_PROMPT_FILE: ${{ inputs.prompt-file }} + ZERO_INPUT_MODEL: ${{ inputs.model }} + ZERO_INPUT_MODE: ${{ inputs.mode }} + ZERO_INPUT_AUTO: ${{ inputs.auto }} + ZERO_INPUT_SELF_CORRECT: ${{ inputs.self-correct }} + ZERO_INPUT_ADD_DIR: ${{ inputs.add-dir }} + ZERO_INPUT_WORKTREE: ${{ inputs.worktree }} + ZERO_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }} + RUNNER_TEMP: ${{ runner.temp }} + run: | + set -euo pipefail + + # Export the provider key under the env name the provider expects. This + # keeps the action provider-agnostic: no provider->env mapping is baked + # in. The value is never printed. + if [ -n "${ZERO_INPUT_API_KEY}" ] && [ -n "${ZERO_INPUT_API_KEY_ENV}" ]; then + if ! printf '%s' "${ZERO_INPUT_API_KEY_ENV}" | grep -qE '^[A-Za-z_][A-Za-z0-9_]*$'; then + echo "::error::api-key-env must be a valid environment variable name." >&2 + exit 2 + fi + # Export in-process so the zero invocation in this same run block sees + # it, and append to GITHUB_ENV so any later steps inherit it too. + export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}" + printf '%s=%s\n' "${ZERO_INPUT_API_KEY_ENV}" "${ZERO_INPUT_API_KEY}" >> "${GITHUB_ENV}" + fi + if [ -n "${ZERO_INPUT_PROVIDER}" ]; then + export ZERO_PROVIDER="${ZERO_INPUT_PROVIDER}" + fi + + args=(exec) + + if [ -n "${ZERO_INPUT_PROMPT_FILE}" ] && [ -n "${ZERO_INPUT_PROMPT}" ]; then + echo "::error::The 'prompt' and 'prompt-file' inputs are mutually exclusive." >&2 + exit 2 + fi + if [ -n "${ZERO_INPUT_PROMPT_FILE}" ]; then + args+=(--file "${ZERO_INPUT_PROMPT_FILE}") + elif [ -n "${ZERO_INPUT_PROMPT}" ]; then + args+=(--prompt "${ZERO_INPUT_PROMPT}") + else + echo "::error::Provide either the 'prompt' or 'prompt-file' input." >&2 + exit 2 + fi + + [ -n "${ZERO_INPUT_MODEL}" ] && args+=(--model "${ZERO_INPUT_MODEL}") + [ -n "${ZERO_INPUT_MODE}" ] && args+=(--mode "${ZERO_INPUT_MODE}") + [ -n "${ZERO_INPUT_AUTO}" ] && args+=(--auto "${ZERO_INPUT_AUTO}") + [ "${ZERO_INPUT_SELF_CORRECT}" = "true" ] && args+=(--allow-escalation) + [ "${ZERO_INPUT_WORKTREE}" = "true" ] && args+=(--worktree) + args+=(--output-format "${ZERO_INPUT_OUTPUT_FORMAT}") + # No --notify in CI (no terminal); the optional Slack post is handled + # separately below so it works regardless of output format. + args+=(--no-notify) + + # Split add-dir on newlines and commas; each non-empty entry is a root. + if [ -n "${ZERO_INPUT_ADD_DIR}" ]; then + while IFS= read -r dir; do + # Trim only surrounding whitespace so paths with internal spaces survive. + dir="$(printf '%s' "${dir}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')" + [ -n "${dir}" ] && args+=(--add-dir "${dir}") + done < <(printf '%s' "${ZERO_INPUT_ADD_DIR}" | tr ',' '\n') + fi + + output_file="${RUNNER_TEMP}/zero-output.txt" + + # Run ZERO. Capture stdout to a file AND echo it to the log. Surface the + # real exit code as the step's status. The sandbox is active by default; + # this action never enables unsafe permission-skipping. + set +e + zero "${args[@]}" | tee "${output_file}" + code=${PIPESTATUS[0]} + set -e + + echo "exit-code=${code}" >> "${GITHUB_OUTPUT}" + echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}" + + # Best-effort one-line summary: last non-empty line of the captured output. + summary="$(grep -v '^[[:space:]]*$' "${output_file}" | tail -n 1 || true)" + # Keep the summary single-line and bounded for the step output. + summary="$(printf '%s' "${summary}" | cut -c1-280)" + { + echo "summary<> "${GITHUB_OUTPUT}" + + exit "${code}" + + - name: Post run summary to pull request + if: ${{ always() && inputs.post-to == 'pr-comment' && github.event.pull_request.number != '' }} + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + ZERO_EXIT: ${{ steps.run.outputs.exit-code }} + ZERO_SUMMARY: ${{ steps.run.outputs.summary }} + run: | + set -euo pipefail + status="succeeded" + [ "${ZERO_EXIT}" != "0" ] && status="failed (exit ${ZERO_EXIT})" + body="$(printf 'ZERO run %s.\n\n%s' "${status}" "${ZERO_SUMMARY}")" + gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + -f body="${body}" >/dev/null + + - name: Post run summary to Slack + if: ${{ always() && inputs.post-to == 'slack' && inputs.slack-webhook-url != '' }} + shell: bash + env: + SLACK_WEBHOOK_URL: ${{ inputs.slack-webhook-url }} + ZERO_EXIT: ${{ steps.run.outputs.exit-code }} + ZERO_SUMMARY: ${{ steps.run.outputs.summary }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + status="succeeded" + [ "${ZERO_EXIT}" != "0" ] && status="failed (exit ${ZERO_EXIT})" + text="ZERO run ${status} for ${GITHUB_REPOSITORY}@${GITHUB_REF_NAME}. ${ZERO_SUMMARY} ${RUN_URL}" + # Slack incoming webhooks accept {"text": "..."}; jq escapes the body and + # the URL is never echoed. A delivery failure is a warning, not a failure. + payload="$(jq -n --arg text "${text}" '{text: $text}')" + curl --fail --silent --show-error -X POST -H 'Content-Type: application/json' \ + -d "${payload}" "${SLACK_WEBHOOK_URL}" >/dev/null || \ + echo "::warning::Slack webhook delivery failed (run not affected)." diff --git a/docs/GITHUB_ACTION.md b/docs/GITHUB_ACTION.md new file mode 100644 index 000000000..746a0c36d --- /dev/null +++ b/docs/GITHUB_ACTION.md @@ -0,0 +1,202 @@ +# ZERO GitHub Action + +Run ZERO headlessly inside a GitHub workflow. The action is a thin wrapper around +`zero exec`: it installs a pinned ZERO release, runs your prompt in the checked-out +repository, surfaces ZERO's exit code as the step status, captures the output as a +file, and can optionally post a summary to the triggering pull request or to Slack. + +ZERO is model- and provider-agnostic, and so is this action: **you** choose the +provider and supply the API key. Nothing is hardcoded to any single provider. + +## Quick start + +```yaml +# .github/workflows/zero.yml +name: ZERO +on: + workflow_dispatch: + inputs: + task: + description: What should ZERO do? + required: true + +permissions: + contents: write + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Gitlawb/zero@v1 + with: + prompt: ${{ inputs.task }} + provider: openai + api-key-env: OPENAI_API_KEY + api-key: ${{ secrets.OPENAI_API_KEY }} + model: gpt-4.1 +``` + +> The `provider`, `api-key-env`, and `api-key` trio is how you stay +> provider-neutral: `api-key-env` is the environment variable name the chosen +> provider reads its key from (for example `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, +> `OPENROUTER_API_KEY`), and `api-key` is the secret value. The action exports +> that variable for the run and never prints it. If your repository already +> commits a `.zero/config.json` with an active provider, you can omit `provider`. + +## Inputs + +| Input | Required | Default | Description | +| --- | --- | --- | --- | +| `prompt` | one of `prompt`/`prompt-file` | `""` | The instruction for ZERO to execute. | +| `prompt-file` | one of `prompt`/`prompt-file` | `""` | Path (relative to `working-directory`) to a file whose contents are the prompt. | +| `provider` | no | `""` | Provider id to activate (e.g. `openai`, `anthropic`, `gemini`, `ollama`, or any compatible endpoint). | +| `api-key` | no | `""` | The provider API key. Pass from a secret; never logged. | +| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported with `api-key`. | +| `model` | no | `""` | Model id. Defaults to the resolved provider's default. | +| `mode` | no | `""` | Run mode (`zero exec --mode`), e.g. `smart`, `deep`, `fast`. | +| `auto` | no | `low` | Autonomy ceiling (`zero exec --auto`): `low`, `medium`, or `high`. Conservative by default. | +| `self-correct` | no | `false` | Allow mid-run model escalation (`zero exec --allow-escalation`). | +| `add-dir` | no | `""` | Newline-/comma-separated extra write roots (`zero exec --add-dir`). | +| `worktree` | no | `false` | Run in an isolated git worktree (`zero exec --worktree`). | +| `output-format` | no | `stream-json` | `text`, `json`, or `stream-json`. Captured to a file. | +| `post-to` | no | `none` | `pr-comment`, `slack`, or `none`. Where to post a summary after the run. | +| `slack-webhook-url` | no | `""` | Slack incoming-webhook (or generic webhook) URL for `post-to: slack`. Pass from a secret. | +| `github-token` | no | `${{ github.token }}` | Token used to post a PR comment. Requires `pull-requests: write`. | +| `working-directory` | no | `${{ github.workspace }}` | Directory to run ZERO in. | +| `zero-version` | no | (action ref → `latest`) | ZERO release version/tag to install, e.g. `v1.2.3` or `latest`. | +| `zero-repo` | no | `Gitlawb/zero` | Repository to install the ZERO release from. | + +## Outputs + +| Output | Description | +| --- | --- | +| `exit-code` | ZERO's exit code (`0` success, `2` usage, `3` provider, non-zero otherwise). | +| `output-file` | Path to the captured ZERO stdout (the raw `output-format` stream). | +| `summary` | A short, single-line summary parsed from the run, when available. | + +The step **fails when ZERO returns a non-zero exit code**, so a failed run fails +the job by default. Use `continue-on-error: true` on the step (and read +`exit-code`) if you want to handle failures yourself. + +## Examples + +### Run ZERO on every issue labeled `zero` + +```yaml +name: ZERO issue triage +on: + issues: + types: [labeled] + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + triage: + if: github.event.label.name == 'zero' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Gitlawb/zero@v1 + with: + prompt: | + Investigate this issue and propose a fix. + + Title: ${{ github.event.issue.title }} + + ${{ github.event.issue.body }} + provider: anthropic + api-key-env: ANTHROPIC_API_KEY + api-key: ${{ secrets.ANTHROPIC_API_KEY }} + auto: low + post-to: slack + slack-webhook-url: ${{ secrets.ZERO_SLACK_WEBHOOK_URL }} +``` + +### Nightly dependency-upgrade PR + +```yaml +name: ZERO nightly deps +on: + schedule: + - cron: "0 6 * * 1" # Mondays 06:00 UTC + +permissions: + contents: write + pull-requests: write + +jobs: + deps: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Gitlawb/zero@v1 + id: zero + with: + prompt-file: .github/zero/upgrade-deps.md + provider: openai + api-key-env: OPENAI_API_KEY + api-key: ${{ secrets.OPENAI_API_KEY }} + worktree: true + auto: medium + - name: Open pull request + run: | + git checkout -b zero/deps-$(date +%Y%m%d) + git commit -am "chore(deps): nightly upgrade via ZERO" || exit 0 + git push -u origin HEAD + gh pr create --fill --label dependencies + env: + GH_TOKEN: ${{ github.token }} + - name: Upload ZERO output + if: always() + uses: actions/upload-artifact@v4 + with: + name: zero-output + path: ${{ steps.zero.outputs.output-file }} + if-no-files-found: warn +``` + +## Security notes + +- **Secrets are passed as Action secrets and never logged.** The action exports + `api-key` (under `api-key-env`) and `slack-webhook-url` into the step + environment only. They are never echoed, and ZERO redacts secret-shaped + strings from its own output. +- **The sandbox is always active.** This action never passes + `--skip-permissions-unsafe`, so writes stay inside the checked-out repository + (plus any roots you grant with `add-dir`). Unsafe mode is never enabled + implicitly. +- **Autonomy defaults to `low`.** Raise it deliberately (`auto: medium`/`high`) + only for tasks you trust to run unattended. +- **Least-privilege tokens.** Grant only the permissions the workflow needs + (`contents: write` to edit files, `pull-requests: write` to comment). The + default `GITHUB_TOKEN` is scoped to the repository. +- **Pin the action.** Reference a tag (`Gitlawb/zero@v1`) or a commit SHA so a + workflow run uses a known ZERO version. `zero-version` lets you pin the + installed binary independently of the action ref. +- **Linux and macOS runners** are supported; Windows runners are rejected with a + clear error. + +## Slack / webhook notifier + +The action's `post-to: slack` step sends a one-line summary to a Slack incoming +webhook after the run. ZERO also has a built-in webhook notifier sink +(`internal/notify`) that an unattended run can use to report +"finished / needs input / verify failed after N retries" to Slack or any generic +webhook: + +- Configure the destination with the `ZERO_SLACK_WEBHOOK_URL` environment variable + (or settings). A blank URL disables the sink. +- The sink POSTs a JSON body `{ "text", "type", "message", "summary?", "links?" }`. + The `text` field is what Slack renders; the structured fields carry the + machine-readable detail. +- **Fail-soft:** a non-2xx response or a transport error is logged (redacted) and + swallowed — a webhook problem never crashes the run. +- **Redaction:** the message, summary, links, and the webhook URL itself are run + through ZERO's redaction before being sent or logged, so tokens never leak. +- **Egress/proxy:** the notifier uses the default HTTP transport, which honors + `HTTP_PROXY`/`HTTPS_PROXY`. In a sandboxed run the scoped-egress proxy is + respected, so a webhook host that is not on the allowlist is denied. diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 3b0e917a2..93426c489 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -43,13 +43,25 @@ type Config struct { const maxMessageLen = 120 -// Notifier emits notifications to w according to cfg. Safe for concurrent use. +// Sink is an additional notification destination beyond the terminal (bell / +// OSC-9). A webhook/Slack sink is the canonical implementation: it lets an +// unattended run report "finished / needs input / verify failed" to a chat +// channel. Emit is fire-and-forget — it must never return an error, panic out, +// or block the run for long, so an unreachable endpoint cannot disrupt the +// agent. Implementations are responsible for their own redaction and timeouts. +type Sink interface { + Emit(event Event, message string) +} + +// Notifier emits notifications to w according to cfg, and fans them out to any +// attached Sinks. Safe for concurrent use. type Notifier struct { w io.Writer cfg Config // immutable after New; reads outside the lock are safe mu sync.Mutex focused bool + sinks []Sink } // New returns a Notifier. focused defaults to false so a headless caller (no @@ -59,6 +71,19 @@ func New(w io.Writer, cfg Config) *Notifier { return &Notifier{w: w, cfg: cfg} } +// AddSink registers an additional destination that receives every eligible +// event (subject to the same mode/focus policy as the terminal). Sinks fire +// even when the Notifier has no terminal writer, so a headless CI run can still +// reach Slack. Safe to call concurrently with Notify. +func (n *Notifier) AddSink(sink Sink) { + if sink == nil { + return + } + n.mu.Lock() + n.sinks = append(n.sinks, sink) + n.mu.Unlock() +} + // SetFocused records the terminal focus state (TUI FocusMsg/BlurMsg). func (n *Notifier) SetFocused(focused bool) { n.mu.Lock() @@ -67,22 +92,44 @@ func (n *Notifier) SetFocused(focused bool) { } // Notify emits a notification for event if policy allows. message is the OSC-9 -// body (ignored for bell). Write errors are intentionally ignored — a failed -// notification must never disrupt the run. +// body (ignored for bell) and is also forwarded verbatim to every sink. Write +// errors are intentionally ignored — a failed notification must never disrupt +// the run. +// +// The terminal sequence is gated by Mode (bell vs OSC-9) and by the focus +// policy; sinks are gated only by "notifications enabled" plus the focus policy, +// because a sink is a separate channel and not bound to the terminal mechanism. +// Sinks are invoked outside the lock so a slow/blocking sink cannot stall a +// concurrent Notify or SetFocused. func (n *Notifier) Notify(event Event, message string) { - if n.w == nil || n.cfg.Mode == ModeOff || n.cfg.Mode == "" { - return - } - seq := sequence(n.cfg.Mode, message) - if seq == "" { + if n.cfg.Mode == ModeOff || n.cfg.Mode == "" { return } + n.mu.Lock() - defer n.mu.Unlock() - if !shouldEmit(n.cfg, event, n.focused) { - return + eligible := shouldEmit(n.cfg, event, n.focused) + var sinks []Sink + if eligible && len(n.sinks) > 0 { + sinks = append(sinks, n.sinks...) } - _, _ = io.WriteString(n.w, seq) + if eligible && n.w != nil { + if seq := sequence(n.cfg.Mode, message); seq != "" { + _, _ = io.WriteString(n.w, seq) + } + } + n.mu.Unlock() + + for _, sink := range sinks { + emitToSink(sink, event, message) + } +} + +// emitToSink invokes one sink, isolating a panic so a misbehaving sink cannot +// crash the run or starve its siblings. A well-behaved Sink already fails soft; +// this is defense in depth. +func emitToSink(sink Sink, event Event, message string) { + defer func() { _ = recover() }() + sink.Emit(event, message) } // DefaultMessage is the generic OSC-9 body for an event (no prompt content). diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go index 1f29aa55d..54ba06aef 100644 --- a/internal/notify/notify_test.go +++ b/internal/notify/notify_test.go @@ -126,3 +126,119 @@ func TestEnabled(t *testing.T) { t.Fatal("bell/notify/both should be enabled") } } + +// recordingSink captures every event/message it receives. It is the test double +// used to assert fan-out from the Notifier to its attached sinks. +type recordingSink struct { + mu sync.Mutex + events []Event + last string +} + +func (s *recordingSink) Emit(event Event, message string) { + s.mu.Lock() + defer s.mu.Unlock() + s.events = append(s.events, event) + s.last = message +} + +func (s *recordingSink) count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.events) +} + +func TestNotifierFanOutHitsTerminalAndSink(t *testing.T) { + var buf bytes.Buffer + sink := &recordingSink{} + n := New(&buf, Config{Mode: ModeBell, FocusMode: FocusAlways}) + n.AddSink(sink) + + n.Notify(Completion, "Zero: ready") + + if buf.String() != "\x07" { + t.Fatalf("terminal bell not emitted, got %q", buf.String()) + } + if sink.count() != 1 { + t.Fatalf("sink received %d events, want 1", sink.count()) + } + if sink.last != "Zero: ready" { + t.Fatalf("sink message = %q", sink.last) + } +} + +func TestNotifierSinkFiresEvenWithoutTerminalWriter(t *testing.T) { + // A headless caller may have no terminal writer but still want a webhook to + // fire. A nil writer must not suppress sink delivery. + sink := &recordingSink{} + n := New(nil, Config{Mode: ModeNotify, FocusMode: FocusAlways}) + n.AddSink(sink) + + n.Notify(Completion, "hi") + + if sink.count() != 1 { + t.Fatalf("sink received %d events, want 1", sink.count()) + } +} + +func TestNotifierOffSuppressesSinks(t *testing.T) { + sink := &recordingSink{} + n := New(&bytes.Buffer{}, Config{Mode: ModeOff}) + n.AddSink(sink) + n.Notify(Completion, "hi") + if sink.count() != 0 { + t.Fatalf("off mode must not fan out to sinks, got %d", sink.count()) + } +} + +func TestNotifierSinkRespectsFocusPolicy(t *testing.T) { + sink := &recordingSink{} + n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusUnfocused}) + n.AddSink(sink) + n.SetFocused(true) + n.Notify(Completion, "hi") + if sink.count() != 0 { + t.Fatalf("focused + unfocused-policy must skip sink, got %d", sink.count()) + } + n.SetFocused(false) + n.Notify(Completion, "hi") + if sink.count() != 1 { + t.Fatalf("unfocused must deliver to sink, got %d", sink.count()) + } +} + +// panickingSink models a misbehaving sink. A sink fault must never crash the run. +type panickingSink struct{} + +func (panickingSink) Emit(Event, string) { panic("sink boom") } + +func TestNotifierSinkFailSoft(t *testing.T) { + var buf bytes.Buffer + n := New(&buf, Config{Mode: ModeBell, FocusMode: FocusAlways}) + n.AddSink(panickingSink{}) + good := &recordingSink{} + n.AddSink(good) + + // Must not propagate the panic, and a sibling sink must still receive the event. + n.Notify(Completion, "hi") + + if buf.String() != "\x07" { + t.Fatalf("terminal output suppressed by sink panic, got %q", buf.String()) + } + if good.count() != 1 { + t.Fatalf("sibling sink starved by panicking sink, got %d", good.count()) + } +} + +func TestNotifierFanOutRaceSafe(t *testing.T) { + n := New(&bytes.Buffer{}, Config{Mode: ModeBell, FocusMode: FocusAlways}) + n.AddSink(&recordingSink{}) + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(3) + go func() { defer wg.Done(); n.SetFocused(true) }() + go func() { defer wg.Done(); n.AddSink(&recordingSink{}) }() + go func() { defer wg.Done(); n.Notify(Completion, "x") }() + } + wg.Wait() +} diff --git a/internal/notify/slack.go b/internal/notify/slack.go new file mode 100644 index 000000000..d6e05abd3 --- /dev/null +++ b/internal/notify/slack.go @@ -0,0 +1,217 @@ +package notify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Gitlawb/zero/internal/redaction" +) + +// defaultWebhookTimeout bounds a single delivery attempt. A notification must +// never stall a finished run, so an unresponsive endpoint is abandoned quickly. +const defaultWebhookTimeout = 10 * time.Second + +// maxWebhookBodyBytes caps how much of an error response we read back for the +// log line, so a hostile or chatty endpoint cannot flood the audit trail. +const maxWebhookBodyBytes = 2 << 10 // 2 KiB + +// WebhookLink is an optional labeled URL attached to a notification (for +// example a link to the CI run, the opened PR, or the session log). +type WebhookLink struct { + Label string `json:"label,omitempty"` + URL string `json:"url"` +} + +// webhookPayload is the JSON body POSTed to the configured webhook. It is +// shaped to be useful to a generic consumer while remaining renderable by a +// Slack incoming webhook: Slack reads the top-level "text" field, and the +// structured fields (type/message/summary/links) carry the machine-readable +// detail. +type webhookPayload struct { + // Text is the human-readable line Slack renders in the channel. + Text string `json:"text"` + // Type is the machine-readable event kind ("completion", "awaiting_input"). + Type string `json:"type"` + // Message is the notification body (already redacted). + Message string `json:"message"` + // Summary is an optional one-line run summary (already redacted). + Summary string `json:"summary,omitempty"` + // Links are optional labeled URLs (run page, PR, logs). + Links []WebhookLink `json:"links,omitempty"` +} + +// WebhookConfig configures a WebhookSink. URL is a Slack incoming-webhook URL or +// any generic endpoint that accepts a JSON POST. The zero value (empty URL) +// yields an inert sink whose Emit is a no-op, so callers can wire a sink +// unconditionally and let configuration decide whether it fires. +type WebhookConfig struct { + // URL is the destination. Empty disables the sink. + URL string + // Summary is an optional run summary attached to every emitted payload. + Summary string + // Links are optional labeled URLs attached to every emitted payload. + Links []WebhookLink + // Client is the HTTP client used for delivery. When nil a client with a + // conservative timeout is used; its default transport honors HTTP(S)_PROXY, + // so a sandboxed run's scoped-egress proxy (and any deny of the webhook host) + // is respected automatically. + Client *http.Client + // Logf records a single line per failed delivery. Lines are passed through + // the repo redaction before being written, so a token in the URL or message + // is never logged in the clear. When nil, failures are silent. + Logf func(format string, args ...any) + // ExtraSecrets are literal values (for example the resolved API key) that must + // be masked in the payload and logs in addition to the built-in patterns. + ExtraSecrets []string +} + +// WebhookSink delivers notifications to a webhook/Slack endpoint. It implements +// Sink. Delivery is best-effort and fails soft: a non-2xx response or a +// transport error is logged (redacted) and swallowed so it can never disrupt +// the run. +type WebhookSink struct { + url string + summary string + links []WebhookLink + client *http.Client + logf func(format string, args ...any) + secrets []string +} + +// NewWebhookSink builds a WebhookSink from cfg. A blank URL produces an inert +// sink (Emit is a no-op). +func NewWebhookSink(cfg WebhookConfig) *WebhookSink { + client := cfg.Client + if client == nil { + client = &http.Client{Timeout: defaultWebhookTimeout} + } + return &WebhookSink{ + url: strings.TrimSpace(cfg.URL), + summary: cfg.Summary, + // Copy the caller-owned slices so later mutation of cfg can neither + // change a future payload nor race with a concurrent Emit. + links: append([]WebhookLink(nil), cfg.Links...), + client: client, + logf: cfg.Logf, + secrets: append([]string(nil), cfg.ExtraSecrets...), + } +} + +// Emit builds the JSON payload for event/message and POSTs it to the webhook. +// It is fire-and-forget: every failure path logs (redacted) and returns nil-ish +// rather than propagating, satisfying the Sink contract that a notification +// must never crash the run. +func (s *WebhookSink) Emit(event Event, message string) { + if s == nil || s.url == "" { + return + } + + // The webhook URL itself can carry a secret token, so treat it as a secret + // when redacting outbound fields (matching log()), not just s.secrets. + options := redaction.Options{ExtraSecretValues: append([]string{s.url}, s.secrets...)} + safeMessage := redaction.RedactString(message, options) + safeSummary := redaction.RedactString(s.summary, options) + links := s.redactLinks(options) + + payload := webhookPayload{ + Text: s.text(event, safeMessage), + Type: eventType(event), + Message: safeMessage, + Summary: safeSummary, + Links: links, + } + + body, err := json.Marshal(payload) + if err != nil { + // A payload built from strings should always marshal; guard anyway. + s.log("notify: webhook payload encode failed: %v", err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), defaultWebhookTimeout) + defer cancel() + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.url, bytes.NewReader(body)) + if err != nil { + s.log("notify: webhook request build failed: %v", err) + return + } + request.Header.Set("Content-Type", "application/json") + + response, err := s.client.Do(request) + if err != nil { + s.log("notify: webhook delivery failed: %v", err) + return + } + defer func() { _ = response.Body.Close() }() + + if response.StatusCode < 200 || response.StatusCode >= 300 { + snippet := readSnippet(response.Body) + s.log("notify: webhook returned %d %s", response.StatusCode, snippet) + return + } + // Drain the success body so the connection can be reused. + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxWebhookBodyBytes)) +} + +// text renders the channel-facing line for an event. +func (s *WebhookSink) text(event Event, safeMessage string) string { + if strings.TrimSpace(safeMessage) != "" { + return safeMessage + } + return DefaultMessage(event) +} + +// redactLinks returns a copy of the configured links with their URLs and labels +// passed through redaction (a URL may carry a token in its query/userinfo). +func (s *WebhookSink) redactLinks(options redaction.Options) []WebhookLink { + if len(s.links) == 0 { + return nil + } + out := make([]WebhookLink, 0, len(s.links)) + for _, link := range s.links { + out = append(out, WebhookLink{ + Label: redaction.RedactString(link.Label, options), + URL: redaction.RedactString(link.URL, options), + }) + } + return out +} + +// log writes one redacted line. The fully-formatted message is redacted as a +// whole so a token appearing in any argument (URL, response body, error) is +// masked before it reaches the logger. +func (s *WebhookSink) log(format string, args ...any) { + if s.logf == nil { + return + } + line := fmt.Sprintf(format, args...) + options := redaction.Options{ExtraSecretValues: append([]string{s.url}, s.secrets...)} + s.logf("%s", redaction.RedactString(line, options)) +} + +// eventType maps an Event to its stable machine-readable string. +func eventType(event Event) string { + switch event { + case AwaitingInput: + return "awaiting_input" + default: + return "completion" + } +} + +// readSnippet reads a bounded, single-line snippet of an error response body for +// logging. The caller redacts the result. +func readSnippet(body io.Reader) string { + data, _ := io.ReadAll(io.LimitReader(body, maxWebhookBodyBytes)) + snippet := strings.TrimSpace(string(data)) + snippet = strings.ReplaceAll(snippet, "\n", " ") + snippet = strings.ReplaceAll(snippet, "\r", " ") + return snippet +} diff --git a/internal/notify/slack_test.go b/internal/notify/slack_test.go new file mode 100644 index 000000000..445c2053e --- /dev/null +++ b/internal/notify/slack_test.go @@ -0,0 +1,255 @@ +package notify + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +// captureLog returns a thread-safe Logf and a getter for the joined output. +func captureLog() (func(string, ...any), func() string) { + var mu sync.Mutex + var lines []string + logf := func(format string, args ...any) { + mu.Lock() + defer mu.Unlock() + lines = append(lines, fmt.Sprintf(format, args...)) + } + get := func() string { + mu.Lock() + defer mu.Unlock() + return strings.Join(lines, "\n") + } + return logf, get +} + +func TestWebhookSinkPostsExpectedJSON(t *testing.T) { + type received struct { + method string + contentType string + body webhookPayload + raw string + } + got := make(chan received, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + var payload webhookPayload + _ = json.Unmarshal(raw, &payload) + got <- received{method: r.Method, contentType: r.Header.Get("Content-Type"), body: payload, raw: string(raw)} + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + sink := NewWebhookSink(WebhookConfig{URL: server.URL}) + sink.Emit(Completion, "Zero: ready") + + select { + case r := <-got: + if r.method != http.MethodPost { + t.Fatalf("method = %q, want POST", r.method) + } + if !strings.HasPrefix(r.contentType, "application/json") { + t.Fatalf("content-type = %q, want application/json", r.contentType) + } + if r.body.Type != "completion" { + t.Fatalf("payload type = %q, want completion", r.body.Type) + } + if r.body.Message != "Zero: ready" { + t.Fatalf("payload message = %q, want %q", r.body.Message, "Zero: ready") + } + // Slack incoming-webhook compatibility: a human-readable "text" field must + // be present so the message renders in a Slack channel. + if strings.TrimSpace(r.body.Text) == "" { + t.Fatalf("payload text was empty; raw=%s", r.raw) + } + case <-time.After(2 * time.Second): + t.Fatal("webhook server never received a request") + } +} + +func TestWebhookSinkIncludesSummaryAndLinks(t *testing.T) { + got := make(chan webhookPayload, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload webhookPayload + _ = json.NewDecoder(r.Body).Decode(&payload) + got <- payload + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + sink := NewWebhookSink(WebhookConfig{ + URL: server.URL, + Summary: "verify failed after 3 retries", + Links: []WebhookLink{{Label: "Run", URL: "https://example.test/run/1"}}, + }) + sink.Emit(AwaitingInput, "Zero: needs input") + + select { + case payload := <-got: + if payload.Type != "awaiting_input" { + t.Fatalf("type = %q, want awaiting_input", payload.Type) + } + if payload.Summary != "verify failed after 3 retries" { + t.Fatalf("summary = %q", payload.Summary) + } + if len(payload.Links) != 1 || payload.Links[0].URL != "https://example.test/run/1" { + t.Fatalf("links = %+v", payload.Links) + } + case <-time.After(2 * time.Second): + t.Fatal("webhook server never received a request") + } +} + +func TestWebhookSinkNon2xxDoesNotPanicAndLogs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer server.Close() + + logf, getLog := captureLog() + sink := NewWebhookSink(WebhookConfig{URL: server.URL, Logf: logf}) + // Must not panic and must not block the caller fatally. + sink.Emit(Completion, "hi") + + if log := getLog(); !strings.Contains(strings.ToLower(log), "webhook") || !strings.Contains(log, "500") { + t.Fatalf("expected a logged 500 webhook failure, got %q", log) + } +} + +func TestWebhookSinkTransportErrorIsLoggedNotFatal(t *testing.T) { + logf, getLog := captureLog() + // An unroutable URL forces a transport error; Emit must swallow it. + sink := NewWebhookSink(WebhookConfig{ + URL: "http://127.0.0.1:0/never", + Logf: logf, + Client: &http.Client{Timeout: 200 * time.Millisecond}, + }) + sink.Emit(Completion, "hi") + if log := getLog(); !strings.Contains(strings.ToLower(log), "webhook") { + t.Fatalf("expected a logged webhook transport error, got %q", log) + } +} + +func TestWebhookSinkRedactsSecretsInPayloadAndLog(t *testing.T) { + const token = "xoxb-123456789012-abcdefghijklmno" + got := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + got <- string(raw) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + logf, getLog := captureLog() + sink := NewWebhookSink(WebhookConfig{URL: server.URL, Logf: logf}) + sink.Emit(Completion, "leaking "+token+" oops") + + select { + case raw := <-got: + if strings.Contains(raw, token) { + t.Fatalf("token leaked into webhook payload: %s", raw) + } + if !strings.Contains(raw, "[REDACTED]") { + t.Fatalf("expected redaction marker in payload, got %s", raw) + } + case <-time.After(2 * time.Second): + t.Fatal("webhook server never received a request") + } + if log := getLog(); strings.Contains(log, token) { + t.Fatalf("token leaked into log: %s", log) + } +} + +func TestWebhookSinkRedactsWebhookURLInLog(t *testing.T) { + // A failing webhook whose URL itself carries a secret-looking token must not + // echo that token when the failure is logged. + logf, getLog := captureLog() + const secretURL = "http://127.0.0.1:0/services/T000/B000/xoxb-123456789012-secrettoken" + sink := NewWebhookSink(WebhookConfig{ + URL: secretURL, + Logf: logf, + Client: &http.Client{Timeout: 200 * time.Millisecond}, + }) + sink.Emit(Completion, "hi") + if log := getLog(); strings.Contains(log, "xoxb-123456789012-secrettoken") { + t.Fatalf("webhook URL token leaked into log: %s", log) + } +} + +func TestWebhookSinkEmptyURLIsNoop(t *testing.T) { + // A sink with no URL configured must do nothing (and never panic). + sink := NewWebhookSink(WebhookConfig{}) + sink.Emit(Completion, "hi") // no server, no panic, returns. +} + +func TestWebhookSinkRedactsWebhookURLInPayload(t *testing.T) { + // If the message or summary echoes the webhook URL (which carries a secret + // token), that URL must be redacted before it is sent in the payload. + got := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + got <- string(raw) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + sink := NewWebhookSink(WebhookConfig{ + URL: server.URL, + Summary: "see " + server.URL + " for the run", + Links: []WebhookLink{{Label: "Hook", URL: server.URL}}, + }) + sink.Emit(Completion, "delivering to "+server.URL) + + select { + case raw := <-got: + if strings.Contains(raw, server.URL) { + t.Fatalf("webhook URL leaked into payload: %s", raw) + } + if !strings.Contains(raw, "[REDACTED]") { + t.Fatalf("expected redaction marker in payload, got %s", raw) + } + case <-time.After(2 * time.Second): + t.Fatal("webhook server never received a request") + } +} + +func TestWebhookSinkCopiesConfigSlices(t *testing.T) { + // The sink must own its links/secrets so a caller mutating the config slices + // after construction cannot alter (or race with) future payloads. + const token = "xoxb-123456789012-abcdefghijklmno" + got := make(chan string, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + got <- string(raw) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + links := []WebhookLink{{Label: "Run", URL: "https://example.test/run/1"}} + secrets := []string{token} + sink := NewWebhookSink(WebhookConfig{URL: server.URL, Links: links, ExtraSecrets: secrets}) + + // Mutate the caller-owned slices after construction. + links[0] = WebhookLink{Label: "Hijacked", URL: "https://attacker.test"} + secrets[0] = "no-longer-redacted" + + sink.Emit(Completion, "leaking "+token+" oops") + + select { + case raw := <-got: + if strings.Contains(raw, "attacker.test") || strings.Contains(raw, "Hijacked") { + t.Fatalf("mutated link leaked into payload: %s", raw) + } + if strings.Contains(raw, token) { + t.Fatalf("token leaked despite original secret config: %s", raw) + } + case <-time.After(2 * time.Second): + t.Fatal("webhook server never received a request") + } +}