Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 47 additions & 36 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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."
Expand All @@ -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 `<jwt>~codeboarding-license~<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

Expand Down Expand Up @@ -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'
Expand Down
187 changes: 187 additions & 0 deletions scripts/oidc_relay.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading