# GitHub Read Relay The relay is the core of Octopool: a single Worker endpoint that performs read-only GitHub requests on behalf of a caller. It serves shared cache hits first, then equivalent token-free reads, and selects a pooled GitHub identity only when needed. Source: `src/relay.ts`, `src/router.ts`, `src/policy.ts`, `src/route-manifest.ts`, `src/github.ts`, `src/github-web.ts`. ## `POST /v1/github/request` Authenticated with a caller bearer token scoped to the target pool (see [Auth](auth.md)). Request body: ```json { "pool": "maintainers", "method": "GET", "path": "/repos/openclaw/openclaw/pulls/123", "query": { "per_page": "100" }, "headers": { "accept": "application/vnd.github+json" }, "route_hint": { "pr_head_sha": "0123456789abcdef0123456789abcdef01234567" } } ``` - `pool`, `method`, `path` are required, non-empty strings. - Only `GET` is enabled. Any other method is rejected with `403 method_denied`. - `query` values are strings or string arrays. Keys are rejected if they look secret-bearing (`token`, `secret`, `password`, `api_key`, …). - `headers` are filtered down to `accept`, `x-github-api-version`, `if-none-match`, `if-modified-since`, `cache-control`. Everything else is dropped. - A `cache-control: max-age=N` request directive bounds acceptable cache staleness: a fresh shared-cache entry older than `N` seconds is treated as a miss and refilled, and the refill writes through to the shared cache, unlike conditional headers, which bypass it. Other `cache-control` directives are ignored, and the header never varies the cache key or reaches GitHub. - `route_hint.pr_head_sha` and closed/merged `route_hint.pr_state` are validated cache discriminators for PR file lists. - Legacy `route_hint.owner`, `route_hint.repo`, `route_hint.kind`, `cache_key`, and `idempotency_key` input remains accepted for wire compatibility but is discarded during validation. It does not enter the trusted request model or affect routing, caching, or policy. ### Path validation `path` must be an absolute GitHub API path. It is rejected (`400 invalid_path`) if it contains `://`, `\`, `?`, `#`, `..`, a bare dot segment, or percent-encoded path traversal (`%2e`, `%5c`). The relay only talks to approved GitHub API, web, raw-content, and patch hosts. ## Response envelope ```json { "status": 200, "headers": { "content-type": "application/json", "etag": "...", "x-ratelimit-remaining": "4998", "x-ratelimit-reset": "1780000000" }, "body": {}, "body_encoding": "json", "identity": { "id": "ghapp_openclaw_openclaw", "kind": "github_app" }, "relay": { "pool": "maintainers", "request_id": "...", "cacheable": true, "cache": "miss", "stale_ok": false, "route_kind": "pr_view", "lease_reason": "highest_remaining" } } ``` - `headers` are filtered to a safe allowlist (content negotiation, caching, rate-limit, request id). Authorization and cookies never leave the Worker. - `body_encoding` is `json`, `text`, or `base64`. Opaque API and public diff/patch responses preserve bytes: invalid UTF-8, a leading UTF-8 BOM, or NUL in the first 1,024 bytes selects base64. Other valid UTF-8 stays text, including literal U+FFFD, CRLF, and later NUL bytes. Empty API responses remain null/text; empty public diff/patch responses remain empty-string/text. Successful `application/json` parsing retains existing JSON value semantics, without promising original JSON bytes or whitespace; malformed JSON falls back to lossless opaque encoding. The response cap applies to upstream bytes before base64 or envelope expansion. - `repo_view` returns a fixed public metadata subset before caching so token-specific repository fields such as identity permissions are not shared. - Release list/latest/tag/id reads and top-level `gh release view` summaries use the anonymous GitHub API, preserving exact raw Markdown in `body` through cache and JSON projection. Rendered HTML is not a source of release bodies. Cache misses consume anonymous API quota; unavailable reads retain bounded exact stale-cache and guarded local-`gh` fallback. Raw API requests retain exact REST response semantics. Octopool does not use pooled credentials for releases, so draft/private release visibility is not shared. - Issue timelines, per-issue events, repository issue-event lists, and individual issue events use only the anonymous API, including conditional requests. A public target repository does not prove that referenced issues or commits are public. Anonymous failures use bounded public stale data or `424 fallback_local` (`web_only_unavailable`); pooled credentials and legacy event cache entries cannot widen visibility. Public JSON and response validators are preserved, and native fallback uses the caller's own credentials. - Machine `gh run list/view --json`, including jobs, uses unshaped exact REST through the shared cache, with lazy verified workflow-name metadata. Human run views and watch retain bounded public-page job/step metadata. Raw `/actions/runs/{id}/jobs` requests retain exact REST response semantics, and log bodies still require authenticated API access. See the [CLI export contract](cli.md) for native defaults, safe-integer limits and acquisition bounds. - Public org repository/member/event reads, user/gist collection reads, global metadata reads, and public repository metadata collections can be served from unauthenticated GitHub API responses before spending pooled identity quota. - `GET /user` is relayed as the caller's public profile: Octopool rewrites it to `GET /users/:login` for the authenticated caller and serves it through the anonymous API, falling through to a pooled identity only when anonymous quota is exhausted. Private `/user` fields (plan, private repo counts, email visibility) are not included; callers that need them fall back to real `gh`. - `GET /orgs/:org` is intentionally not relayed because authenticated GitHub responses can include additional org fields that are not present in unauthenticated public API responses. - `GET /users/:login/starred` and `/subscriptions` are intentionally not relayed because authenticated responses can include private repositories visible to the caller. - `cache` is `hit`, `stale`, `miss`, or `bypass` (conditional, log, large-payload, or otherwise non-cacheable request). - `stale_ok: true` means an expired public cache entry was served because all eligible identities were depleted, cooling down, missing, or rate-limited, or because a token-free-only route lost its public backend (`web_only_unavailable`). `stale_reason` and `cache_expires_at` are included on those responses. - `backend` is present as `web` or `github_public` when a cache miss or identity-less cache hit was served without a pooled API identity. Audit backend separately describes the resource fetch/verifier: anonymous API replacements and `304` validations count as `github_api`; a cache-only hit has no audit backend. - Repository statistics `202` responses are returned unchanged without body caching. Each later poll can reach upstream readiness; old pending entries cannot serve hits, revalidate, or supply outage stale data. A forced pending refresh preserves any ready entry's original lifetime, and unrelated routes keep their existing `202` behavior. - `lease_reason` is `sticky` or `highest_remaining` — see [Identities & routing](identities.md). ## Supported routes Routes are defined in `src/route-manifest.ts` and enforced by `src/policy.ts`. Only the following read-only shapes are enabled. A safe CLI-shaped request outside this set gets `424 fallback_local` with reason `route_denied`, so the shim can delegate to real `gh`: - `user_view` - `user_repo_list` - `user_org_list` - `user_gist_list` - `user_follower_list` - `user_following_list` - `user_event_list` - `user_received_event_list` - `user_key_list` - `user_gpg_key_list` - `org_repo_list` - `org_event_list` - `org_public_member_list` - `org_public_member_view` - `gist_view` - `emoji_list` - `github_meta` - `license_list` - `license_view` - `gitignore_template_list` - `gitignore_template_view` - `repo_view` - `commit_list` - `commit_view` - `commit_view_ref` - `commit_comments` - `commit_pulls` - `commit_branches_where_head` - `commit_statuses` - `commit_statuses_ref` - `repo_comment` - `compare` - `contents` - `repo_readme` - `pr_view` - `pr_list` - `pr_files` - `pr_commits` - `pr_review_comments` - `pr_review_comment_list` - `pr_review_comment_view` - `pr_review_comment_reactions` - `pr_reviews` - `pr_review_view` - `pr_review_comments_for_review` - `pr_requested_reviewers` - `commit_check_runs` - `commit_check_runs_ref` - `commit_check_suites` - `commit_check_suites_ref` - `commit_status` - `commit_status_ref` - `ref_statuses` - `run_list` - `run_view` - `run_jobs` - `run_artifacts` - `job_view` - `job_logs` - `check_run_annotations` - `issue_view` - `issue_list` - `issue_comments` - `issue_comment_list` - `issue_comment_view` - `issue_comment_reactions` - `issue_events` - `issue_event_list` - `issue_event_view` - `issue_labels` - `issue_reactions` - `issue_timeline` - `assignee_list` - `assignee_view` - `label_list` - `label_view` - `milestone_list` - `milestone_view` - `branch_list` - `branch_view` - `branch_protection` - `repo_ruleset_list` - `repo_ruleset_view` - `branch_rules` - `tag_list` - `repo_languages` - `repo_contributors` - `repo_license` - `repo_topics` - `community_profile` - `fork_list` - `stargazer_list` - `subscriber_list` - `deployment_list` - `repo_event_list` - `network_event_list` - `repo_stats_contributors` - `repo_stats_commit_activity` - `repo_stats_code_frequency` - `repo_stats_participation` - `repo_stats_punch_card` - `git_blob` - `git_commit` - `git_tag` - `git_tree` - `git_ref` - `git_matching_refs` - `workflow_list` - `workflow_view` - `workflow_run_list` - `release_list` - `release_latest` - `release_view` - `release_assets` - `release_asset` - `search_issues` - `search_code` - `search_commits` - `search_repositories` - `rate_limit` `job_logs` is a large-payload, log-class route: it follows GitHub's signed redirect to `*.actions.githubusercontent.com` / `*.blob.core.windows.net`, caches immutable logs in R2 for seven days only after the owning run completes, and is gated by the pool's `allow_logs` policy. Cached logs get at most a one-hour zero-contact window before an authenticated existence probe honors upstream deletion; active-run and failed-preflight logs retain the direct-fetch bypass. ### Native protection reads The following exact GET routes are recognized by the canonical manifest but always return `424 fallback_local` with reason `local_credentials_required`. After caller authentication and fresh authoritative string-rewrite and pool-policy checks, the Worker hands them off before cache reads/writes, repository visibility probes, anonymous requests, or pooled credentials. The CLI checks current policy again before dispatching the user's native `gh`. `OCTOPOOL_NO_FALLBACK=1` therefore refuses these reads. ```text GET /repos/{owner}/{repo}/branches/{branch}/protection GET /repos/{owner}/{repo}/branches/{branch}/protection/enforce_admins GET /repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks GET /repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks/contexts GET /repos/{owner}/{repo}/branches/{branch}/protection/required_pull_request_reviews GET /repos/{owner}/{repo}/branches/{branch}/protection/required_signatures GET /repos/{owner}/{repo}/branches/{branch}/protection/restrictions GET /repos/{owner}/{repo}/branches/{branch}/protection/restrictions/apps GET /repos/{owner}/{repo}/branches/{branch}/protection/restrictions/teams GET /repos/{owner}/{repo}/branches/{branch}/protection/restrictions/users GET /repos/{owner}/{repo}/rulesets GET /repos/{owner}/{repo}/rulesets/{id} GET /repos/{owner}/{repo}/rules/branches/{branch} ``` GitHub's [branch-protection API](https://docs.github.com/en/rest/branches/branch-protection) requires Administration repository read permission, including these subresources. The [rules API](https://docs.github.com/en/rest/repos/rules) permits anonymous public reads and otherwise uses Metadata read permission, but ruleset details include `bypass_actors` only when the caller has write access to the ruleset. All these routes conservatively use the caller's native credentials, even for public repositories, preserving complete authenticated response semantics. This does not grant permissions or change GitHub's errors. Applicable branch rules include active rules from repository and higher levels; they exclude disabled/evaluate rulesets and do not require an existing branch. They are not a substitute for reading classic branch protection. Only the listed paths join the strict read allowlist: no org/admin routes, rule suites/history, arbitrary protection suffixes, or mutations. Branch names remain percent-encoded in the native request; strict preparation accepts encoded slashes only in these manifest-owned branch parameters after decoded structural checks. Traversal, unresolved placeholders, structural policy matches, and unsafe headers still fail closed on the modeled path. Safe unmodeled native routes or flags retain the CLI's [best-effort filtering](cli.md#outbound-string-rewrite-protection); the Worker remains GET-only and does not relay those neighboring routes. ## Policy gates `classifyRoute` enforces, per pool: - `allowed_owners` — owners with scoped identity routing. Defaults to `DEFAULT_ALLOWED_OWNERS` (`openclaw`). - `allow_public_repos` — public repositories from other owners are allowed after the public-repo guard proves `private: false` (default `true`). These routes use broad PAT identities from the pool rather than repo-scoped GitHub App installation tokens. - `allow_logs` — log routes require it (default `true`), else `424 fallback_local` with reason `logs_denied`. - `allow_search` — search routes require it (default `false`). Issue, code, and commit searches require exactly one `repo:owner/name` qualifier plus plain terms and optional `type:issue|pr` / `state:open|closed`. Every token must match this grammar: additional, quoted, bare, or malformed repo qualifiers, `OR`/`NOT`, and negated terms are rejected before upstream dispatch or cache reuse. Qualifier names and filter values are lowercase; owner/repository casing and whitespace between tokens are accepted without rewriting the query sent upstream. Repository search keeps its separate plain-term grammar. Invalid queries return `424 fallback_local` with reason `search_denied`. The supported token-free issue-search shape can run with `allow_search: false`, subject to the same grammar and owner/public-repository gates, and never falls through to pooled credentials. Stored policy must be a JSON object. Missing fields, including an explicit `{}`, retain the defaults above; present boolean fields must be booleans and `allowed_owners` must contain only strings. Invalid JSON, roots, or known fields return `503 pool_policy_unavailable` with a generic message, before cache access or pooled identity selection. Authentication and deployment-wide string protection still run first. This configuration error does not authorize native fallback; valid policy denials and caller-owned native reads retain their existing `424 fallback_local` behavior. Valid policies may remain cached in an isolate for 30 seconds after a database edit. Cold and expired lookups reject corrupt storage, and failed parses are never cached as successful configuration; a corrected value can be read on the next lookup. There is no persistent last-known-good policy fallback. When a Cloudflare backend (D1 or the pool Durable Object) rejects work because its request queue backed up, the relay returns `424 fallback_local` with reason `relay_overloaded` (other surfaces report `503 relay_overloaded`) instead of an untyped `internal_error`, so the shim can back off and delegate to real `gh`. Every repo route additionally passes a public-visibility check before a pooled identity or cache entry is used — see [Cache & public-repo guard](cache.md). An eligible wildcard PAT also covers explicitly allowed owners. Missing local bindings do not widen scopes or bypass policy, native-only, private-repository, or token-free release/event boundaries. Credentials are resolved only after identity selection; a classified local configuration failure records shared health and tries another eligible identity. If every selected credential fails locally, the first generic typed `503` is returned without binding names or secret contents, rather than serving stale bytes. The existing clean anonymous local fallback is preserved when opportunistic pooling cannot help; string-protection denials and credential-feedback infrastructure failures still propagate. See [identities](identities.md) for per-observation cooldowns, cached App token prerequisites, and mixed-version method availability. An aggregate already in progress does not restart or splice pages from another identity if a later App refresh lacks credentials. It refuses the incomplete result without partial publication or new local credential health; page-fetch and refresh string-protection denials remain hard `403` failures. The complete list of relay paths eligible for anonymous API or public web/raw/Git transport is in [Token-Free GitHub Endpoints](token-free.md). `route_hint.pr_head_sha` and `route_hint.pr_state` are validated, optional cache discriminators for PR file lists. They do not bypass policy or visibility checks; they only let clients that already know current PR state keep `/files` cache entries separate across head SHAs or closed/merged state. ## Safety limits - Redirects from `api.github.com` are denied (`502 github_redirect_denied`) except the log-download flow above. - Response bodies on every route use the single `MAX_RESPONSE_BYTES` cap (2 MiB default; the hosted deployment sets 4 MiB). Over-cap responses return `424 fallback_local` with reason `github_response_too_large` so callers can retry with local credentials. - Requests time out after `REQUEST_TIMEOUT_MS` (15s default). ## Audit Every validated request from an authenticated caller to an existing pool writes an `audit_events` row with request id, caller, pool, route key, route kind, identity id, status, error code, bounded backend classification, and duration. Backend values distinguish public web pages from anonymous GitHub API and pooled-identity responses without storing URLs, query values, request bodies, or credentials. Parse, authentication, and pool-lookup failures occur before the audit boundary. Audit writes happen via `ctx.waitUntil` and never block the response. The hourly maintenance task deletes audit rows older than 30 days in bounded batches, matching the maximum stats query window.