Skip to content
This text was generated by an LLM (how cgwalters uses LLMs). cgwalters has not reviewed it yet.

Inference through a proxy

The agent needs a model and must not hold the credential that pays for it: a credential in the sandbox is one a prompt injection can send somewhere, and one with no spending cap. So inference goes through a proxy that holds the provider credential. The agent gets the proxy's URL and a token for this run. The proxy checks the token, adds the real credential, forwards the request and counts what the run uses; when the run is ended its token admits nothing more.

The proxy is either praxis-credential-broker, which its operator runs and which the agent's runner has to reach (how to run one is in its README), or, for a caller with only a provider's API key, one that run serves itself (below). The agent sees the same thing either way: an address and a token for the run. The README sums up what each needs.

tests/inference.rs exercises registration with a mock proxy. Real-agent deployment evidence is reported in the README. This page describes the client protocol, not the broker's current deployment.

What run does

The proxy is the [inference] table of the binary's configuration. url is its root, http or https; without it there is no proxy and the rest of the table is refused. anthropic-url and openai-url say where it serves each API, by default URL/anthropic and URL/v1: Claude Code is given the first and opencode the second. Vibe uses URL/mistral/v1/chat/completions, with the same run token; its route is not selected by openai-url. register says how the run gets its token, and has no default. A proxy without it is refused, and so is a key that belongs to another mode:

  • github-oidc asks GitHub for the job's identity token, made out to audience, and sends it as the bearer of POST /v1/runs. The job needs id-token: write, or an earlier one with it registers: agentic-job register writes the run token to its step's output, and run given it in AGENTIC_JOB_INFERENCE_RUN_TOKEN takes the run over once GET /v1/runs/self says it is active, and counts and ends it as its own (the run token). The reusable workflow does that, from a job of agentic-job.yml, so the broker's called_workflows entries admit it as before. This is the only identity token the binary knows how to get; another forge needs another proof. Neither request takes the job's HTTP_PROXY, since a proxy would see the tokens. On a host that reaches the CI system only through a proxy, identity-proxy names an http or https CONNECT proxy for the identity-token request alone: it is refused unless that request's URL is https, so the proxy relays TLS it cannot read. POST /v1/runs still goes directly.
  • plain sends a name and no proof: below. Either of these two can have run stand in front of the broker: front.
  • token-file reads a token from the absolute path token-file. There is no run API then: nothing counts the run's requests and nothing ends it, so a cap on requests is refused.
  • api-key has no broker: url is the provider's API root, provider says which API, and run serves the proxy itself (below).

The reusable workflow passes github-oidc unless its caller names another (workflow.md).

The first two speak praxis-credential-broker's run API; which jobs may register is the broker's run-token policy, whose default audience is praxis-credential-broker. run clones the target first and registers second, so a clone that fails leaves no registration behind. It expects 201 with a token and a usage record. A try that gets no answer, a 429 or a 5xx is repeated, four tries three seconds apart, and stops if the run is told to stop; any other answer fails at once. A 404 is such a failure, not a proxy that needs no token. A run that cannot register never started (exit state 4). run warns if the run expires at the proxy before the agent's timeout.

The token stays in memory and goes into one file of the sandbox user's (layout.md). While the agent works run reads GET /v1/runs/self every five seconds; the record's requests plus unmetered is the count the request cap reads. A count that cannot be fetched leaves the last one for five failed polls. After six consecutive failures the count becomes unavailable and a session with max-requests stops; a session without that optional cap continues. Each poll takes at most 30 seconds followed by a five-second wait, so an outage is detected within 210 seconds of the last successful poll (plus scheduling delay). This prevents missing accounting from silently disabling the request cap; it is not a broker-side hard spending limit.

Whatever way the run ends, run then sends DELETE /v1/runs/self, with the same retries. A 401 means the proxy no longer knows the token, which ends it without a usage record. If the run cannot be ended its token may still be live, and nothing it produced is left to upload. Under token-file there is nothing to end and the upload goes ahead.

All of these requests go straight to the proxy, ignoring HTTP_PROXY, and follow no redirect.

The agent's own requests go through the egress proxy unless egress.direct opens the broker. The proxy resolves names with the host's resolver, but the rules for its uid refuse it DNS to any but the configured resolvers, and loopback, private, shared (the tailnet) and link-local addresses: PROXY_DENIED_V4 and PROXY_DENIED_V6 in crates/secure-host/src/network.rs. A broker named by an in-cluster or tailnet name that the proxy would have to reach therefore gets a 502, though root on the host reaches it (#1810). Give the broker by its tailnet IPv4 address, so that egress.direct opens it, or pass that address as inference-address (workflow.md).

The host and port of url, anthropic-url and openai-url (host:port) are redacted from the run's logs and files as its secrets are, except under api-key, whose URL is the provider's public one. A URL that names no port is written without one, and is not matched: give the gateway's port explicitly to have it redacted. They are no credential, but the address of a gateway on a private network, and an agent's error names it: Claude Code's 529 message did, in a public job log (#1860). As with every redacted string, a published file that still holds one after the redaction, such as a patch, stops the upload.

Registering without proof

plain is for a client with no identity token. The request is POST /v1/runs with no Authorization, no body, and the header x-run-id: NAME, 1 to 128 letters, digits, ., _ and -. The broker refuses a slash or a colon, so run joins the run's identifier and attempt from --meta with a hyphen, and refuses a name that does not fit before sending it. The reply is 201 {"token": "praxis-run-...", "usage": {...}} whose record says "proof": "none"; run does not read that field.

The broker registers a run this way only if its operator's policy file has an unproven section. That is absent by default. A broker without it answers 401; run then says the policy must admit unproven runs.

A name stays taken until a day after its run expires, and asking for it again is a 409, which run does not retry. Within one registration, a try that got no answer may have taken the name, so the next asks for NAME.retry1; the run the lost reply registered is left at the broker, since nobody holds its token. Starting run again with the same --meta asks for the same name and fails. The other refusals: 429 and 503 are retried as above, which will not outlast a quota's window; 400, and 403 (the request carried Origin or Sec-Fetch-Site, which run does not set), fail at once with the status and the start of the broker's text.

Without front, the per-run cap under plain is a convention. The sandbox user must reach the proxy to work, so the agent can register a run of its own and get a fresh token with a fresh cap, and the session's request cap reads only the run the job registered. What still bounds spending is the broker's cap on all runs together, its quota max_registrations of unproven registrations per window (an hour by default), shared by all callers, the session's timeout and the end of the job. With front, the agent cannot reach the broker, so only the job's own processes can register: plain is then safe for a trusted caller whose broker listener only job runners can reach, and OIDC registration is optional rather than required.

Fronting the broker

front = true in [inference], for github-oidc and plain, is off by default. With it, run serves the agent the loopback proxy of api-key, with the broker as its upstream: the same request filter, content-block allowlist, credential stripping and hard 403 past max-requests, counted at admission. The agent is given that proxy's address, with the broker's Anthropic, OpenAI and Mistral routes under /anthropic, /openai and /mistral/v1, and a token made for the run. run adds the run token itself, so the agent never holds it. The broker's run API is not forwarded.

The sandbox user must not reach the broker, by either of its two ways out:

  • Around the egress proxy. agentic-job config does not default egress.direct to the broker, and the check refuses an egress.direct entry that opens any of its URLs, as well as egress.proxy = false.
  • Through the egress proxy. front needs the Rust proxy (egress.rust-proxy, the default): sandbox setup starts it with --refuse for each of the broker's hosts and ports, and it refuses the sandbox user any request or CONNECT there, by any method and whatever its policy says. It refuses the broker's hosts at every port, by name or an address in any spelling, and every connection it would open to one of their addresses: another name for the broker, or one that resolves to it only later, is refused too. Its 403 says so in X-Egress-Denied. Setup and run also refuse an egress policy that names the broker's host in a [[write]] rule or under an [egress] allow domain. front does not go with egress.write-policy, under which run's own requests to a broker on port 80 or 443 would need such a rule. A broker in egress.direct needs none: the write policy lets the runner's user reach it at its address and port.

run resolves the broker's names once, when it fronts the run, and its proxy then connects to those addresses alone. Before the agent starts it checks that none of those addresses is on loopback or in egress.direct, whether it answers now or not, and probes that:

  • the sandbox user cannot open a TCP connection to any of them that run reaches;
  • the egress proxy refuses a GET and a POST of /v1/runs, and a CONNECT, to each of the broker's names and addresses, with its own 403. With podman, a container of [sandbox.check] container-image, as a subordinate uid, is refused the GET and POST too;
  • no process the sandbox user can read holds the run token.

The controls are that the broker resolved, that run reaches it and that the sandbox user reaches run's proxy. Any failure stops the run. front needs a sandbox user: --runner-home refuses it.

The request cap reads the higher of the broker's count and the proxy's. Ending the run closes the proxy's port first, then ends the run at the broker. Under front the proxy listens on loopback port 3130, the same for every run, and Claude Code's managed settings pin ANTHROPIC_BASE_URL to it: a target repository's .claude/settings.json cannot point Claude Code elsewhere. A run whose port is taken stops.

Which workflows a broker admits

Under github-oidc the agent cannot register a run: the sandbox user cannot ask for the identity token (sandbox.md). Which jobs' tokens the broker accepts is its operator's policy, and agentic-job fixes none of it. The broker supports two admission forms:

  • any_workflow: any workflow, narrowed by owner or repository id, or any: true.
  • called_workflows: a reusable workflow called from another repository, named by its file at a ref or at the commit in job_workflow_sha, with the calling repositories by id. With callers pinning a commit the entry is the sha form, and has to be updated whenever the pin moves.

A caller that writes the agent job itself (the pieces), as example-compose.yml does, runs it in its own workflow, not a called one: the token's job_workflow_ref names the caller's file, so only the first form, narrowed to that repository's id, admits it. That entry is the permissive one below: any workflow of the repository that can ask for an identity token can register a run. The shipped dispatch.yml and the other examples call agentic-job.yml, which called_workflows admits; composing the agent job is an opt-in that widens admission until the broker can name a caller's own workflow file.

Under a permissive entry, an agent that holds a GitHub credential able to push a workflow to an admitted repository could start a job of its own that registers a run. What an operator turns on for this workflow is linked from workflow.md.

How the default was chosen

The workflow defaults to github-oidc because network reachability alone does not bind spending to one run. Under plain without front, the agent can register another run. The binary has no default registration mode; non-GitHub callers must choose one explicitly.

A provider API key

The reusable workflow refuses this mode for now, GitHub's billing included: its agent job runs the agent as the runner's user, which could read the key from run, and run --runner-home refuses api-key until the host keeps the runner's processes from reading each other (workflow.md). What follows is what the binary does for a caller that composes its own agent job with a sandbox user.

register = "api-key" is for a caller with a provider's API key and no broker. provider is anthropic, for Claude Code, or openai, for opencode at an OpenAI-compatible API with a bearer key (opencode's built-in configuration uses the Responses API); url is the root the agent's client would be given, such as https://api.anthropic.com or https://api.openai.com/v1, and must be https (plain http only to loopback). github is GitHub's billing, below. anthropic-url and openai-url are refused, and so is an agent the provider's API does not serve: Vibe, and a child agent. Threat detection (detect) needs a registered run, and the key never reaches its host, so enabled detection fails closed in this mode and blocks apply. The caller must unset AGENTIC_JOB_DETECT_MODEL to use API-key inference without detection. run reads the key as AGENTIC_JOB_INFERENCE_KEY, which only the step that runs it should get. agentic-job.yml, review.yml and dispatch.yml declare the secret INFERENCE_API_KEY, so that callers that pass it still parse, and give it to no job.

