
Company News
Free Business Plan Upgrades for Open Source Maintainers
Open source maintainers are under more pressure than ever. We're raising our open source program from the Team plan to the Business plan, free.
@runinfra/cli
Advanced tools
RunInfra CLI: browser-approved sign-in and resumable downloads for optimized model packages
Sign in from your terminal and pull the optimized model packages your workspace owns.
$ npm install -g @runinfra/cli
$ runinfra login
$ runinfra pull <slug> --out ./models
# Recipe package only: also fetch its exact pinned source weights
$ runinfra pull <slug> --out ./models --weights
Replace <slug> with the slug shown on your package's page under
Optimized models on runinfra.ai, for example
qwen3-6-27b-fp8cd-v3-mlponly-h100-vllm.
Three ways in, all live. These kits get pulled onto GPU hosts, and a GPU host often has Python but no Node, so pick whichever matches the machine you are standing on:
| Channel | Command | Reach for it when |
|---|---|---|
| Standalone | curl -fsSL https://raw.githubusercontent.com/RightNow-AI/runinfra-cli/main/install.sh | sh | The box is bare. No Node, no Python, the download brings its own runtime. |
| Python | pip install runinfra-cli | The machine already lives in Python. |
| Node | npm install -g @runinfra/cli | The machine already lives in Node 20 or newer. |
On Windows the standalone line is
irm https://raw.githubusercontent.com/RightNow-AI/runinfra-cli/main/install.ps1 | iex.
This table deliberately carries no version and no status column. It used to
carry both, and both rotted: after the release that took every channel live, a
reader on npm was still being told that two of the three did not exist and that
the client was two versions behind. A number written into prose is a number
nobody updates. For what is actually published right now, ask the registries:
npm view @runinfra/cli version and pip index versions runinfra-cli.
All three channels publish the same version at the same time and put the same
runinfra command on PATH: the standalone and Python channels repackage the
client that is already on npm rather than changing it. So runinfra --version
reads the same however it
arrived, and every command in this document behaves identically. What differs
is only what has to be on the machine first:
pip install runinfra-cli is
this tool, pip install runinfra is the inference SDK, a different product
for calling an endpoint from your code.Rather not install anything globally? The npm channel also runs through npx,
for example npx @runinfra/cli pull <slug>.
If your registry cannot find @runinfra/cli, the release has not reached it
yet. Run the CLI from a checkout instead, see Development.
Every release publishes SHA256SUMS, which lists the sha256 of each binary,
and SHA256SUMS.sig, an Ed25519 signature over that file.
install.sh checks both for you. It carries the public key inside the
script rather than fetching it, because a key downloaded from the same host
as the binary proves nothing: whoever can replace one can replace the other and
serve a matching pair. It verifies the signature before it reads a single hash
out of SHA256SUMS, and a bad signature ends the install rather than printing
a warning.
A missing signature ends the install too, when the artifacts came from a
release. An attacker who can serve you a swapped binary can also delete the
signature that would expose it, so a check that disappears on request is not a
check. If you are deliberately mirroring a release, or installing one published
before the signing key existed, RUNINFRA_ALLOW_UNSIGNED=1 skips it and says
so, and artifacts taken from your own --base-url are never held to this rule.
Being unable to perform the check is the one case that stays a warning. If
the host has no openssl, or none that can do Ed25519, install.sh says so on
one line, names that exact reason, and continues on the checksum alone.
Refusing to install on a minimal container would cost more than it buys, and
the checksum still refuses a bad download. On macOS it looks past the LibreSSL
at /usr/bin/openssl, which cannot do the job, and tries the Homebrew
openssl@3 locations before it gives up.
install.ps1 does not check the signature. Windows PowerShell 5.1 has no
Ed25519 and .NET only gained one in 8, so it verifies the checksum, prints the
command below, and tells you which check it skipped rather than letting you
assume both ran.
To check a release by hand, save this as runinfra-release.pub:
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAYkEJIc7GfRAHAvUXcY/jrtnwFj53XZNDoXE6cAkOxYk=
-----END PUBLIC KEY-----
Its fingerprint is sha256 over the raw 32 byte key, and you can derive it from the file you just saved rather than taking this line on trust:
$ openssl pkey -pubin -in runinfra-release.pub -outform DER | tail -c 32 | sha256sum
5b2c8f637c0cd00a61ec6f126e5a9493c022ef1ea5be70fdd3ef4adf55801532
Then, in the directory holding the downloaded files:
$ openssl pkeyutl -verify -pubin -inkey runinfra-release.pub -rawin -in SHA256SUMS -sigfile SHA256SUMS.sig
Signature Verified Successfully
$ sha256sum --ignore-missing -c SHA256SUMS
runinfra-linux-x64: OK
Run them in that order. The signature is what makes the checksum worth
checking: SHA256SUMS arrives from the same place as the binary, so anyone who
can swap the binary can swap its recorded hash to match. Verify the signature
first, then let the verified SHA256SUMS speak for the bytes.
Two things that are a failed check rather than a bad file. If the signature is
published base64 encoded rather than as 64 raw bytes, decode it first and point
-sigfile at the result, base64 -d SHA256SUMS.sig > SHA256SUMS.sig.raw, with
-D instead of -d on macOS. And
-rawin needs OpenSSL 1.1.1 or newer: the openssl on a stock macOS is
LibreSSL, whose pkeyutl has no -rawin at all, so it answers with a usage
error rather than a verdict. Read that as "not checked", not as "bad", and
install OpenSSL through Homebrew if you want the real answer.
It proves the RunInfra release pipeline signed that file with the private half of the key above, and that nothing has altered it since.
It does not prove that pipeline was not subverted. The signing key lives in CI, so anyone who could steal the release token or edit the release workflow could also reach the key and sign whatever they wanted. What this closes is a release asset altered after publication, and a mirror or CDN serving something other than what was published. Both are real attacks and both are worth closing. Neither is the same thing as the download being tamper proof, and we do not claim it is.
runinfra logout or
Settings kills it.whoami and logout show a redacted form
(rp_k...8fq2). doctor shows only the server-issued visible prefix and no
suffix. No code path renders the full key.purpose = 'cli'.
The inference gateway refuses every purpose that is not customer, so a
leaked credentials file cannot be turned into inference spend. It opens
exactly one door: downloading a package your workspace already owns.doctor is a report the user chooses to run and
paste. It writes nothing to local disk and transmits no report. When a valid
local credential is present, its one authenticated request only asks whether
that credential still works.| Command | What it does |
|---|---|
runinfra login [--device] | Connects this terminal by having you approve it in a browser. |
runinfra pull <slug> [--out DIR] [--concurrency N] [--weights] | Downloads a package this workspace owns. --weights also fetches a recipe package's pinned source weights. Resumable. |
runinfra logout [--local] | Revokes this machine's key and deletes the stored copy. |
runinfra whoami | Shows what this machine has stored, and when its access expires. |
runinfra doctor [--json] [--offline] [--out DIR] | Diagnoses this runtime, credential, network access, environment, and pull target without repairing or deleting anything. |
Working aliases are -h, --help, -V, --version, -o, --out DIR for
doctor and pull, and -c, --concurrency N for pull.
runinfra doctor$ runinfra doctor --out /data/models
Human mode writes an aligned report to stderr and nothing to stdout. --json
writes exactly one versioned JSON document to stdout and suppresses narration,
so an agent can parse and branch on the result without scraping terminal text.
Doctor never writes, deletes, repairs, refreshes, or rotates local state. It
does not remove partial downloads. When it finds resumable .part files or
recipe-weight resume records, it names them, reports their logical size, and
shows a command the user may choose to run after deciding not to resume.
Without --offline, doctor makes exactly one authenticated request when a valid
local credential is available, to answer whether it is valid, expired, revoked,
or belongs to a workspace the user can no longer access. With no readable local
credential, that check is skipped and no authenticated request is made. The
request uses an intentionally invalid catalog slug: authentication runs first,
then slug validation stops the request before entitlement lookup or presigning.
It updates the key's server-side lastUsedAt bookkeeping and consumes one token
from the catalog download IP rate limit. It does not download anything and does
not change state the user can observe or would need preserved for a bug report.
--offline skips that request and every other network check. The JSON entries
for install.version, auth.server, network.api, and
network.huggingface then have status skip.
Warnings do not make the command fail. A missing HF_TOKEN, an unreachable
Hugging Face origin, low space under the documented large-pull heuristic, and
an auth-server timeout are warnings because none proves the requested package
cannot work. A typed credential rejection is a failure. The first failed check
in stable report order selects its exit code from the table below.
runinfra loginThere are two flows, and the CLI picks for you.
On a machine with a browser, runinfra login opens the approval page
itself, and the approved code is delivered straight back to a listener on
127.0.0.1. You click Approve and the terminal connects on its own. If that
listener cannot be reached, the page shows the code so you can paste it into
the prompt that is already waiting, and the sign-in still completes.
On a headless box reached over SSH, in CI, or with no graphical display, there is nothing to open, so the CLI prints a short code and you type it into the page on whatever device does have a browser. It never puts the code in a link: a link that arrives with the code already in it did not come from RunInfra.
$ runinfra login
Opened your browser to approve this terminal.
https://runinfra.ai/cli/authorize?request=3b1f2e9c-illustrative-request-id
Waiting for approval. This request expires in 10 minutes.
Or paste the code shown on the approval page and press Enter:
Connected to workspace <your-workspace-id>.
Credentials stored at /home/mona/.config/runinfra/credentials.json
This access expires 2026-10-24T09:14:02.118Z.
Revoke this terminal any time from Settings, API keys.
Waiting for approval repaints itself in place on a terminal, and is logged
once every ten seconds when the output is piped to a file.
Identical, with a different reason line. The CLI does not try to open a browser
over SSH, in CI, or on a machine with no graphical display: launching
xdg-open there prints a stack trace and hangs.
mona@gpu-07:~$ runinfra login
Using the device flow because this is an SSH session.
To connect this terminal:
1. On any device, open https://runinfra.ai/cli/authorize
2. Type this code: T4WX-9BQH
The page will not fill the code in for you. If a link ever arrives with the code already in it, it did not come from RunInfra.
Waiting for approval, 891s left
Connected to workspace <your-workspace-id>.
Credentials stored at /home/mona/.config/runinfra/credentials.json
This access expires 2026-10-24T09:14:02.118Z.
Revoke this terminal any time from Settings, API keys.
runinfra login --device skips the browser attempt entirely and prints the
code immediately. It is a convenience, not a requirement.
The code is what you type, never what you click. The page has no field that can
be prefilled from a link, and the CLI discards the RFC 8628
verification_uri_complete field precisely because a code-bearing link is how
an attacker gets a victim to approve the attacker's terminal.
The approval page names the account, the workspace, the device label
(user@host, sent by the CLI), the code you typed, and the one thing it
grants: downloading optimized model packages the workspace already owns.
Approving requires a role that can deploy in that workspace. A viewer sees
"Not available for this role" instead of an Approve button, because a viewer
approval would mint a key that is refused on its first use.
slow_down.pull refuses locally and asks you to
sign in again. Revoking from Settings ends it sooner.The persistent credential file is credentials.json. It holds the key, the API
base it was minted against, the workspace id, the granted scope, and the expiry.
No password, no refresh token, no cookie. The same directory can also contain:
update-check.json, the last optional update notice shown.credentials.json.tmp, while credentials are written atomically.update-check.json.<pid>.tmp, while update state is written atomically.update-check.json.lock, while one process decides whether to show a notice.The temporary and lock files are normally removed after the operation. A crash can leave a stale one behind.
| Platform | Path | Protection |
|---|---|---|
| Linux, macOS | $XDG_CONFIG_HOME/runinfra/credentials.json, else ~/.config/runinfra/credentials.json | Directory 0700, file 0600 |
| Windows | %LOCALAPPDATA%\runinfra\credentials.json | Directory ACL rewritten to the current user only |
RUNINFRA_CONFIG_DIR overrides both, for CI and for mounted volumes.
macOS purists would expect ~/Library/Application Support. Every CLI already on
that machine (gh, aws, docker, kubectl) uses ~/.config, and matching the
neighbours beats matching the platform guideline for a file people occasionally
delete by hand.
Windows uses %LOCALAPPDATA% rather than %APPDATA% so a roaming profile never
syncs a live API key around a domain. chmod means nothing there (Node maps it
to the read-only attribute), so the directory ACL is rewritten instead:
icacls "%LOCALAPPDATA%\runinfra" /inheritance:r /grant:r "DOMAIN\user":(OI)(CI)F
/inheritance:r drops inherited entries, which on a domain machine routinely
include Administrators and SYSTEM. /grant:r replaces any existing grant
instead of adding a second one. (OI)(CI)F is object- and container-inherit
full control, so the file created inside inherits it.
If that call fails, the CLI says so rather than implying a protection it did not apply:
warning: Could not restrict C:\Users\mona\AppData\Local\runinfra (icacls exited with code 5). The API key is protected only by your Windows profile permissions.
The mode is also applied with an explicit chmod on POSIX, because
writeFile's mode option only applies when it creates the file: overwriting an
existing 0644 credentials file would otherwise keep the loose mode.
runinfra whoamiReports what this machine has stored, and labels it as such. There is no
identity endpoint on the CLI surface, so this does not claim a round trip it did
not make. A key revoked from Settings five minutes ago still shows here, and
fails on your next pull.
$ runinfra whoami
Workspace <your-workspace-id>
Key rp_k3n9 (rp_k...8fq2)
Grants catalog:download
Endpoint https://runinfra.ai
Connected 2026-07-26T09:14:02.118Z
Expires 2026-10-24T09:14:02.118Z (in 2159h 58m)
This is what this machine has stored. Access may have been revoked from Settings, API keys since it was granted.
The fields go to stdout and the closing sentence to stderr, so
runinfra whoami > terminal.txt captures the facts without the caveat.
runinfra logoutSigning out means "this machine no longer has access", so logout revokes the
key it is holding and then deletes the local copy.
$ runinfra logout
Revoked this machine's key and removed the local credentials.
The key rp_k3n9 (rp_k...8fq2) no longer works anywhere.
The local key is gone, the remote key is not, and the CLI says so rather than letting you walk away from a lost laptop thinking it is handled:
$ runinfra logout
Removed this machine's credentials.
warning: The key was NOT revoked, so it is still live.
runinfra.ai did not answer within 5000ms.
The key rp_k3n9 (rp_k...8fq2) is STILL VALID until 2026-10-24T09:14:02.118Z.
To end its access now, revoke it in Settings, API keys on runinfra.ai.
The revoke call has a five second ceiling covering connect, headers and
body together, so logout cannot hang. A timeout, a refused connection, a 5xx
and a deployment too old to have the route all land here, each naming its own
cause.
The exit code is 0 in both cases, matching npm logout and
gh auth logout. The file this machine had is gone, which is what the command
is named for, and logout || true in a CI teardown keeps working. The
incompleteness is carried by the warning, not by the exit status.
--localDeletes the stored key and makes no network call at all. For an offline machine, and for teardown scripts that must not reach the network:
$ runinfra logout --local
Removed this machine's credentials.
Skipped the revoke because --local was passed. No network call was made.
The key rp_k3n9 (rp_k...8fq2) is STILL VALID until 2026-10-24T09:14:02.118Z.
To end its access now, revoke it in Settings, API keys on runinfra.ai.
Logging out a machine that was never signed in prints
This machine was not signed in., exits 0, and also makes no network call:
there is no key, so there is nothing to authenticate one with.
POST /api/cli/revoke revokes the key that called it, and nothing else.
There is no id in the path, none in the query, and the body is never read, so
there is nothing for a caller to name. It cannot list keys, cannot see another
terminal, and cannot touch any other resource. A leaked credentials file
therefore gains exactly one new power: destroying the credential it already is.
It is idempotent. Revoking a key that is already revoked or already expired answers success, because the thing you asked for ("this key is dead") is already true.
Open Settings, API keys on runinfra.ai. Every terminal you connected is
listed there as RunInfra CLI: user@host, with its prefix, when it was created,
and when it was last used. Revoke the row. The next command that terminal runs
is refused:
$ runinfra pull qwen3-6-27b-fp8cd-v3-mlponly-h100-vllm
error: This terminal's access was revoked.
Run `runinfra login` to connect it again.
Revocation takes effect on the next request, whichever way it was done, and your role in the workspace is re-read on every request too. Someone removed from a workspace, or demoted below deploy, loses CLI access immediately rather than at their next key rotation.
One honest limit: a download URL already minted stays valid for its 30 minute lifetime. Revoking stops new downloads from starting, it does not reach into a transfer already in flight.
runinfra pull$ runinfra pull qwen3-6-27b-fp8cd-v3-mlponly-h100-vllm --out /data/models
Downloading kit.zip (41.2 GB) into /data/models
kit.zip [########----------------] 33.4% 13.8 GB / 41.2 GB 92.4 MB/s ETA 49m 12s
Transferred 41.2 GB in 1h 14m.
Checksum verified.
Saved kit.zip.
/data/models/kit.zip
The final path is the only thing on stdout, so MODEL=$(runinfra pull ... )
works and progress does not pollute it.
Options: -o, --out DIR (defaults to the working directory),
-c, --concurrency N (1 to 16, defaults to 8), and --weights.
A recipe package does not redistribute model weights. Its signed kit records
the Hugging Face repository, a full immutable commit, the authoritative file
list, and the expected weights-tree sha256. Add --weights to fetch and verify
those files:
$ runinfra pull recipe-model --out /data/models --weights
Saved kit.zip.
Fetching acme/model@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa (12.4 GB) into /data/models/recipe-model/weights
Verifying the signed weights-tree checksum.
Pinned weights tree checksum verified.
Saved pinned weights in /data/models/recipe-model/weights.
/data/models/kit.zip
The final kit path remains the only stdout line. Weight progress and verification
stay on stderr. Partial files and the resume record live beside weights/, not
inside it, so the kit's own verifier sees only completed signed files.
The final check walks the whole weights/ directory, not only the files the
package signed, so a file the package did not sign fails the pull and is named in
the error. That matters when you pull into a directory that still holds an older
version of the same package: every pinned file arrives correctly, but the leftover
is still there, and a serving engine that globs *.safetensors would load it
alongside the pinned ones. Pull into an empty directory, or remove what the error
names.
Extract kit.zip into the same --out directory and its archive root joins the
already downloaded weights/ directory.
For a package that already contains optimized weights, --weights exits 0 and
prints that nothing additional was downloaded.
Gated models require two things: accept the model license on huggingface.co, and
set a Hugging Face read token in HF_TOKEN. The token is sent only to the
Hugging Face origin and is removed before following a cross-origin CDN redirect.
Interrupt it. Run the same command again.
^C
Stopping. Run the same command again to resume.
error: Download interrupted.
Run the same command again to resume from the bytes already on disk.
$ runinfra pull qwen3-6-27b-fp8cd-v3-mlponly-h100-vllm --out /data/models
Resuming kit.zip: 13.8 GB of 41.2 GB already on disk.
kit.zip [##########--------------] 41.7% 17.2 GB / 41.2 GB 88.1 MB/s ETA 46m 30s
A second Ctrl-C exits immediately without finishing the write in progress.
Two files carry the state. <name>.part holds the bytes, preallocated to the
final size so a full disk is discovered now rather than at 90%.
<name>.part.json records which byte ranges are durable: a range is written
to that file only after the data is flushed to disk, so a power cut can lose
work but can never claim bytes that are not there.
Resume is refused, and the partial file discarded, in three cases. Each one prints why:
.part is preallocated, so its
length says nothing about which bytes are real. Guessing would produce a file
that passes every check except the checksum, hours later.--out.Everything else resumes. A dropped connection retries the affected chunk a few times with backoff before giving up. The download URL is valid for 30 minutes and a large kit is not, so the CLI renews it automatically 2 minutes before expiry and whenever storage refuses a stale signature. Every renewal re-checks that the artifact is still the same one.
When the catalog published a sha256 for the package, the CLI hashes the file and compares. A mismatch renames nothing, so bad bytes never appear under the final name:
error: The downloaded bytes do not match the published checksum for qwen3-6-27b-fp8cd-v3-mlponly-h100-vllm.
The partial file was left at /data/models/kit.zip.part. Delete it and run the command again.
When the package publishes no checksum, the CLI says the file is UNVERIFIED rather than implying it checked something.
Every chunk is also checked as it arrives, which matters because the checksum
is a verdict that lands hours later and only for packages that publish one. A
206 Partial Content is a claim about which bytes follow it, and the CLI
now reads the Content-Range header that carries that claim before a single
byte is written. The response has to state exactly the range that was asked
for, and if it names a total, that total has to be the size of the object being
downloaded. A missing, unreadable or disagreeing Content-Range stops the
download at exit 6, and, unlike a dropped connection, it is not retried:
error: storage.runinfra.ai did not serve the byte range that was asked for: it answered 206 for bytes 33554432-67108863 with bytes 0-33554431.
The download stopped rather than write bytes it cannot place. This is a broken or hostile origin, not a connection problem, so report it to support with the package slug rather than retrying.
The failure this closes is specific. A proxy, CDN or mirror that returns the right number of bytes from the wrong offset used to have them written at the offset the CLI asked for. On a resumed multi-gigabyte download the file still reaches its full length, every range is still recorded as durable, and nothing looks wrong until a whole-file hash disagrees at the end, if there is one to disagree.
pull proves entitlement first, then probes the real size, then checks free
space against what is left plus 5% headroom. The failure you are most likely
to hit happens in the first two seconds rather than in hour four:
error: Not enough free space in /data/models: 12.4 GB available, 28.7 GB needed.
Free some space or pass --out to a larger volume, then run the same command again to resume.
A preflight cannot see a volume that fills up later, from something else on the
machine, while the transfer is running. When that happens the write is what
fails, so the CLI names the file it was writing rather than the host it was
reading from, and exits 7 like the preflight above:
error: The volume holding /data/models/kit.zip.part ran out of space.
Free some space, or pass --out to a larger volume, then run the same command again to resume from the bytes already on disk.
An exhausted quota, a read-only mount and a permission refusal are reported the same way, each naming its own cause. None of them is retried: the volume is not going to change between the first attempt and the fourth, and retrying only delays the one sentence the user can act on.
Scripted use is a first-class case, so failures are grouped rather than all
being 1.
| Code | Meaning | Retry? |
|---|---|---|
| 0 | Success | |
| 1 | Unexpected internal CLI error | After reporting or fixing the CLI bug |
| 2 | Bad usage, or an unsupported Node runtime | No |
| 3 | Not signed in, denied, expired, revoked, or refused by a gated Hugging Face source | After fixing authentication or source access |
| 4 | Workspace access was removed, the workspace does not own the package, or its files are not published yet | No |
| 5 | Network, server, or rate-limit failure, including an origin that advertises byte ranges and then will not serve them | Yes |
| 6 | Integrity failure: checksum mismatch, wrong pinned revision, a 206 that did not describe the requested range, or the artifact changed mid-download | Start over |
| 7 | No space, quota exhausted, or the destination is not writable | After freeing space |
| 130 | Interrupted | Yes, it resumes |
runinfra logout is 0 whenever the local key is gone, including when the
revoke could not be made. That is deliberate and matches npm logout and
gh auth logout: the command did the thing it is named for on this machine, so
a non-zero would break logout || true and every CI teardown. What the revoke
did is carried by the message, which states plainly whether the key is dead or
still live.
runinfra doctor is 0 whenever no check has status fail. Warnings and
skipped checks remain machine-visible in the report but do not turn information
into an error. If checks fail, the first failure in the stable check order uses
the existing code for that failure family.
Two of these rows were wrong until they were measured against what the client
actually does, and both errors pointed a script at the wrong recovery. An
origin that ignores Range was reported as 6, which tells a caller the bytes
on disk are corrupt and the download must start over, when the bytes are fine
and the origin simply cannot do resumable transfers. A disk that filled up
mid-download was reported as 5, naming the storage host, which sent people to
check a connection that was working. Ranges are a capability and belong in
5; a full disk, an exhausted quota, a read-only mount and a permission
refusal are the local machine and belong in 7, with the path in the
message rather than the host.
| Variable | Purpose |
|---|---|
RUNINFRA_API_BASE | Point at another deployment. Plaintext http:// is accepted only for a loopback host, because the sign-in response carries a live API key. |
RUNINFRA_CONFIG_DIR | Where credentials are stored. Overrides the platform default. |
HF_TOKEN | Hugging Face read token used only for a gated recipe package's pinned source files. |
The base a key was minted against travels with the key. If you sign in against a preview deploy and then run against production, the CLI warns and uses the base the key belongs to rather than sending that credential to a host that never issued it.
Every path and field this CLI speaks lives in src/endpoints.ts, read off the
routes as built.
| Route | Purpose |
|---|---|
POST /api/cli/device/init | Start the device flow, returns the device code and the code you type |
POST /api/cli/device/poll | Poll for approval, returns the key once approved |
POST /api/cli/device/loopback-init | Start the browser flow, returns the request id the approval page opens |
POST /api/cli/token | Loopback authorization code exchange, the browser flow's final step |
POST /api/cli/revoke | Revokes the calling key, and only the calling key |
GET /cli/authorize | The approval page, browser only |
GET /api/catalog/<slug>/download | Dual auth, accepts a purpose='cli' bearer key |
Path choices are forced by the CSRF origin check: /api/cli/token,
/api/cli/device/ and /api/cli/revoke are exempt from it, because no
non-browser HTTP client sends Origin. /api/cli/authorize is not exempt
(that one is the browser approving with session cookies), so the CLI never
calls it. Any new CLI-called POST route must be added to
EXEMPT_PATH_PREFIXES in lib/csrf/origin-check.ts or it will 403 on every
platform, and app/api/cli/route-surface.test.ts fails until it is.
Doctor uses the existing download route for its single authenticated
credential check, with the intentionally invalid slug __runinfra_doctor__.
The route authenticates before validating the slug, so a valid credential gets
the ordinary not_found envelope without entitlement lookup or a presigned
URL. The route's IP limiter runs before key validation, so every online doctor
run consumes one limiter token even when the credential is rejected.
Sign-in routes answer OAuth-shaped { "error": "<code>" } (RFC 6749 5.2, RFC
8628 3.5) with Retry-After in the header, not the body:
invalid_request invalid_grant expired_token access_denied
authorization_pending slow_down rate_limited temporarily_unavailable
server_error unsupported_code_challenge_method invalid_scope
invalid_grant deliberately fuses replayed, unapproved, wrong-verifier,
wrong-redirect and never-existed, and the device poll answers expired_token
for a code it has never seen. The server refuses to be an oracle, so the CLI
does not try to reconstruct the distinction either.
The download route answers { ok: false, code } with two vocabularies:
not_found, artifact_not_ready, rate_limited for the download itself, and
missing_credentials, invalid_key, key_revoked, key_expired,
workspace_access_revoked, auth_unavailable when the key did not hold up.
They map to different exit codes, so "your key was revoked" and "your workspace
does not own this" never collapse into one sentence.
POST /api/cli/revoke shares that envelope, because it authenticates the same
way. It answers { "ok": true } on success and refuses with
missing_credentials, invalid_key, workspace_access_revoked,
auth_unavailable, rate_limited or server_error. key_revoked and
key_expired are deliberately absent: both mean the key is already dead, which
is the outcome that was asked for, so both answer 200 { "ok": true }.
{ "apiKey": "rp_...", "keyPrefix": "rp_k3n9", "tokenType": "Bearer",
"scope": "catalog:download", "workspaceId": "<your-workspace-id>",
"expiresAt": "2026-10-24T09:14:02.118Z", "expiresIn": 7776000 }
Deliberately not called access_token: this is a RunInfra API key with no
refresh and no introspection endpoint, and borrowing the OAuth name would
promise a contract that does not exist.
OAuth 2.0 authorization code with PKCE (RFC 7636), plus the device shape (RFC 8628) for headless hosts. The code verifier stays in the CLI process and is never written, printed, or sent: only its SHA-256 challenge goes on the wire at request time. Codes are stored hashed, so reading the database cannot complete a pending sign-in. An approval is consumed by a single conditional UPDATE, so two racing exchanges cannot both mint a key. Redirect targets are restricted to loopback literals in the route, in the shared core, and in a database CHECK.
One thing this CLI wants still does not exist. It degrades honestly rather than pretending, and it becomes a small change here when it lands.
GET /api/cli/whoami, so whoami reports the
local credential and says so in those words. It makes no network call, and
access it reports may already have been revoked from Settings.Terminal revocation, which this document listed here as unbuilt, now exists:
POST /api/cli/revoke kills the calling key, and runinfra logout calls it
before it deletes the local file. The browser sign-in flow it also once listed
as unbuilt exists too.
It is created by POST /api/cli/device/loopback-init, which takes
{ codeChallenge, codeChallengeMethod, scope, redirectUri, deviceLabel } and
returns { requestId }. It sits under /api/cli/device/ so it inherits the
origin exemption, which is why a route that is not a device route lives at
that path.
node ../node_modules/typescript/bin/tsc -p tsconfig.json # build to dist/
node ../node_modules/vitest/vitest.mjs run cli/src --root .. # tests
node bin/runinfra.mjs --help # run it
RUNINFRA_API_BASE=http://localhost:3000 node bin/runinfra.mjs login
Tests cover the pure logic first: range planning and resume arithmetic, sidecar validation, the lease-refresh decision, per-platform browser command selection, redirect construction for IPv4 and IPv6, credential and path resolution, and every response parser.
The rest run against a real HTTP server on a loopback port, because the
defects that reached users did not live in the pure logic. Credential leakage
across a redirect, a Content-Range nobody read, and an errno raised inside a
response handler are all properties of how node:http is called and what is
done with what comes back, and a mock of that call is exactly the thing that
would not have caught them. So http-redirect-credentials.test.ts,
range-integrity.test.ts and logout.test.ts stand up an origin, misbehave in
a specific way, and assert on what lands on disk. logout.test.ts in
particular needs a real socket: a mocked transport cannot express a server that
accepts a connection and then never answers, or a port with nothing behind it,
and those are two of the three ways the revoke has to fail safely.
Nothing in the test suite opens a socket to the internet: every server it talks
to is one it started on 127.0.0.1.
FAQs
RunInfra CLI: browser-approved sign-in and resumable downloads for optimized model packages
We found that @runinfra/cli demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Company News
Open source maintainers are under more pressure than ever. We're raising our open source program from the Team plan to the Business plan, free.

Security News
The supply chain control that delays freshly published gems now covers lockfile generation and gem vendoring in Ruby projects.

Security News
During a UK cyber test, a Mythos 5 agent used sockpuppets, social engineering, and prompt injection to try to get a maintainer to merge malware.