diff --git a/action.yml b/action.yml index 75fc874..e362993 100644 --- a/action.yml +++ b/action.yml @@ -573,6 +573,7 @@ runs: RAW_PARSING_MODEL: ${{ inputs.parsing_model }} RAW_LICENSE: ${{ inputs.license_key }} RAW_PROXY_URL: ${{ inputs.proxy_url }} + ACTION_PATH: ${{ github.action_path }} run: | set -euo pipefail AUTH_FILE="${RUNNER_TEMP}/openrouter-auth.json" @@ -608,11 +609,10 @@ runs: echo "Credential mode: $MODE" # ── Hosted modes (license / oidc): provider is always OpenRouter via proxy ── - # The hosted tiers run on CodeBoarding's OpenRouter account, so the engine - # always talks OpenRouter here. The bearer is the OIDC JWT (free) or the OIDC - # JWT packed with the license (license mode); the proxy verifies the OIDC, - # applies the license, and swaps in the real OpenRouter key. To use a DIFFERENT - # provider, set llm_api_key for that provider (that's BYO-key mode, below). + # The hosted tiers run on CodeBoarding's OpenRouter account. A loopback relay + # mints a fresh GitHub OIDC JWT for every engine request, then forwards it to + # the hosted proxy. This matters because an analysis can outlive a single OIDC + # JWT. To use a DIFFERENT provider, set llm_api_key (BYO-key mode, below). if [ "$MODE" != "byokey" ]; then if [ -z "$PROXY_URL" ]; then echo "::error::proxy_url is empty but no llm_api_key was provided. Set llm_api_key, or restore proxy_url." @@ -628,46 +628,50 @@ runs: AGENT_MODEL="${AGENT_MODEL:-google/gemini-3-flash-preview}" PARSING_MODEL="${PARSING_MODEL:-google/gemini-3.1-flash-lite-preview}" - # Both hosted tiers (free + license) authenticate to the proxy with a - # GitHub OIDC JWT — it's the unforgeable per-repo identity the proxy meters - # and abuse-checks on, so it is mandatory even when a license is present. - # The engine (ChatOpenAI) can only set the bearer, not a custom header, so a - # license rides the bearer alongside the OIDC token as `~codeboarding-license~`; - # the proxy splits on that separator (KEEP IN SYNC with gha_proxy handler). - # ACTIONS_ID_TOKEN_REQUEST_URL/_TOKEN are injected into the runner process env - # (NOT the `env` context) only when the job grants `id-token: write`; read them - # directly from the shell env. + # ACTIONS_ID_TOKEN_REQUEST_URL/_TOKEN are injected into the runner process + # env (NOT the `env` context) only when the job grants `id-token: write`. + # Pass them to the local relay through its inherited environment; it requests + # a new JWT per forwarded request instead of freezing one into the engine. if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then echo "::error::No GitHub OIDC token available. Add \`permissions: id-token: write\` to the job (the hosted tier — free and license — needs the OIDC token to identify your repository; an llm_api_key avoids the proxy entirely)." exit 1 fi - OIDC_RESP="$(curl -sS --max-time 15 \ - -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ - "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=codeboarding-proxy" || true)" - OIDC_JWT="$(printf '%s' "$OIDC_RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("value",""))' 2>/dev/null || true)" - if [ -z "$OIDC_JWT" ]; then - echo "::error::Failed to mint a GitHub OIDC token (is \`permissions: id-token: write\` granted?)." - exit 1 - fi - echo "::add-mask::$OIDC_JWT" - + RELAY_READY="${RUNNER_TEMP}/cb-oidc-relay-port" + RELAY_PID_FILE="${RUNNER_TEMP}/cb-oidc-relay.pid" + RELAY_LICENSE_FILE="${RUNNER_TEMP}/cb-oidc-relay-license" + RELAY_LOG="${RUNNER_TEMP}/cb-oidc-relay.log" + rm -f "$RELAY_READY" "$RELAY_PID_FILE" "$RELAY_LICENSE_FILE" "$RELAY_LOG" + relay_args=(--upstream-base-url "$PROXY_URL" --ready-file "$RELAY_READY") if [ "$MODE" = "license" ]; then - # Pack the license after the OIDC JWT; the proxy verifies the JWT (identity) - # and validates the license (skips the free quota). Mask both halves. echo "::add-mask::$LICENSE" - BEARER="${OIDC_JWT}~codeboarding-license~${LICENSE}" - echo "Using CodeBoarding license via hosted proxy (OIDC-identified)." - else - BEARER="$OIDC_JWT" - echo "Using the free hosted tier via a GitHub OIDC token (metered per repository owner)." + printf '%s' "$LICENSE" > "$RELAY_LICENSE_FILE" + relay_args+=(--license-file "$RELAY_LICENSE_FILE") fi - - echo "::add-mask::$BEARER" - printf '%s' "$BEARER" > "${RUNNER_TEMP}/cb-llm-key" + python3 "$ACTION_PATH/scripts/oidc_relay.py" "${relay_args[@]}" >"$RELAY_LOG" 2>&1 & + RELAY_PID=$! + printf '%s' "$RELAY_PID" > "$RELAY_PID_FILE" + for _ in $(seq 1 50); do + [ -s "$RELAY_READY" ] && break + kill -0 "$RELAY_PID" 2>/dev/null || break + sleep 0.1 + done + if [ ! -s "$RELAY_READY" ]; then + echo "::error::Failed to start the GitHub OIDC relay." + sed -n '1,20p' "$RELAY_LOG" || true + exit 1 + fi + RELAY_PORT="$(cat "$RELAY_READY")" + case "$RELAY_PORT" in *[!0-9]*|'') echo "::error::OIDC relay returned an invalid port."; exit 1 ;; esac + printf '%s' 'github-actions-oidc-relay' > "${RUNNER_TEMP}/cb-llm-key" printf '%s' "$PROVIDER_ENV" > "${RUNNER_TEMP}/cb-provider-env" - printf '%s' "$PROXY_URL" > "${RUNNER_TEMP}/cb-base-url" + printf '%s' "http://127.0.0.1:${RELAY_PORT}" > "${RUNNER_TEMP}/cb-base-url" printf '%s' "$AGENT_MODEL" > "${RUNNER_TEMP}/cb-agent-model" printf '%s' "$PARSING_MODEL" > "${RUNNER_TEMP}/cb-parsing-model" + if [ "$MODE" = "license" ]; then + echo "Using CodeBoarding license via a GitHub OIDC relay (token refreshed per request)." + else + echo "Using the free hosted tier via a GitHub OIDC relay (token refreshed per request)." + fi exit 0 fi @@ -1202,11 +1206,18 @@ runs: if: always() && steps.guard.outputs.skip != 'true' shell: bash run: | + if [ -f "${RUNNER_TEMP}/cb-oidc-relay.pid" ]; then + kill "$(cat "${RUNNER_TEMP}/cb-oidc-relay.pid")" 2>/dev/null || true + fi rm -f "${RUNNER_TEMP}/cb-llm-key" \ "${RUNNER_TEMP}/cb-provider-env" \ "${RUNNER_TEMP}/cb-base-url" \ "${RUNNER_TEMP}/cb-agent-model" \ - "${RUNNER_TEMP}/cb-parsing-model" + "${RUNNER_TEMP}/cb-parsing-model" \ + "${RUNNER_TEMP}/cb-oidc-relay.pid" \ + "${RUNNER_TEMP}/cb-oidc-relay-port" \ + "${RUNNER_TEMP}/cb-oidc-relay-license" \ + "${RUNNER_TEMP}/cb-oidc-relay.log" - name: Diff analyses → Mermaid if: steps.guard.outputs.skip != 'true' && steps.guard.outputs.mode == 'review' diff --git a/scripts/oidc_relay.py b/scripts/oidc_relay.py new file mode 100644 index 0000000..cd5f36a --- /dev/null +++ b/scripts/oidc_relay.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Loopback relay that refreshes a GitHub Actions OIDC JWT for every request. + +The hosted CodeBoarding proxy authenticates each OpenRouter request with a +GitHub OIDC JWT. Those JWTs are deliberately short lived, whereas an analysis +can make requests for longer than a single token's lifetime. This tiny, +stdlib-only relay is started by ``action.yml`` on the runner's loopback +interface. The engine talks to it with a harmless placeholder API key; the +relay obtains a fresh JWT from GitHub and swaps it into the request sent to the +real proxy. + +It is intentionally not a general-purpose proxy: it only binds to 127.0.0.1, +only uses the upstream URL supplied by the action, and never logs credentials. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Mapping +from urllib.error import HTTPError, URLError +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from urllib.request import Request, urlopen + + +_HOP_BY_HOP = { + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +} + + +@dataclass(frozen=True) +class RelayConfig: + upstream_base_url: str + id_token_request_url: str + id_token_request_token: str + license_file: Path | None = None + + +def _with_audience(url: str) -> str: + """Add (or replace) the audience without assuming GitHub's query layout.""" + parsed = urlsplit(url) + query = [(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key != "audience"] + query.append(("audience", "codeboarding-proxy")) + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment)) + + +def _mint_oidc_token(config: RelayConfig) -> str: + request = Request( + _with_audience(config.id_token_request_url), + headers={"Authorization": f"Bearer {config.id_token_request_token}"}, + method="GET", + ) + try: + with urlopen(request, timeout=15) as response: # nosec B310 - GitHub runner URL + payload = json.loads(response.read()) + except (HTTPError, URLError, OSError, ValueError) as exc: + raise RuntimeError("could not mint a GitHub OIDC token") from exc + + token = payload.get("value") if isinstance(payload, dict) else None + if not isinstance(token, str) or not token.strip(): + raise RuntimeError("GitHub returned an empty OIDC token") + return token.strip() + + +def _authorization(config: RelayConfig) -> str: + token = _mint_oidc_token(config) + if config.license_file is not None: + try: + license_key = config.license_file.read_text(encoding="utf-8").strip() + except OSError as exc: + raise RuntimeError("could not read CodeBoarding license") from exc + if not license_key: + raise RuntimeError("CodeBoarding license is empty") + token = f"{token}~codeboarding-license~{license_key}" + return f"Bearer {token}" + + +def _upstream_url(base_url: str, path: str) -> str: + return base_url.rstrip("/") + (path if path.startswith("/") else f"/{path}") + + +class _RelayHandler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, _format: str, *_args: object) -> None: + # Request headers contain bearer material. Keep Action logs credential-free. + return + + @property + def config(self) -> RelayConfig: + return self.server.config # type: ignore[attr-defined] + + def _request_body(self) -> bytes: + try: + length = int(self.headers.get("content-length", "0")) + except ValueError: + length = 0 + return self.rfile.read(max(0, length)) + + def _send(self, status: int, headers: Mapping[str, str], body: bytes) -> None: + self.send_response(status) + for key, value in headers.items(): + if key.lower() not in _HOP_BY_HOP: + self.send_header(key, value) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if body: + self.wfile.write(body) + + def _handle(self) -> None: + try: + headers = { + key: value + for key, value in self.headers.items() + if key.lower() not in _HOP_BY_HOP and key.lower() != "authorization" + } + headers["Authorization"] = _authorization(self.config) + request = Request( + _upstream_url(self.config.upstream_base_url, self.path), + data=self._request_body(), + headers=headers, + method=self.command, + ) + try: + with urlopen(request, timeout=310) as response: # nosec B310 - configured proxy URL + self._send(response.status, dict(response.headers.items()), response.read()) + except HTTPError as response: + self._send(response.code, dict(response.headers.items()), response.read()) + except (RuntimeError, URLError, OSError) as exc: + body = json.dumps({"error": {"message": "CodeBoarding OIDC relay request failed."}}).encode() + self._send(502, {"Content-Type": "application/json"}, body) + print(f"OIDC relay request failed: {exc}", file=sys.stderr) + + do_GET = _handle + do_POST = _handle + do_PUT = _handle + do_PATCH = _handle + do_DELETE = _handle + + +class RelayServer(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + def __init__(self, config: RelayConfig): + super().__init__(("127.0.0.1", 0), _RelayHandler) + self.config = config + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--upstream-base-url", required=True) + parser.add_argument("--ready-file", required=True, type=Path) + parser.add_argument("--license-file", type=Path) + args = parser.parse_args(argv) + + request_url = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL", "") + request_token = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + if not request_url or not request_token: + print("A GitHub OIDC request URL/token is unavailable.", file=sys.stderr) + return 2 + + server = RelayServer(RelayConfig(args.upstream_base_url, request_url, request_token, args.license_file)) + args.ready_file.write_text(str(server.server_port), encoding="utf-8") + try: + server.serve_forever() + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_oidc_relay.py b/tests/test_oidc_relay.py new file mode 100644 index 0000000..5430681 --- /dev/null +++ b/tests/test_oidc_relay.py @@ -0,0 +1,108 @@ +"""Tests for the stdlib loopback relay used by the hosted OIDC tier.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.request import Request, urlopen + + +_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "oidc_relay.py" +_SPEC = importlib.util.spec_from_file_location("oidc_relay", _SCRIPT) +assert _SPEC and _SPEC.loader +oidc_relay = importlib.util.module_from_spec(_SPEC) +sys.modules[_SPEC.name] = oidc_relay +_SPEC.loader.exec_module(oidc_relay) + + +class _Server: + def __init__(self, handler): + self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + + @property + def url(self): + return f"http://127.0.0.1:{self.server.server_port}" + + def start(self): + self.thread.start() + + def close(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=2) + + +class TestOidcRelay(unittest.TestCase): + def test_each_forwarded_request_mints_a_fresh_token(self): + issued_tokens = [] + received = [] + + class OidcIssuer(BaseHTTPRequestHandler): + def do_GET(self): + issued_tokens.append(self.path) + payload = json.dumps({"value": f"jwt-{len(issued_tokens)}"}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *_args): + pass + + class Upstream(BaseHTTPRequestHandler): + def do_POST(self): + received.append( + (self.path, self.headers.get("Authorization"), self.rfile.read(int(self.headers["Content-Length"]))) + ) + payload = b'{"ok":true}' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *_args): + pass + + issuer = _Server(OidcIssuer) + upstream = _Server(Upstream) + issuer.start() + upstream.start() + relay = oidc_relay.RelayServer( + oidc_relay.RelayConfig( + upstream_base_url=f"{upstream.url}/api/v1", + id_token_request_url=f"{issuer.url}/token?existing=value", + id_token_request_token="request-token", + ) + ) + relay_thread = threading.Thread(target=relay.serve_forever, daemon=True) + relay_thread.start() + try: + relay_url = f"http://127.0.0.1:{relay.server_port}/chat/completions?model=test" + for _ in range(2): + with urlopen(Request(relay_url, data=b'{"prompt":"hello"}', method="POST")) as response: + self.assertEqual(response.status, 200) + self.assertEqual(response.read(), b'{"ok":true}') + finally: + relay.shutdown() + relay.server_close() + relay_thread.join(timeout=2) + issuer.close() + upstream.close() + + self.assertEqual(issued_tokens, ["/token?existing=value&audience=codeboarding-proxy"] * 2) + self.assertEqual([auth for _, auth, _ in received], ["Bearer jwt-1", "Bearer jwt-2"]) + self.assertEqual([path for path, _, _ in received], ["/api/v1/chat/completions?model=test"] * 2) + + def test_audience_replaces_an_existing_value(self): + self.assertEqual( + oidc_relay._with_audience("https://issuer.example/token?audience=old&x=1"), + "https://issuer.example/token?x=1&audience=codeboarding-proxy", + )