run reads the key from its environment and asks the provider for its list of models with it, retried as a registration is. A missing key, one that is not a key, and one the provider refuses (401 or 403) stop the run before the agent starts, with the variable's name and the provider's answer, the key cut out of it. agentic-job check-key --config FILE makes the same check, so that a step before sandbox setup can fail on a bad key in seconds rather than after the minutes of setup; it is no control, as run checks again, and the reusable workflow does not run it while it refuses the mode. Then run listens on a loopback port and gives the agent that address and a token it made for the run, in the same places a broker's would go. The sandbox user reaches loopback without the egress proxy. For each request the proxy:

  • refuses it (401) unless it carries the run's token, in x-run-token, as a bearer, or in x-api-key, and once the run has ended;
  • refuses (403) all but the model requests (POST /v1/messages and /v1/messages/count_tokens; POST /responses and /chat/completions) and the list of models. A batch would run many model requests that the count sees as one, and an uploaded file stays at the provider after the run;
  • refuses (403) a model request that offers the model a tool the provider runs, such as web search or fetch, code execution or an MCP server, or that names Anthropic's mcp_servers or container, web_search_options or a stored prompt. The provider would reach the network for the agent, outside the egress filter, with whatever the agent puts in the request. Only tools the agent runs itself pass: function and custom ones, and Anthropic's own client tools;
  • refuses (403) a model request that names OpenRouter's plugins or fallback models, or a model whose name says it searches the web with every request (search, sonar, :online). Names are the provider's choice, so this list cannot be complete: a caller whose provider serves other such models should not use this mode with them;
  • refuses (403) a model request whose content the provider would fetch: an image, document or file named by URL or by a file id at the provider (the run uploads none). Content is read by its API's shapes at every depth, such as a tool's result, and a content block of a type the proxy does not know is refused, so a new provider feature stays closed until it is named. Inline content (text, base64, data: URLs) passes. What is sent is the JSON that was checked, written out again, so the provider cannot read the request otherwise than the check did;
  • refuses (403) a model request past max-requests, counted when it is admitted, so that requests sent at once cannot pass the cap together;
  • drops every credential the agent sent, adds the key, and passes the answer back as it streams, with its content type, request id and retry-after and no other header of the provider's. A refusal of the provider's is read whole first, under 64 KiB and not compressed, and the key cut out of it, as some providers quote the key they refuse; any other refusal is answered 502 instead.

