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.
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.- REST stays
GET-only. The sole caller-supplied POST read is the validated repository GraphQL envelope below; other methods/paths are 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, andx-octopool-public-shape. 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. - For REST GET reads,
gh api --cache <duration>(or--cache=<duration>) maps a positive Go duration tocache-control: max-age=N, truncated to whole seconds:60sbecomesmax-age=60,5mbecomesmax-age=300, and500msbecomesmax-age=0. An explicit Cache-Control header wins. This caller cache policy also overrides the live-read default for--hostname github.com. Invalid, zero, or negative durations, non-GET REST requests, and/rate_limitreads with--cachestay on native gh, preserving its errors and the caller's own rate-limit budget. Repository GraphQL reads accept the flag but retain their existing cache policy; fixed GraphQL landing reads use the same maximum-age mapping as REST. 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 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.
{
"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.- Weighted
Acceptheaders (withq=parameters) use the GitHub API instead of the public diff/patch shortcut, so upstream selects the requested representation. 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).cache_created_at, when present, is the stored representation's GitHub observation time as an ISO 8601 UTC string. REST hits, including SWR stale entries and coalesced followers, retain their source entry'screated_at; a run projected from a REST list retains the list's time. Merged REST job pages retain the first (oldest) page's observation time. Job projections requiring a separate run proof use the older of the job and proof times. Public/web projections without REST provenance omit it because the oldest source time is not retained. Fresh misses and direct revalidation responses also omit it. Clients must treat absence as unknown, and a coalesced response does not imply that this request started the fetch.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.
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_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_refcheck_suite_viewcheck_suite_check_runscommit_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
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.
| GET route | Audit route kind | Query parameters |
|---|---|---|
/repos/{owner}/{repo}/check-suites/{id} |
check_suite_view |
None |
/repos/{owner}/{repo}/check-suites/{id}/check-runs |
check_suite_check_runs |
check_name, status, filter, per_page, page |
Both routes use the normal owner policy, public-repository proof and pooled identity
gates. Other methods, unknown query keys (including app_id) and repeated query
values are refused. Suite IDs are numeric; rerun and other write endpoints stay native.
The CLI accepts the same safe headers as other REST reads: Accept,
X-GitHub-Api-Version, If-None-Match, If-Modified-Since, and Cache-Control,
with case-insensitive names and -H (separate or attached), --header, or --header= syntax.
Suite detail and suite check-run lists stay fresh for 60 seconds while active or
missing trustworthy timestamps. Completed suites and nonempty, fully completed
lists scale freshness to 10% of the newest completion/update age, bounded to 60–300
seconds. Reruns can change both routes, so they never receive the six-hour terminal
job/attempt TTL or day-long terminal retention. They retain the ordinary five-minute
outage stale window and 60-second stale-while-revalidate window; explicit caller age
bounds still apply and disable SWR. Omitted filter and filter=latest share list cache
entries, while filter=all stays separate.
gh api check-run lists support --paginate and --paginate --slurp through the
existing ten-page relay limit, preserving native object pages and the slurped
array-of-pages shape. Totals must remain consistent across pages, including pages
followed through GitHub Link headers; incomplete or changing responses fall back
before printing partial output. Explicit --hostname github.com retains the existing
live-read default unless Cache-Control or --cache specifies a cache policy.
GET /repos/{owner}/{repo}/pulls/{number} supports five additional
x-octopool-public-shape values. The Worker constructs the exact upstream query
from its allowlist in src/github-public-shapes.ts; these shapes do not accept caller GraphQL.
The existing pr_view owner routes these projections through the pool's graphql
budget instead of REST's core budget.
| Shape | Public response | Query parameters |
|---|---|---|
pr-ci-summary-v1 |
PR state, mergeability, head, rollup state and check/status counts | None |
pr-ci-rollup-v1 |
PR state, mergeability, head and up to 100 rollup contexts, including check suite/workflow IDs | Optional cursor |
pr-merge-snapshot-v1 |
Repository identity, main ref, PR identity/head/base, mergeability, merge commit and auto-merge/queue state | None |
pr-comments-v1 |
PR identity and up to 100 public comments, without viewer fields | Optional cursor |
pr-commits-v1 |
PR identity/head and up to 100 commits with the first 100 authors | Optional cursor |
The envelope's body is the unchanged GraphQL JSON response, including data and
any errors. Successful non-null PR responses cache for 60 seconds without stale
fallback; cache-control: max-age=0 refreshes one projection through the pool.
GraphQL errors keep their upstream HTTP status and never populate the body cache.
Consumers must inspect errors even on HTTP 200, as the CLI does.
The PR-view CLI reads comment and commit projections live and reconstructs the
native export contract. Reviews remain native because pending
reviews depend on the requesting account.
Enabled autoMergeRequest exports stay on native gh with caller credentials.
Pooled credentials may have maintainer permissions; authorEmail and enabledBy
are not proven visible to every caller and must not enter shared relay responses.
An explicit REST auto_merge: null still exports null; missing data falls back.
The smaller pr-merge-snapshot-v1 selects only autoMergeRequest.mergeMethod;
it cannot supply the complete native auto-merge export. The CI summary and rollup
shapes likewise cannot supply that export. PR-view mergeStateStatus
reuses the existing live landing snapshot, preserving the GraphQL enum instead of
inferring it from REST mergeable_state, with the same head consistency checks.
Only default JSON media is accepted. Conditional validators, extra query parameters,
non-scalar/empty cursors, cursors longer than 512 characters or containing control
characters, and cursors on other shapes are refused before upstream dispatch. The
public-repository guard, caller/pool policy, string protection, response caps and
pooled identity eligibility still apply. There is no anonymous REST or page substitute
for these GraphQL projections. viewerMergeBodyText and mutations remain native.
Other eligible repository queries use the separate scoped-token route below.
POST /v1/github/request also accepts this envelope:
{
"pool": "maintainers",
"method": "POST",
"path": "/graphql",
"graphql": {
"query": "query($owner:String!,$name:String!,$pr:Int!){repository(owner:$owner,name:$name){pullRequest(number:$pr){state mergeable headRefOid}}}",
"variables": { "owner": "openclaw", "name": "openclaw", "pr": 42 }
},
"headers": { "cache-control": "max-age=0" }
}The Worker parses the document with graphql-js before dispatch. Exactly one query
operation must select one or more repository(owner:,name:) roots, optionally aliased,
plus optional __typename fields. Every root must resolve to the identical owner/name,
compared case-insensitively; each argument retains the character and length checks.
Owner/name must be string literals or supplied string variables; variable defaults do not
establish scope. Optional operationName
must match the sole operation. Fragments are expanded for root and depth checks;
missing, duplicate, cyclic, or excessively expanded fragments are refused. All field
names, including unused fragments, are checked for viewer*, mergeStateStatus,
__schema, and __type.
Only @include and @skip directives are accepted. Other roots (including viewer,
rateLimit, node, nodes, search, organization/user and enterprise lookups),
mutations, subscriptions, and schema definitions are refused. The CLI can remove one
top-level viewer { login } (optionally aliased) after proving the active native gh
github.com config user equals Octopool's saved login, without token overrides or
ambiguous config. It restores that object locally in document order, preserves errors,
and leaves null data unchanged. Viewer selections with arguments, directives, fragments,
or additional fields stay native. The Worker never receives or answers viewer fields.
Generic mergeStateStatus selections return 424 fallback_local before backend or
cache work. The scoped App can report a different merge state than native caller
credentials; aliases and batches do not change that boundary. Fixed public landing
projections use their separate allowlist and pooled credential contract.
Query and variables are independently capped at 16 KiB; selection and JSON variable
depth are capped at 12, with 4,000 document tokens and expanded selections. Variables
may contain JSON scalars, lists, or objects. Only default JSON Accept and cache-control
headers are supported; query-string fields and conditional validators are refused.
The owner must occur in allowed_owners, even when allow_public_repos is enabled.
The existing public-repository proof guard must authorize the repository before any
response is served or published. On pooled misses, App token acquisition and the read
may overlap this guard for the proof owner after fresh negative proofs are rejected. Private or unknown visibility returns
424 fallback_local (repo_not_public / repo_public_check_failed).
The credential is the primary security boundary. Only active, route-eligible GitHub
App identities can execute these queries. The Worker verifies the installation account
matches the repository owner, then mints with repositories: [name] and an explicit
read-only subset of granted metadata, contents, pull_requests, issues, actions,
checks, and statuses permissions. Organization and other permissions are excluded.
The mint response must confirm exactly that repository with private: false and those
read-only permissions. Since every root resolves to that same repository, this check
covers all aliases; the GraphQL body itself need not select a privacy field. A mismatched
root, unverified visibility, or invalid mint response fails closed for the entire batch.
Nested traversal such as owner.repositories or author.repositories can therefore
see only the selected repository and public GitHub data, not unrelated private org
repositories. AST restrictions are defense in depth, not the confinement mechanism.
The normal public-proof lifetime still bounds detection of a repository becoming private.
Scoped tokens use a separate bounded per-repository memory cache, refreshed at least
60 seconds before expiry. Installation-wide tokens and PATs are never substituted.
Missing access, invalid scope/permissions, missing credentials, or mint failure returns
424 fallback_local with github_app_repo_token_unavailable; unavailable App candidates
use the existing pool fallback reasons. A query needing an ungranted permission retains
GitHub's GraphQL errors. Only https://api.github.com/graphql receives the query POST;
redirects are denied. Installation metadata/token exchange remains internal auth traffic.
The response uses lossless body_encoding: "text" JSON bytes through the ordinary
envelope, preserving whitespace, escape spelling, large numbers, and HTTP-200 errors.
The existing sanitation policy still applies; a body requiring sanitation is re-encoded.
Response caps, timeouts, authoritative string protection (including decoded GraphQL
string literals and variable values), backend-work admission, and audit remain enforced.
Audit uses route_kind: graphql_read, route_key: POST /graphql repository-read, the App
identity, and requested_max_age. Rate headers update the App's GraphQL resource;
HTTP-200 secondary-limit errors also enter the identity cooldown without changing the body.
The pool/repository, canonically printed AST, recursively sorted variables, operation
name, and source App identity partition cache entries. Error-free JSON data caches for
60 seconds with no stale fallback. max-age=0 always fetches upstream; positive maximum
ages allow bounded reuse and identical misses use existing coalescing/publication ownership.
The CLI defaults this route to max-age=20, including explicit GitHub.com host reads.
Explicit Cache-Control headers retain their bound; max-age=0 or OCTOPOOL_FRESH=1
forces live reads. Viewer insertion happens locally after cache retrieval, so the shared
body contains only repository results and optional __typename fields. The existing
exact landing projections retain their live defaults.
Upgrade both Worker and CLI; older Workers trigger guarded native fallback. No schema
migration, permission expansion, or cache purge is required.
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.
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
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.
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 and the scoped search allowlist. Validation runs before upstream dispatch or cache reuse. 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 its existing narrower grammar and the same 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.
Recognized transient D1 and Durable Object failures during relay reads return
424 fallback_local with details.reason: relay_storage_unavailable. Signals include
Cloudflare's network-loss, reset, storage-timeout and overload messages, and DO errors
with retryable === true or overloaded === true. The shim delegates to native gh
without a CLI upgrade. Original errors remain in Workers Logs; authenticated relay audit
rows record error_code: fallback_local and fallback_reason: relay_storage_unavailable
when the audit write succeeds. Other runtime messages matching the existing is overloaded
or queued for too long matcher retain 424 fallback_local with reason relay_overloaded.
Unknown errors outside admission RPCs retain 500 internal_error; GitHub
responses, explicit authentication/policy errors, admin endpoints and write rejection
retain their existing behavior. This mapping alone adds no storage retries.
When a client's backend-work allowance is full or its permit expires, the relay returns
424 fallback_local with reason relay_overloaded. The default is sixteen concurrent backend-work requests per authenticated caller/client
in each pool, configurable with CLIENT_BACKEND_CONCURRENCY. At most eight background
refreshes share this count, leaving room for the CLI's eight foreground slots. Fresh cache-only hits bypass
admission, including eligible identity-cache entries; misses, revalidations, and live
probes require a permit. See backend-work admission
for lease deadlines, cancellation, upgrade requirements, and client-attributed stats.
Admission acquisition retries an untyped RPC failure once after 50–150 ms of jitter,
bounded by the request signal and backend lease/deadline, using the same permit ID.
Work never starts without an acknowledged grant. Repeated acquisition failures and
renewal RPC failures use relay_storage_unavailable; renewal failures stop active work,
and release remains best-effort. One structured octopool.worker.admission_unavailable
warning per failed RPC operation records attempts and retry success without error messages.
Background SWR refreshes use the same handling without affecting the served response;
deferred log proof uses its separate bounded scope without caller admission.
Every repo route additionally passes a public-visibility check before a response is served
or published. On pooled identity misses, only the proof owner overlaps this check with
its upstream read after rejecting fresh negative proofs — 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.
Issue, PR, code, and commit searches accept one unquoted, positive repo:owner/name;
each component must match [A-Za-z0-9_.-]+. The repository still passes the existing
owner policy and identity eligibility checks before dispatch, and public-visibility proof
before serving or publishing results. Only the proof owner's pooled miss read may overlap
that proof; followers wait for its verdict.
is:public only narrows the query; it never
replaces that proof.
Queries are limited to 4,096 characters and 128 tokens. The tokenizer respects double
quotes and separates tokens on spaces, tabs, CR, or LF outside quotes. Qualifier names
and enumerated values are lowercase; login, name, ref, phrase, and repository casing
is preserved. The full original q, including all filters and phrases, is forwarded
unchanged and included in the cache key, along with sort/order/pagination parameters.
| Qualifier | Allowed values |
|---|---|
type, state |
issue / pr; open / closed, respectively |
is |
open, closed, merged, unmerged, issue, pr, draft, locked, unlocked, public |
author, assignee, mentions, commenter, involves, reviewed-by, review-requested |
[A-Za-z0-9][A-Za-z0-9-]{0,38}, optionally prefixed with app/; @me is rejected because it would resolve to the pooled identity |
label, milestone |
[A-Za-z0-9_./-]+ or a nonblank double-quoted name with spaces and simple punctuation |
no |
label, milestone, assignee |
in |
title, body, comments, or comma-separated lists of these values |
created, updated, closed, merged |
YYYY-MM-DD, optionally prefixed with >, >=, <, <=, or YYYY-MM-DD..YYYY-MM-DD; month/day components are bounded to 01–12 / 01–31 |
draft |
true, false |
review |
none, required, approved, changes_requested |
status |
pending, success, failure |
base, head |
[A-Za-z0-9._/-]+ (no colon) |
sort |
created, updated, comments, reactions, optionally suffixed with -asc or -desc |
Plain terms retain [A-Za-z0-9_.-]+. Double-quoted phrases and names allow printable
ASCII, including parentheses inside quotes (for example "(batch"), but no internal
quotes, colons, backslashes, or blank-only values. One leading - may negate plain
terms or the non-scope qualifiers above; negated phrases and -is:public are rejected.
Everything outside this allowlist is denied, including missing/duplicate/negated repo:,
org:, user:, owner:, is:private, @me logins, unquoted OR/AND/NOT in any
case, grouping parentheses, malformed or escaped quotes, and unknown qualifiers.
The allow_search: false exception retains the existing issue-search-v1 grammar:
one repo, one type:issue|pr, at most one state:open|closed, and plain terms on the
first page, without sort/order parameters or these additional filters.
The raw gh api search/issues path forwards this syntax through query parameters or
explicit --method=GET -f q=... fields. Top-level gh search issues|prs accepts these
qualifiers and quoted phrases as positional arguments, with one --repo/-R scope.
Its --author, --assignee, --label, and custom sort/match flags still use native gh.
JSON gh pr list --search and gh issue list --search use GET /search/issues with
the same client-side term validation, adding the repository, entity type, state, and
supported author/assignee/label flags. Unsupported queries are rejected locally before
relay dispatch. List exports preserve native uppercase states, nullable close/merge
timestamps, label objects, and GraphQL author objects; author names use cacheable user
profile reads. mergedAt comes only from pull_request.merged_at, and PR draft status
comes from draft. PR createdAt stays native because search returns the issue's
creation time, which can differ from the PR's. Head/base refs, files,
assignees/milestone exports, and other fields
not faithfully represented by this list-search projection remain native, as do human
output, empty searches, and limits above 100.
Search reads use one page with per_page equal to the requested limit (default 30),
preserve GitHub's default best-match ordering unless the query contains a sort qualifier,
and remain cacheable unless OCTOPOOL_FRESH=1 is set. Richer queries require pooled
search to be enabled and a Worker with the matching grammar (including comma-list in:
support). A search_denied response or a confirmed upstream search rate limit uses
guarded local fallback, respecting OCTOPOOL_NO_FALLBACK; authentication and outbound
protection failures remain terminal. Each pooled token has GitHub's separate search
quota of 30 requests per minute.
- 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).
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.