Keyless design
Each host runs the open-source ushr agent. The agent holds the GitHub App private key on the host. The agent polls GitHub for queued jobs and mints a just-in-time runner registration with its own key. The control plane only decides which host runs which job.
The control plane never holds your App private key. It has no installation token and no runner registration token. It cannot call the GitHub API for you. It stores the App's webhook secret, which GitHub issues, to check webhook signatures.
When you create the GitHub App from the dashboard, GitHub returns a one-time manifest code. ushr.io stores that code until the host finishes setup or starts a new setup for the same scope. If neither happens, ushr.io deletes the code 90 days after the host's last token is revoked. ushr.io passes the code only to the host that started the setup. The host proves this with a PKCE verifier. With CLI versions after v0.2.10, ushr.io stores the code encrypted to a key that only the host holds. With v0.2.10 or older, it stores the code in plain text. GitHub accepts the code for 1 hour only. The host exchanges the code with GitHub for the App key, so the key never passes through ushr.io.
| Scope | Permissions |
|---|---|
| Organisation | actions: read · metadata: read · organization_self_hosted_runners: write |
| Single repository | actions: read · metadata: read · administration: write |
What crosses the wire
The agent dials out to the control plane over HTTPS. Nothing dials in to your host. These are the fields on the dispatch path.
| Field | Content |
|---|---|
| capacity | Number of concurrent slots on the host |
| busy | Handles of the slots in use |
| labels | Runner labels the host advertises |
| queues[]. | Scope: GitHub organisation name, or owner/repo for a single repository |
| queues[]. | Your priority for that organisation |
| queues[]. | Numeric GitHub job ID |
| queues[]. | The labels the job requests |
| queues[]. | Seconds the job has waited |
| disk_free_bytes, | Free and total space of the VM image store |
| blocked | True when the host refuses work for low disk space |
| version, | Agent version and managed-update state |
| update_error | Error text from the last failed managed update |
| failed_request, | The update request and target version that failed |
| Field | Content |
|---|---|
| id | Dispatch ID, also the runner name |
| org | Scope the runner registers to: organisation, or owner/repo |
| job_id | The job to run |
| labels | Labels the runner advertises |
| Request | Content |
|---|---|
| claim | No body. Accepts the offer. |
| done. | done, failed or lost |
| done. | Error text when a mint or provision fails. The control plane writes it to its server logs. |
The poll and the offer carry no workflow, no source code, no secrets and no logs. They carry a repository name only for a single-repository scope, where the scope is owner/repo.
The hosted setup also points the App's webhook at the control plane. GitHub then sends workflow_job events for start and completion. From those events the control plane keeps the job ID, organisation, repository name, workflow name, run ID, conclusion, labels, runner name and timestamps. It ignores events for runners that ushr did not start.
What the control plane stores
| Data | Content | Kept |
|---|---|---|
| Host status | Host name, organisations, labels, capacity, busy slots, queue depth, disk space, agent version, update state and update error text | Latest value, while the host is enrolled, then 90 days after its last token is revoked |
| Job runs | The dispatch and webhook fields above | 13 months after the job ends |
| Dispatch events | Organisation, dispatch ID, event name, host name, job ID, status | 30 days |
| Enrollment tokens | SHA-256 hash, organisations, host name, approver and approval IP address | 90 days after the token is revoked or replaced. A replaced token that the host's App record still uses stays until the host is revoked, then 90 days. |
| Login sessions | Host name, confirmation code, requester IP address and the approved token, encrypted to the CLI key (plain text for CLI v0.2.10 or older) | Until the CLI collects the token, or 1 day after the 10-minute expiry |
| Hosted GitHub Apps | App ID, App slug, webhook secret and, until setup finishes, the one-time manifest code | While the host is enrolled, then 90 days after its last token is revoked |
Enrollment tokens
ushr login enrolls a host. You approve the request in a signed-in browser. The approval page shows the host name, the IP address of the request and a confirmation code. Check that the code matches the one in your terminal.
- The enrollment token table stores only the SHA-256 hash of each token.
- The approved token passes through the login session to the CLI. With CLI versions after v0.2.10, the session holds it encrypted to a key that only the CLI holds, in memory. With v0.2.10 or older, the session holds it in plain text.
- A token serves only the organisations that your workspace has verified. A token with no organisations is refused.
- A token is bound to one host name.
- Each host has one live token. Enrolling the host again revokes the old token.
- A login session expires after 10 minutes. The CLI collects the token once, with a PKCE verifier, and the session is then deleted.
Webhook signatures
GitHub creates a webhook secret with the App. The host receives it with the App key and registers only the secret with ushr.io. GitHub signs each delivery with HMAC SHA-256 and that secret. The control plane rejects any delivery with a missing or wrong signature.
Each App has its own webhook URL and secret. An event counts only if it matches the App's verified organisation and a runner name from that host. The webhook secret authenticates deliveries. It cannot call the GitHub API.
Signed releases
Each ushr release publishes a checksum file. v0.2.10 and earlier releases publish checksums only. The release pipeline adds a cosign signature for the checksum file and a software bill of materials (SBOM) from the first release after v0.2.10. Verify the signature with cosign, then check each binary against the checksum file.
The installer and managed agent updates download the official release and verify its published checksum before they install it. With cosign installed, the installer also verifies the signature of a signed release.
When the control plane is down
- Running jobs continue. Runners connect directly to GitHub, not to ushr.io.
- No new jobs start on ushr hosts. Queued jobs wait in GitHub's queue.
- The agent retries its poll until the control plane answers.
- The agent keeps each job's completion report in memory and sends it when the control plane is back. The agent loses the report if it restarts first.
- Your GitHub App key and your hosts are not affected.
You can also run the open-source controller yourself. Then the controller and the agent send no data to ushr.io. The installer downloads releases through get.ushr.io unless you set GITHUB_TOKEN. Hosts also download tools and images from other sources, such as Homebrew and Docker Hub.
Report a vulnerability
Report it privately through GitHub security advisories. Include the steps to reproduce, the affected version and the impact. Please do not open a public issue for a vulnerability.