🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@runinfra/cli

Package Overview
Dependencies
Maintainers
1
Versions
7
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@runinfra/cli

RunInfra CLI: browser-approved sign-in and resumable downloads for optimized model packages

latest
Source
npmnpm
Version
0.2.5
Version published
Maintainers
1
Created
Source

@runinfra/cli

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.

Install

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:

ChannelCommandReach for it when
Standalonecurl -fsSL https://raw.githubusercontent.com/RightNow-AI/runinfra-cli/main/install.sh | shThe box is bare. No Node, no Python, the download brings its own runtime.
Pythonpip install runinfra-cliThe machine already lives in Python.
Nodenpm install -g @runinfra/cliThe 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:

  • Standalone and Python install one compiled executable with the runtime inside it. Nothing else is required, which is the whole point on a host you did not build. Note the distribution name: pip install runinfra-cli is this tool, pip install runinfra is the inference SDK, a different product for calling an endpoint from your code.
  • npm installs the same program as JavaScript and runs it on the Node you already have. It has zero runtime dependencies by design, so it installs quickly and runs cleanly inside a slim container or a locked-down host.

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.

Verifying a release

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.

What a good signature proves

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.

What the CLI never does

  • It never sees your password. Sign-in happens in your browser, in your existing RunInfra session. The CLI only ever holds an authorization code and, after the exchange, the key that code bought.
  • It never asks you to paste an API key. You do not create a key, copy a key, or store a key by hand. Approving in the browser mints one, the CLI writes it to a file only you can read, and either runinfra logout or Settings kills it.
  • It never prints the key. 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.
  • The key it holds cannot spend money. CLI keys carry 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.
  • It sends no telemetry. 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.

Commands

CommandWhat 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 whoamiShows 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 login

What actually happens today

There 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.

On a laptop

$ 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.

On a headless SSH box

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.

What the browser shows

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.

Timing and limits

  • A device code is valid for 15 minutes. After that the CLI stops polling and says so.
  • Polling starts at one request every 5 seconds and backs off by five more seconds each time the server says slow_down.
  • The minted key lives 90 days, then pull refuses locally and asks you to sign in again. Revoking from Settings ends it sooner.

Where credentials live

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.

PlatformPathProtection
Linux, macOS$XDG_CONFIG_HOME/runinfra/credentials.json, else ~/.config/runinfra/credentials.jsonDirectory 0700, file 0600
Windows%LOCALAPPDATA%\runinfra\credentials.jsonDirectory 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 whoami

Reports 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 logout

Signing 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 order, and why it is that order

  • The revoke goes first, while the key is still on disk. The key is what authenticates the revoke, so deleting first would throw away the only credential that can prove the request.
  • The local file is then deleted no matter what the revoke did. A logout on a plane, behind a corporate proxy, or during an outage still signs this machine out. A network step is never allowed to veto that.
  • You are told which of the two happened, in those words, every time.

When the revoke does not go through

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.

--local

Deletes 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.

What the revoke endpoint can and cannot do

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.

Revoking someone else's terminal

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.

Fetching recipe 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.

How resume works

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:

  • The sidecar is missing or unreadable. The .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.
  • The package was republished while you were downloading. Version, size or ETag moved. Continuing would splice two different artifacts into one file.
  • The final file already exists at a different size. Nothing is overwritten; you are told to move it or pass a different --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.

Verification

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.

Before it writes anything

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.

Exit codes

Scripted use is a first-class case, so failures are grouped rather than all being 1.

CodeMeaningRetry?
0Success
1Unexpected internal CLI errorAfter reporting or fixing the CLI bug
2Bad usage, or an unsupported Node runtimeNo
3Not signed in, denied, expired, revoked, or refused by a gated Hugging Face sourceAfter fixing authentication or source access
4Workspace access was removed, the workspace does not own the package, or its files are not published yetNo
5Network, server, or rate-limit failure, including an origin that advertises byte ranges and then will not serve themYes
6Integrity failure: checksum mismatch, wrong pinned revision, a 206 that did not describe the requested range, or the artifact changed mid-downloadStart over
7No space, quota exhausted, or the destination is not writableAfter freeing space
130InterruptedYes, 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.

Environment

VariablePurpose
RUNINFRA_API_BASEPoint at another deployment. Plaintext http:// is accepted only for a loopback host, because the sign-in response carries a live API key.
RUNINFRA_CONFIG_DIRWhere credentials are stored. Overrides the platform default.
HF_TOKENHugging 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.

The HTTP contract

Every path and field this CLI speaks lives in src/endpoints.ts, read off the routes as built.

RoutePurpose
POST /api/cli/device/initStart the device flow, returns the device code and the code you type
POST /api/cli/device/pollPoll for approval, returns the key once approved
POST /api/cli/device/loopback-initStart the browser flow, returns the request id the approval page opens
POST /api/cli/tokenLoopback authorization code exchange, the browser flow's final step
POST /api/cli/revokeRevokes the calling key, and only the calling key
GET /cli/authorizeThe approval page, browser only
GET /api/catalog/<slug>/downloadDual 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.

Refusal envelope

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 }.

Sign-in success body

{ "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.

How sign-in is secured

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.

Server-side gaps

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.

  • Identity. There is no 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.

Development

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.

Keywords

runinfra

FAQs

Package last updated on 29 Jul 2026

Did you know?

Socket

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.

Install

Related posts