It counts every model request it forwards, and that count is what max-requests reads, as it reads a broker's. Ending the run closes the port and cuts off every connection, so a request whose body was still arriving is not sent. Before the agent starts, run checks that no process the sandbox user can read holds the key, beside the probes of the run token. The key is redacted from the run's logs and files, as the run token is.

What this does not give: the key is long-lived and the provider's own limits are its only spending cap. The run's request cap, budget and timeout bound one run, as with a broker, but nothing ends the key when the job ends. A deployment that wants no long-lived key in its repositories uses a broker. gh-aw does the same with a proxy container on loopback beside the agent; here the proxy is part of run, so nothing else is started.

GitHub billing

provider = "github" is the same proxy with a GitHub token as the key, so GitHub bills the run's inference as gh-aw's Copilot engine has it billed. In the reusable workflow inference-provider: github implies register = "api-key" and the root https://api.githubcopilot.com, so the agent job refuses it for now, as above. Another root, such as GitHub Enterprise's, takes inference-url with inference-register: api-key, so that the URL of a broker left in a caller never gets the token (Enterprise is untried).

The API is GitHub's Copilot API, the one gh-aw's API proxy forwards to, at one root for both agents: Claude Code's POST /v1/messages and opencode's POST /responses and /chat/completions, with every check of the section above. The proxy sends the token as a bearer with gh-aw's Copilot-Integration-Id, agentic-workflows, in place of any the agent sends: the API refuses a job token without an integration id its App allows (HTTP 400), and the id also decides which models it offers. The key is checked against GET /models. model has to be a name that list offers: they are GitHub's, such as claude-sonnet-4.5, not the provider's. Claude Code's background requests name its own small model, which GitHub may not know under that name. opencode's built-in configuration uses the Responses API, which GitHub serves for some of its models only. GitHub Models (models.github.ai, models: read) is not used: it serves only OpenAI's chat completions, and no Anthropic model.

GitHub takes two tokens for this, as it does for gh-aw:

  • a fine-grained personal access token with the Copilot Requests permission, billed to its owner's Copilot. This is the one an agent job of your own passes; the reusable workflow refuses the mode for now, above.
  • the job's own token where the job has copilot-requests: write, billed to the organization if its Copilot policy allows Copilot CLI billed to it (GitHub's docs). The reusable workflow does not ask for that permission: every caller would have to grant it to every call, and the agent's read token, the job's, would then pay for inference. An agent job of the caller's own grants it and passes inference-api-key: ${{ github.token }} to run, with github-token empty.

run refuses a key that is also the agent's GitHub token, GH_TOKEN: the agent would hold what pays, outside the run's caps. It cannot tell whether another token the agent gets has Copilot access; in a job that grants copilot-requests: write, the job token must not be the agent's. A token GitHub refuses stops the run before the agent starts with GitHub's reason, such as a token without the Copilot Requests permission, and says which tokens it takes.