diff --git a/.github/workflows/agent-source-fix.yml b/.github/workflows/agent-source-fix.yml new file mode 100644 index 0000000000..defa11bcab --- /dev/null +++ b/.github/workflows/agent-source-fix.yml @@ -0,0 +1,399 @@ +name: Agent Source Fix +run-name: >- + Agent Source Fix ${{ github.event.client_payload.target_repository || 'sweep' }}#${{ + github.event.client_payload.pr_number || github.run_id }}@${{ + github.event.client_payload.pr_head_sha || github.sha }} + +on: + schedule: + - cron: "2-57/5 * * * *" + repository_dispatch: + types: [agent-source-fix] + +permissions: + contents: read + +concurrency: + group: >- + agent-source-fix-${{ github.event_name }}-${{ + github.event.client_payload.target_repository || github.repository }}-${{ + github.event.client_payload.pr_number || 'sweep' }} + cancel-in-progress: false + +jobs: + sweep: + if: github.event_name == 'schedule' && github.repository == 'ContextualWisdomLab/.github' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + actions: read + contents: write + id-token: write + env: + LOOKBACK_HOURS: ${{ vars.AGENT_SOURCE_FIX_LOOKBACK_HOURS || '168' }} + MAX_DISPATCHES: ${{ vars.AGENT_SOURCE_FIX_MAX_DISPATCHES || '10' }} + OPENCODE_REPOSITORY_DISPATCH_TARGETS: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }} + steps: + - name: Resolve sibling-repository token + id: token + env: + PR_REVIEW_MERGE_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN }} + OPENCODE_APPROVE_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN }} + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + run: | + set -euo pipefail + token="${PR_REVIEW_MERGE_TOKEN:-${OPENCODE_APPROVE_TOKEN:-}}" + source_type=organization + if [ -z "$token" ]; then + source_type=installation + [ -n "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] && [ -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || { + echo "::error::Source-fix sweep needs an organization token or GitHub OIDC."; exit 1; + } + request_url="$ACTIONS_ID_TOKEN_REQUEST_URL" + separator="?"; case "$request_url" in *\?*) separator="&" ;; esac + oidc="$(curl -fsS --connect-timeout 10 --max-time 30 -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${request_url}${separator}audience=${OIDC_AUDIENCE}")" + jwt="$(jq -r '.value // empty' <<<"$oidc")" + [ -n "$jwt" ] || { echo "::error::OIDC response was empty."; exit 1; } + token="$(curl -fsS --connect-timeout 10 --max-time 30 -X POST -H "Authorization: Bearer ${jwt}" "${OPENCODE_API_BASE_URL}/exchange_github_app_token" | jq -r '.token // empty')" + fi + [ -n "$token" ] || { echo "::error::Source-fix sweep token was empty."; exit 1; } + echo "::add-mask::$token" + echo "token=$token" >>"$GITHUB_OUTPUT" + echo "source_type=$source_type" >>"$GITHUB_OUTPUT" + + - name: Check out trusted scanner dependencies + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Sweep trusted source-fix commands + env: + TARGET_REPOSITORY_TOKEN: ${{ steps.token.outputs.token }} + REPOSITORY_SOURCE: ${{ steps.token.outputs.source_type }} + DISPATCH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + PYTHONPATH=scripts/ci python3 - <<'PY' + import hashlib, json, os, re, time + from datetime import datetime, timezone + from agent_mention_router import GitHubClient, parse_repository_allowlist + from agent_mention_sweep import cutoff_timestamp, list_recent_comments, list_recent_pull_requests + + trusted = {"OWNER", "MEMBER", "COLLABORATOR"} + direct = re.compile(r"^[ \t]*@cwl-source-fix(?![\w/-])", re.I | re.M) + compat = re.compile(r"^[ \t]*@opencode-agent(?![\w/-])[ \t]+(?:fix|repair)\b", re.I | re.M) + sha_re = re.compile(r"^[0-9a-f]{40}$") + repo_re = re.compile(r"^ContextualWisdomLab/[A-Za-z0-9_.-]+$") + target = GitHubClient(os.environ["TARGET_REPOSITORY_TOKEN"]) + dispatch = GitHubClient(os.environ["DISPATCH_TOKEN"]) + allowed = {r.casefold() for r in parse_repository_allowlist(os.environ.get("OPENCODE_REPOSITORY_DISPATCH_TARGETS", ""))} + maximum = max(1, min(100, int(os.environ.get("MAX_DISPATCHES", "10")))) + now = datetime.now(timezone.utc) + since = cutoff_timestamp(int(os.environ.get("LOOKBACK_HOURS", "168")), now=now) + deadline = time.monotonic() + 480 + sent = 0 + cache = {} + + def repo_error(repo, exc): + print(f"::warning::Source-fix sweep skipped {repo}: {exc.__class__.__name__}") + + def key(repo, pr, base_ref, base_sha, head_ref, head_sha, comment_id, actor): + claim = {"actor": actor, "base_ref": base_ref, "base_sha": base_sha, "command": "source-fix", "comment_id": comment_id, "head_ref": head_ref, "head_sha": head_sha, "pr_number": pr, "repository": repo} + return hashlib.sha256(json.dumps(claim, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode()).hexdigest() + + issues = list_recent_pull_requests( + target, organization="ContextualWisdomLab", repository_source=os.environ["REPOSITORY_SOURCE"], + since=since, on_error=repo_error, rotation_offset=int(now.timestamp() // 300), + ) + for issue in issues: + if sent >= maximum or time.monotonic() >= deadline: + break + repo, number = str(issue.get("repository") or ""), int(issue.get("number") or 0) + if not repo_re.fullmatch(repo) or number < 1 or repo.casefold() == "contextualwisdomlab/.github" or repo.casefold() not in allowed: + continue + comments = list_recent_comments(target, repository=repo, pull_request_number=number, since=since) + pr = target.request([f"repos/{repo}/pulls/{number}"]) + if not isinstance(pr, dict) or pr.get("state") != "open": + continue + base, head = pr.get("base") or {}, pr.get("head") or {} + base_ref, base_sha = str(base.get("ref") or ""), str(base.get("sha") or "").lower() + head_ref, head_sha = str(head.get("ref") or ""), str(head.get("sha") or "").lower() + if str((head.get("repo") or {}).get("full_name") or "") != repo or not sha_re.fullmatch(base_sha) or not sha_re.fullmatch(head_sha): + continue + for comment in comments: + if sent >= maximum: + break + body = str(comment.get("body") or "") + if not (direct.search(body) or compat.search(body)) or str((comment.get("user") or {}).get("type") or "").casefold() == "bot" or str(comment.get("author_association") or "").upper() not in trusted: + continue + actor, cid = str((comment.get("user") or {}).get("login") or ""), comment.get("id") + if not re.fullmatch(r"[A-Za-z0-9-]+", actor) or not isinstance(cid, int) or cid < 1: + continue + invocation = key(repo, number, base_ref, base_sha, head_ref, head_sha, cid, actor) + artifact = f"cwl-source-fix-invocation-{invocation}" + if artifact not in cache: + response = dispatch.request(["repos/ContextualWisdomLab/.github/actions/artifacts", "-X", "GET", "-f", f"name={artifact}", "-f", "per_page=100"]) + if not isinstance(response, dict) or type(response.get("total_count")) is not int or not isinstance(response.get("artifacts"), list) or response["total_count"] != len(response["artifacts"]): + raise RuntimeError("source-fix artifact ledger is malformed or truncated") + live = False + for item in response["artifacts"]: + if not isinstance(item, dict) or type(item.get("id")) is not int or item.get("id", 0) < 1 or item.get("name") != artifact or type(item.get("expired")) is not bool: + raise RuntimeError("source-fix artifact metadata is malformed") + live = live or item["expired"] is False + cache[artifact] = live + if cache[artifact]: + continue + dispatch.request(["repos/ContextualWisdomLab/.github/dispatches", "-X", "POST"], input_payload={ + "event_type": "agent-source-fix", + "client_payload": {"target_repository": repo, "pr_number": number, "pr_base_ref": base_ref, "pr_base_sha": base_sha, "pr_head_ref": head_ref, "pr_head_sha": head_sha, "requested_by": actor, "source_comment_id": cid, "invocation_key": invocation}, + }) + cache[artifact] = True + sent += 1 + print(f"Queued {sent} source-fix command(s).") + PY + + execute: + if: github.event_name == 'repository_dispatch' && github.event.action == 'agent-source-fix' && github.repository == 'ContextualWisdomLab/.github' + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: read + id-token: write + env: + TARGET_REPOSITORY: ${{ github.event.client_payload.target_repository || '' }} + PR_NUMBER: ${{ github.event.client_payload.pr_number || '' }} + PR_BASE_REF: ${{ github.event.client_payload.pr_base_ref || '' }} + PR_BASE_SHA: ${{ github.event.client_payload.pr_base_sha || '' }} + PR_HEAD_REF: ${{ github.event.client_payload.pr_head_ref || '' }} + PR_HEAD_SHA: ${{ github.event.client_payload.pr_head_sha || '' }} + REQUESTED_BY: ${{ github.event.client_payload.requested_by || '' }} + SOURCE_COMMENT_ID: ${{ github.event.client_payload.source_comment_id || '' }} + INVOCATION_KEY: ${{ github.event.client_payload.invocation_key || '' }} + OPENCODE_REPOSITORY_DISPATCH_TARGETS: ${{ vars.OPENCODE_REPOSITORY_DISPATCH_TARGETS }} + steps: + - name: Harden runner + uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + with: + egress-policy: audit + + - name: Validate immutable source-fix claim + run: | + set -euo pipefail + if ! [[ "$TARGET_REPOSITORY" =~ ^ContextualWisdomLab/[A-Za-z0-9_.-]+$ ]] || [ "$TARGET_REPOSITORY" = "ContextualWisdomLab/.github" ] || ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || ! [[ "$PR_BASE_REF" =~ ^[A-Za-z0-9._/-]+$ ]] || [[ "$PR_BASE_REF" == -* ]] || ! [[ "$PR_HEAD_REF" =~ ^[A-Za-z0-9._/-]+$ ]] || [[ "$PR_HEAD_REF" == -* ]] || ! [[ "$PR_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] || ! [[ "$PR_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || ! [[ "$SOURCE_COMMENT_ID" =~ ^[1-9][0-9]*$ ]] || ! [[ "$REQUESTED_BY" =~ ^[A-Za-z0-9-]+$ ]] || ! [[ "$INVOCATION_KEY" =~ ^[0-9a-f]{64}$ ]]; then + echo "::error::Malformed source-fix invocation; central .github self-modification is unsupported."; exit 1 + fi + python3 - <<'PY' + import hashlib, hmac, json, os + allowed = {x.strip().casefold() for x in os.environ.get("OPENCODE_REPOSITORY_DISPATCH_TARGETS", "").split(",") if x.strip()} + if os.environ["TARGET_REPOSITORY"].casefold() not in allowed: + raise SystemExit("target repository is not in OPENCODE_REPOSITORY_DISPATCH_TARGETS") + claim = {"actor": os.environ["REQUESTED_BY"], "base_ref": os.environ["PR_BASE_REF"], "base_sha": os.environ["PR_BASE_SHA"], "command": "source-fix", "comment_id": int(os.environ["SOURCE_COMMENT_ID"]), "head_ref": os.environ["PR_HEAD_REF"], "head_sha": os.environ["PR_HEAD_SHA"], "pr_number": int(os.environ["PR_NUMBER"]), "repository": os.environ["TARGET_REPOSITORY"]} + expected = hashlib.sha256(json.dumps(claim, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode()).hexdigest() + if not hmac.compare_digest(expected, os.environ["INVOCATION_KEY"]): + raise SystemExit("source-fix invocation key does not match canonical claim") + PY + + - name: Inspect durable source-fix ledger + id: ledger + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + name="cwl-source-fix-invocation-${INVOCATION_KEY}"; echo "LEDGER_ARTIFACT_NAME=$name" >>"$GITHUB_ENV" + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts" -X GET -f "name=${name}" -f per_page=100 >"$RUNNER_TEMP/ledger.json" + python3 - "$name" "$GITHUB_OUTPUT" "$RUNNER_TEMP/ledger.json" <<'PY' + import json, sys + name, output, path = sys.argv[1:] + payload = json.load(open(path, encoding="utf-8")); items = payload.get("artifacts") + if type(payload.get("total_count")) is not int or not isinstance(items, list) or payload["total_count"] != len(items): + raise SystemExit("source-fix ledger is malformed or truncated") + live = False + for item in items: + if not isinstance(item, dict) or type(item.get("id")) is not int or item.get("id", 0) < 1 or item.get("name") != name or type(item.get("expired")) is not bool: + raise SystemExit("source-fix ledger artifact metadata is malformed") + live = live or item["expired"] is False + with open(output, "a", encoding="utf-8") as handle: handle.write(f"claim={'false' if live else 'true'}\n") + PY + if grep -q '^claim=true$' "$GITHUB_OUTPUT"; then + mkdir -p "$RUNNER_TEMP/source-fix-ledger" + jq -n --arg invocation_key "$INVOCATION_KEY" --arg target_repository "$TARGET_REPOSITORY" --arg pr_head_sha "$PR_HEAD_SHA" --arg requested_by "$REQUESTED_BY" --argjson pr_number "$PR_NUMBER" --argjson source_comment_id "$SOURCE_COMMENT_ID" '{invocation_key:$invocation_key,target_repository:$target_repository,pr_number:$pr_number,pr_head_sha:$pr_head_sha,requested_by:$requested_by,source_comment_id:$source_comment_id}' >"$RUNNER_TEMP/source-fix-ledger/claim.json" + fi + + - name: Claim source-fix invocation + if: steps.ledger.outputs.claim == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ env.LEDGER_ARTIFACT_NAME }} + path: ${{ runner.temp }}/source-fix-ledger/claim.json + if-no-files-found: error + retention-days: 30 + compression-level: 0 + overwrite: false + include-hidden-files: false + + - name: Resolve target-repository write token + if: steps.ledger.outputs.claim == 'true' + id: token + env: + PR_REVIEW_MERGE_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN }} + OPENCODE_APPROVE_TOKEN: ${{ secrets.OPENCODE_APPROVE_TOKEN }} + OIDC_AUDIENCE: opencode-github-action + OPENCODE_API_BASE_URL: https://api.opencode.ai + run: | + set -euo pipefail + token="${PR_REVIEW_MERGE_TOKEN:-${OPENCODE_APPROVE_TOKEN:-}}" + if [ -z "$token" ]; then + [ -n "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] && [ -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || { echo "::error::Source-fix write token requires an organization token or GitHub OIDC."; exit 1; } + request_url="$ACTIONS_ID_TOKEN_REQUEST_URL"; separator="?"; case "$request_url" in *\?*) separator="&" ;; esac + jwt="$(curl -fsS --connect-timeout 10 --max-time 30 -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${request_url}${separator}audience=${OIDC_AUDIENCE}" | jq -r '.value // empty')" + [ -n "$jwt" ] || { echo "::error::OIDC response was empty."; exit 1; } + token="$(curl -fsS --connect-timeout 10 --max-time 30 -X POST -H "Authorization: Bearer ${jwt}" "${OPENCODE_API_BASE_URL}/exchange_github_app_token" | jq -r '.token // empty')" + fi + [ -n "$token" ] || { echo "::error::Source-fix write token was empty."; exit 1; } + echo "::add-mask::$token"; echo "token=$token" >>"$GITHUB_OUTPUT" + + - name: Check out trusted control-plane source + if: steps.ledger.outputs.claim == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ContextualWisdomLab/.github + ref: ${{ github.sha }} + fetch-depth: 1 + persist-credentials: false + path: trusted-source + + - name: Validate command and check out exact PR head + if: steps.ledger.outputs.claim == 'true' + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + run: | + set -euo pipefail + export PR_JSON="$(gh api -X GET "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" + export COMMENT_JSON="$(gh api -X GET "repos/${TARGET_REPOSITORY}/issues/comments/${SOURCE_COMMENT_ID}")" + python3 - <<'PY' + import json, os, re + pr, comment = json.loads(os.environ["PR_JSON"]), json.loads(os.environ["COMMENT_JSON"]) + direct = re.compile(r"^[ \t]*@cwl-source-fix(?![\w/-])", re.I | re.M) + compat = re.compile(r"^[ \t]*@opencode-agent(?![\w/-])[ \t]+(?:fix|repair)\b", re.I | re.M) + if pr.get("state") != "open": raise SystemExit("source-fix requires an open pull request") + if not str(comment.get("issue_url") or "").endswith(f"/issues/{os.environ['PR_NUMBER']}"): raise SystemExit("source-fix source comment does not belong to the claimed pull request") + if (pr.get("head") or {}).get("repo", {}).get("full_name") != os.environ["TARGET_REPOSITORY"]: raise SystemExit("source-fix supports only same-repository PR heads") + observed = {"base_ref": (pr.get("base") or {}).get("ref"), "base_sha": (pr.get("base") or {}).get("sha"), "head_ref": (pr.get("head") or {}).get("ref"), "head_sha": (pr.get("head") or {}).get("sha")} + expected = {"base_ref": os.environ["PR_BASE_REF"], "base_sha": os.environ["PR_BASE_SHA"], "head_ref": os.environ["PR_HEAD_REF"], "head_sha": os.environ["PR_HEAD_SHA"]} + if observed != expected: raise SystemExit("source-fix PR identity moved after dispatch") + user = comment.get("user") or {} + if user.get("login") != os.environ["REQUESTED_BY"] or str(user.get("type") or "").casefold() == "bot" or str(comment.get("author_association") or "").upper() not in {"OWNER", "MEMBER", "COLLABORATOR"}: raise SystemExit("source-fix comment actor is not trusted") + body = str(comment.get("body") or ""); match = direct.search(body) or compat.search(body) + if not match: raise SystemExit("source-fix trigger is absent from the source comment") + instruction = (body[:match.start()] + body[match.end():]).strip() + if not instruction or len(instruction) > 12000: raise SystemExit("source-fix needs a concrete instruction of at most 12000 characters") + open(os.environ["RUNNER_TEMP"] + "/source-fix-instruction.txt", "w", encoding="utf-8").write(instruction + "\n") + PY + target="$RUNNER_TEMP/source-fix-target"; mkdir -p "$target"; git init -q "$target"; gh auth setup-git + git -C "$target" remote add origin "${GITHUB_SERVER_URL}/${TARGET_REPOSITORY}.git" + git -C "$target" fetch --no-tags origin "+refs/heads/${PR_BASE_REF}:refs/remotes/origin/${PR_BASE_REF}" "+refs/heads/${PR_HEAD_REF}:refs/remotes/origin/${PR_HEAD_REF}" + [ "$(git -C "$target" rev-parse "refs/remotes/origin/${PR_HEAD_REF}")" = "$PR_HEAD_SHA" ] || { echo "::error::Fetched head differs from claimed head."; exit 1; } + git -C "$target" switch --detach "$PR_HEAD_SHA" + git -C "$target" config user.email "41898282+github-actions[bot]@users.noreply.github.com"; git -C "$target" config user.name "github-actions[bot]" + echo "TARGET_WORKSPACE=$target" >>"$GITHUB_ENV" + + - name: Seal authenticated PR-authored edit scope + if: steps.ledger.outputs.claim == 'true' + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + run: | + set -euo pipefail + export PR_JSON="$(gh api -X GET "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")"; files="$RUNNER_TEMP/source-fix-files.json" + gh api -X GET "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}/files" -f per_page=100 --paginate --slurp >"$files"; export FILES_JSON="$files" + python3 - <<'PY' + import json, os + from pathlib import Path + pr = json.loads(os.environ["PR_JSON"]); pages = json.loads(Path(os.environ["FILES_JSON"]).read_text()); files = [x for page in pages for x in page] + expected = pr.get("changed_files") + if type(expected) is not int or expected < 0 or expected > 3000 or len(files) != expected: raise SystemExit("source-fix requires a complete GitHub PR-files receipt with at most 3000 files") + paths = [] + for item in files: + path, status = str(item.get("filename") or ""), str(item.get("status") or "").lower() + if status == "removed": continue + if not path or path != path.strip() or path.startswith("/") or ".." in path.split("/") or any(c in path for c in "\0\r\n`"): raise SystemExit("source-fix PR-files receipt contains an unsafe path") + if path.startswith(".github/") or path.startswith("scripts/ci/"): continue + paths.append(path) + if not paths: raise SystemExit("source-fix has no existing PR-authored path available for a bounded edit") + Path(os.environ["RUNNER_TEMP"] + "/source-fix-allowed-paths.zlist").write_bytes(b"".join(os.fsencode(p) + b"\0" for p in sorted(set(paths)))) + PY + + - name: Provision contextual-orchestrator sidecar + if: steps.ledger.outputs.claim == 'true' + env: + BYTEZ_API_KEY: ${{ secrets.BYTEZ_API_KEY }} + NVIDIA_NIM_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }} + NVIDIA_NIM_API_KEY_SUB: ${{ secrets.NVIDIA_NIM_API_KEY_SUB }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: bash "$GITHUB_WORKSPACE/trusted-source/scripts/ci/contextual_orchestrator_review_sidecar.sh" + + - name: Install OpenCode CLI + if: steps.ledger.outputs.claim == 'true' + env: + OPENCODE_VERSION: "1.17.13" + OPENCODE_SHA256: 157afa289d1a8d9372de0ce19ac726119b937a1f6b201808d46f06e4e59bb348 + run: | + set -euo pipefail + archive="$RUNNER_TEMP/opencode.tar.gz"; dir="$HOME/.opencode/bin"; mkdir -p "$dir" + curl -fsSL -o "$archive" "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz" + printf '%s %s\n' "$OPENCODE_SHA256" "$archive" | sha256sum -c -; tar -xzf "$archive" -C "$RUNNER_TEMP"; install -m 0755 "$RUNNER_TEMP/opencode" "$dir/opencode"; echo "$dir" >>"$GITHUB_PATH" + + - name: Run bounded source repair + if: steps.ledger.outputs.claim == 'true' + env: + MODEL: contextual-orchestrator/orchestrator/free + SHARE: "false" + NO_COLOR: "1" + run: | + set -euo pipefail + source "$GITHUB_WORKSPACE/trusted-source/scripts/ci/load_contextual_orchestrator_token.sh" + allowed="$RUNNER_TEMP/source-fix-allowed-paths.zlist" + allowed_json="$(python3 - "$allowed" <<'PY' + import json, sys + from pathlib import Path + data = Path(sys.argv[1]).read_bytes() + if data and not data.endswith(b"\0"): raise SystemExit("source-fix allowlist is not NUL terminated") + print(json.dumps([x.decode() for x in data[:-1].split(b"\0")] if data else [], ensure_ascii=True)) + PY + )" + snapshot="$RUNNER_TEMP/source-fix-before.json" + python3 "$GITHUB_WORKSPACE/trusted-source/scripts/ci/pr_review_conflict_scope.py" snapshot --root "$TARGET_WORKSPACE" --output "$snapshot" + backup="$RUNNER_TEMP/opencode-jsonc.backup"; had=0; [ ! -f "$TARGET_WORKSPACE/opencode.jsonc" ] || { cp "$TARGET_WORKSPACE/opencode.jsonc" "$backup"; had=1; } + restore() { [ "$had" = 1 ] && cp "$backup" "$TARGET_WORKSPACE/opencode.jsonc" || rm -f "$TARGET_WORKSPACE/opencode.jsonc"; } + cat >"$TARGET_WORKSPACE/opencode.jsonc" <<'JSON' + {"$schema":"https://opencode.ai/config.json","model":"contextual-orchestrator/orchestrator/free","small_model":"contextual-orchestrator/orchestrator/free","enabled_providers":["contextual-orchestrator"],"permission":{"edit":{"*":"allow",".git":"deny",".git/*":"deny"},"bash":"deny","read":"allow","grep":"allow","glob":"allow","list":"allow","task":"deny","skill":"deny","question":"deny","webfetch":"deny","websearch":"deny","lsp":"deny","external_directory":"deny","doom_loop":"deny"},"agent":{"ci-source-fix":{"description":"Bounded source repair for a trusted maintainer request","mode":"primary","model":"contextual-orchestrator/orchestrator/free","reasoningEffort":"high","steps":16,"permission":{"edit":{"*":"allow",".git":"deny",".git/*":"deny"},"bash":"deny","read":"allow","grep":"allow","glob":"allow","list":"allow","task":"deny","skill":"deny","question":"deny","webfetch":"deny","websearch":"deny","lsp":"deny","external_directory":"deny","doom_loop":"deny"}}},"provider":{"contextual-orchestrator":{"npm":"@ai-sdk/openai-compatible","name":"Contextual Orchestrator","options":{"baseURL":"{env:CONTEXTUAL_ORCHESTRATOR_BASE_URL}","apiKey":"{env:CONTEXTUAL_ORCHESTRATOR_TOKEN}"},"models":{"orchestrator/free":{"name":"Orchestrator Free","tool_call":true,"reasoning":true,"limit":{"context":200000,"output":32768}}}}}} + JSON + prompt="$RUNNER_TEMP/source-fix-prompt.md" + cat >"$prompt" <$(cat "$RUNNER_TEMP/source-fix-instruction.txt") + Authoritative editable paths: ${allowed_json} + Establish the causal defect from source. Make the smallest coherent repair only inside these paths. Do not create new paths. If no safe in-scope repair exists, leave the tree unchanged. + EOF + cd "$TARGET_WORKSPACE"; trap restore EXIT + env -u GITHUB_TOKEN -u GH_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u ACTIONS_ID_TOKEN_REQUEST_URL opencode run "$(cat "$prompt")" --pure --agent ci-source-fix --model "$MODEL" --title "PR #${PR_NUMBER} source fix" + restore; trap - EXIT + python3 "$GITHUB_WORKSPACE/trusted-source/scripts/ci/pr_review_conflict_scope.py" verify --root "$TARGET_WORKSPACE" --snapshot "$snapshot" --allowed-paths "$allowed" + + - name: Validate repair and push exact-head descendant + if: steps.ledger.outputs.claim == 'true' + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + run: | + set -euo pipefail; cd "$TARGET_WORKSPACE"; git diff --check + allowed="$RUNNER_TEMP/source-fix-allowed-paths.zlist"; mapfile -d '' -t allowed_paths <"$allowed"; mapfile -d '' -t changed < <({ git diff --name-only -z; git ls-files --others --exclude-standard -z; } | sort -zu) + [ "${#changed[@]}" -gt 0 ] || { echo "No safe source change was produced."; exit 0; } + for path in "${changed[@]}"; do ok=0; for candidate in "${allowed_paths[@]}"; do [ "$path" != "$candidate" ] || { ok=1; break; }; done; [ "$ok" = 1 ] || { echo "::error::Source fix escaped sealed PR scope: $path"; exit 1; }; done + python_files=(); workflow_files=(); for path in "${changed[@]}"; do case "$path" in *.py) python_files+=("$path") ;; esac; case "$path" in .github/workflows/*.yml|.github/workflows/*.yaml) workflow_files+=("$path") ;; esac; done + [ "${#python_files[@]}" = 0 ] || python3 -m py_compile "${python_files[@]}"; if [ "${#workflow_files[@]}" -gt 0 ] && command -v actionlint >/dev/null; then actionlint "${workflow_files[@]}"; fi + [ "$(gh api -X GET "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}" --jq '.head.sha')" = "$PR_HEAD_SHA" ] || { echo "::error::PR head moved during source fix; refusing to push."; exit 1; } + git add -A; git -c core.hooksPath=/dev/null commit -m "fix(pr-${PR_NUMBER}): apply requested source repair" + git -c core.hooksPath=/dev/null push "${GITHUB_SERVER_URL}/${TARGET_REPOSITORY}.git" "HEAD:${PR_HEAD_REF}" diff --git a/CHANGELOG.d/agent-source-fix-command.md b/CHANGELOG.d/agent-source-fix-command.md new file mode 100644 index 0000000000..d53da8f99e --- /dev/null +++ b/CHANGELOG.d/agent-source-fix-command.md @@ -0,0 +1,6 @@ +# Agent source-fix command lane + +- Added a bounded organization-wide `@cwl-source-fix` command for trusted maintainers, with compatibility recognition for `@opencode-agent fix` and `@opencode-agent repair`. +- Bound every mutation request to the exact repository, PR, source comment, actor, base SHA, and head SHA, and added a durable Actions-artifact invocation ledger. +- Restricted edits to the complete authenticated GitHub PR Files set, excluding removed files, `.github/`, `scripts/ci/`, forks, new paths, and the central `.github` repository itself. +- Routed model work only through contextual-orchestrator `orchestrator/free`, denied shell/web/external tool escape, restored temporary model configuration before scope verification, and required an unchanged live head before a normal non-force push. diff --git a/docs/automation/source-fix-command.md b/docs/automation/source-fix-command.md new file mode 100644 index 0000000000..dc7adb1ecf --- /dev/null +++ b/docs/automation/source-fix-command.md @@ -0,0 +1,27 @@ +# Source-fix command lane + +CWL review mentions and source mutation are separate capabilities. `@opencode-agent` remains a review request. A maintainer who needs an agent to change an existing pull-request branch uses the explicit source-fix lane instead: + +```text +@cwl-source-fix Fix the stale selection state that is lost after project reload. Preserve the existing persistence contract and add the smallest regression-safe repair. +``` + +For compatibility, `@opencode-agent fix ...` and `@opencode-agent repair ...` are also recognized by the source-fix sweep. Those compatibility forms still match the review mention router, so they can enqueue a review as well. Use `@cwl-source-fix` when source mutation is the only requested action. + +## Trust and mutation boundary + +The command is accepted only from an `OWNER`, `MEMBER`, or `COLLABORATOR` on an open same-repository pull request in the configured OpenCode repository allowlist. The invocation is bound to the requesting actor, source comment ID, repository, pull-request number, base ref/SHA, and head ref/SHA. A durable exact-name Actions artifact prevents the same immutable invocation from being applied twice. + +The worker deliberately rejects `ContextualWisdomLab/.github`. Central automation cannot use this lane to rewrite its own control plane. It also excludes `.github/` and `scripts/ci/` paths in target repositories. Control-plane repairs stay with the canonical `.github` development path rather than being delegated to a source-fix command. + +The editable set comes from the complete paginated GitHub PR Files receipt. The `changed_files` count must match the returned records and may not exceed GitHub's 3,000-file PR Files ceiling. Removed, unsafe, control-plane, or non-PR-authored paths are excluded. Version 1 cannot add a new path or broaden the pull request's authored scope. + +## Execution model + +The scheduled sweep rotates across accessible CWL repositories and looks only for trusted source-fix commands. It dispatches an exact immutable claim to the central worker. Before editing, the worker re-reads the live pull request and source comment, verifies the same actor and command, rejects fork heads, and checks that the base/head tuple has not moved. + +OpenCode runs through the released contextual-orchestrator sidecar with `contextual-orchestrator/orchestrator/free`. Provider-specific models and paid fallback are not selected by this workflow. Shell, web, external-directory, task, and nested-agent access are denied to the model. The maintainer's requested outcome is treated as untrusted task text, not as permission to weaken tests, review gates, security boundaries, or repository policy. + +The pre-edit workspace is snapshotted, the temporary OpenCode configuration is restored before scope verification, and every changed path must remain inside the sealed PR-authored allowlist. The worker runs `git diff --check`, compiles changed Python files, and runs `actionlint` for changed workflows when available. Immediately before mutation it re-reads the live PR head. Only an unchanged exact head may receive a normal descendant commit and non-force push. + +A successful source-fix run does not approve or merge the pull request. The new head must pass the repository's normal checks, security scans, and independent review. If the model cannot make a safe in-scope change, the tree remains unchanged. Because the invocation ledger is comment-scoped, a materially new repair request should be made in a new comment rather than editing or replaying the old command. diff --git a/tests/test_agent_source_fix_workflow_contract.py b/tests/test_agent_source_fix_workflow_contract.py new file mode 100644 index 0000000000..96df77873e --- /dev/null +++ b/tests/test_agent_source_fix_workflow_contract.py @@ -0,0 +1,78 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "agent-source-fix.yml" + + +def text() -> str: + return WORKFLOW.read_text(encoding="utf-8") + + +def test_command_and_identity_contract() -> None: + value = text() + assert "types: [agent-source-fix]" in value + assert "@cwl-source-fix" in value + assert "@opencode-agent" in value and "(?:fix|repair)" in value + assert "source comment does not belong to the claimed pull request" in value + for association in ("OWNER", "MEMBER", "COLLABORATOR"): + assert association in value + for field in ("pr_base_ref", "pr_base_sha", "pr_head_ref", "pr_head_sha", "source_comment_id", "requested_by", "invocation_key"): + assert field in value + assert '"command": "source-fix"' in value + assert "hmac.compare_digest" in value + assert "cwl-source-fix-invocation-" in value + + +def test_mutation_boundary() -> None: + value = text() + assert '[ "$TARGET_REPOSITORY" = "ContextualWisdomLab/.github" ]' in value + assert "source-fix supports only same-repository PR heads" in value + assert 'path.startswith(".github/")' in value + assert 'path.startswith("scripts/ci/")' in value + assert "source-fix has no existing PR-authored path available for a bounded edit" in value + assert "Do not create new paths" in value + + +def test_authenticated_pr_file_scope() -> None: + value = text() + assert 'pulls/${PR_NUMBER}/files' in value + assert "--paginate --slurp" in value + assert 'expected = pr.get("changed_files")' in value + assert "expected > 3000" in value and "len(files) != expected" in value + assert "complete GitHub PR-files receipt" in value + + +def test_canonical_model_and_denied_tools() -> None: + value = text() + assert value.count("contextual-orchestrator/orchestrator/free") >= 4 + assert '"enabled_providers":["contextual-orchestrator"]' in value + for capability in ('"bash":"deny"', '"webfetch":"deny"', '"websearch":"deny"', '"task":"deny"', '"external_directory":"deny"'): + assert capability in value + assert "contextual_orchestrator_review_sidecar.sh" in value + + +def test_workspace_restore_scope_check_and_non_force_push() -> None: + value = text() + snapshot = value.index('pr_review_conflict_scope.py" snapshot') + temp_config = value.index('cat >"$TARGET_WORKSPACE/opencode.jsonc"') + run_model = value.index("opencode run") + restore = value.index("restore;", run_model) + verify = value.index('pr_review_conflict_scope.py" verify', restore) + recheck = value.index("PR head moved during source fix; refusing to push") + push = value.index("core.hooksPath=/dev/null push") + assert snapshot < temp_config < run_model < restore < verify < recheck < push + assert "git diff --check" in value + assert "python3 -m py_compile" in value + assert "git rebase" not in value + assert "push --force" not in value + assert "push -f" not in value + + +def test_pinned_actions_and_bounded_fair_sweep() -> None: + value = text() + assert "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" in value + assert "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" in value + assert 'cron: "2-57/5 * * * *"' in value + assert "rotation_offset=int(now.timestamp() // 300)" in value + assert "deadline = time.monotonic() + 480" in value + assert "MAX_DISPATCHES" in value