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).
Request body:
{
"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,pathare required, non-empty strings.- Only
GETis enabled. Any other method is rejected with403 method_denied. queryvalues are strings or string arrays. Keys are rejected if they look secret-bearing (token,secret,password,api_key, …).headersare filtered down toaccept,x-github-api-version,if-none-match,if-modified-since,cache-control. Everything else is dropped.- A
cache-control: max-age=Nrequest directive bounds acceptable cache staleness: a fresh shared-cache entry older thanNseconds is treated as a miss and refilled, and the refill writes through to the shared cache, unlike conditional headers, which bypass it. Othercache-controldirectives are ignored, and the header never varies the cache key or reaches GitHub. route_hint.pr_head_shaand closed/mergedroute_hint.pr_stateare validated cache discriminators for PR file lists.- Legacy
route_hint.owner,route_hint.repo,route_hint.kind,cache_key, andidempotency_keyinput 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
{
"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"
}
}
headersare filtered to a safe allowlist (content negotiation, caching, rate-limit, request id). Authorization and cookies never leave the Worker.body_encodingisjson,text, orbase64. 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. Successfulapplication/jsonparsing 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_viewreturns 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 viewsummaries use the anonymous GitHub API, preserving exact raw Markdown inbodythrough 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-ghfallback. 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}/jobsrequests retain exact REST response semantics, and log bodies still require authenticated API access. See the CLI export contract 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 /useris relayed as the caller's public profile: Octopool rewrites it toGET /users/:loginfor the authenticated caller and serves it through the anonymous API, falling through to a pooled identity only when anonymous quota is exhausted. Private/userfields (plan, private repo counts, email visibility) are not included; callers that need them fall back to realgh.GET /orgs/:orgis intentionally not relayed because authenticated GitHub responses can include additional org fields that are not present in unauthenticated public API responses.GET /users/:login/starredand/subscriptionsare intentionally not relayed because authenticated responses can include private repositories visible to the caller.cacheishit,stale,miss, orbypass(conditional, log, large-payload, or otherwise non-cacheable request).stale_ok: truemeans 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_reasonandcache_expires_atare included on those responses.backendis present asweborgithub_publicwhen 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 and304validations count asgithub_api; a cache-only hit has no audit backend.- Repository statistics
202responses 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 existing202behavior. lease_reasonisstickyorhighest_remaining— see Identities & routing.
#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:
<!-- supported-route-kinds:start -->
user_viewuser_repo_listuser_org_listuser_gist_listuser_follower_listuser_following_listuser_event_listuser_received_event_listuser_key_listuser_gpg_key_listorg_repo_listorg_event_listorg_public_member_listorg_public_member_viewgist_viewemoji_listgithub_metalicense_listlicense_viewgitignore_template_listgitignore_template_viewrepo_viewcommit_listcommit_viewcommit_view_refcommit_commentscommit_pullscommit_branches_where_headcommit_statusescommit_statuses_refrepo_commentcomparecontentsrepo_readmepr_viewpr_listpr_filespr_commitspr_review_commentspr_review_comment_listpr_review_comment_viewpr_review_comment_reactionspr_reviewspr_review_viewpr_review_comments_for_reviewpr_requested_reviewerscommit_check_runscommit_check_runs_refcommit_check_suitescommit_check_suites_refcommit_statuscommit_status_refref_statusesrun_listrun_viewrun_jobsrun_artifactsjob_viewjob_logscheck_run_annotationsissue_viewissue_listissue_commentsissue_comment_listissue_comment_viewissue_comment_reactionsissue_eventsissue_event_listissue_event_viewissue_labelsissue_reactionsissue_timelineassignee_listassignee_viewlabel_listlabel_viewmilestone_listmilestone_viewbranch_listbranch_viewbranch_protectionrepo_ruleset_listrepo_ruleset_viewbranch_rulestag_listrepo_languagesrepo_contributorsrepo_licenserepo_topicscommunity_profilefork_liststargazer_listsubscriber_listdeployment_listrepo_event_listnetwork_event_listrepo_stats_contributorsrepo_stats_commit_activityrepo_stats_code_frequencyrepo_stats_participationrepo_stats_punch_cardgit_blobgit_commitgit_taggit_treegit_refgit_matching_refsworkflow_listworkflow_viewworkflow_run_listrelease_listrelease_latestrelease_viewrelease_assetsrelease_assetsearch_issuessearch_codesearch_commitssearch_repositoriesrate_limit
<!-- supported-route-kinds:end -->
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.
<!-- native-read-routes:start -->
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}
<!-- native-read-routes:end -->
GitHub's branch-protection API requires Administration repository read permission, including these subresources. The rules API 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; 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 toDEFAULT_ALLOWED_OWNERS(openclaw).allow_public_repos— public repositories from other owners are allowed after the public-repo guard provesprivate: false(defaulttrue). These routes use broad PAT identities from the pool rather than repo-scoped GitHub App installation tokens.allow_logs— log routes require it (defaulttrue), else424 fallback_localwith reasonlogs_denied.allow_search— search routes require it (defaultfalse). Issue, code, and commit searches require exactly onerepo:owner/namequalifier plus plain terms and optionaltype: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 return424 fallback_localwith reasonsearch_denied. The supported token-free issue-search shape can run withallow_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. 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 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.
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.comare denied (502 github_redirect_denied) except the log-download flow above. - Response bodies on every route use the single
MAX_RESPONSE_BYTEScap (2 MiB default; the hosted deployment sets 4 MiB). Over-cap responses return424 fallback_localwith reasongithub_response_too_largeso 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.