Skip to content

Latest commit

 

History

History
275 lines (193 loc) · 49 KB

cli.md

File metadata and controls

275 lines (193 loc) · 49 KB

Developer CLI

sentinel is one binary: the offline pipeline commands, the host-local admin commands and the Linux server/worker roles, plus the networked commands that talk to a controller's API with an OAuth grant or a static credential. This page is the contract for the networked half.

Command What
sentinel auth login|status|logout sign in (browser or --device), inspect, sign out (below)
sentinel context use|show the profile's default tenant
sentinel run dispatch|status|list|cancel|wait, status, wait, job, log, workers, queue, artifact, cache the networked command surface (below)
sentinel service-account create|allow|grant|grants|revoke service principals and their grants (below)
sentinel mcp local stdio MCP server using the shared credential profile (MCP)
sentinel doctor configuration and connectivity checks with fixes (below)
sentinel api … the sentinel api commands with a static credential (below)

The shared client

Every networked command goes through sentinel::client::Client and accepts, anywhere after its name:

Flag Meaning
--profile NAME the profile to use (also SENTINEL_PROFILE; default: current in profiles.json)
--server URL the controller (also SENTINEL_SERVER); with a profile it must be the profile's own server
--token-file PATH a static credential: an sntl_ API credential or an sntl_at_ access token (also SENTINEL_TOKEN)
--output text|json|ndjson output mode (default text); --json is --output json

Credential precedence. (1) --token-file, then SENTINEL_TOKEN: a static credential, which needs --server or SENTINEL_SERVER and is never refreshed. (2) Otherwise a profile: --profile, then SENTINEL_PROFILE, then the current profile. Without either the command exits 3 and names sentinel auth login --server ….

Server/profile mismatch. A --server or SENTINEL_SERVER given together with a profile must equal the profile's server after normalization; otherwise the command exits 2 before any request, so a credential is never sent to a server it was not issued by.

Server URLs are normalized to one spelling: lower-case scheme and host, default port and trailing / dropped, a path prefix kept. Userinfo, query and fragment are refused, and plain http:// is accepted only for loopback (127.0.0.0/8, [::1], localhost).

Refresh. With a profile, a 401 makes the client refresh the access token once (under the profile lock) and retry once; if that still fails the command exits 3 with not signed in to {server} (profile {p}); run: sentinel auth login --server {server} --profile {p}.

Exit codes

Stable; scripts and agents switch on them (sentinel::client::Exit).

Exit Meaning
0 success (wait: the run passed)
1 remote fault, an unclassified error code, or a malformed answer
2 usage, local configuration or profile error, server/profile mismatch
3 unauthenticated or forbidden, including an expired or revoked grant; the message names the login command
4 not found
5 conflict or idempotency-key mismatch
6 busy (rate_limited, storage_full, internal) or unreachable, after the client's retries; or outcome_unknown, a write the controller could not confirm — it may have been applied, so check before repeating it
7 wait: the deadline passed first
8 wait: the run finished but did not pass

Retries: a rate_limited, storage_full, internal or outcome_unknown answer, a proxy's 502/503/504 and a transport failure are retried up to three attempts with back-off (200 ms, then 400 ms, or a retry-after header's seconds when the answer carries one, at most 2 s) when the request is safe to repeat — GET, PUT, DELETE, and a POST that carries an Idempotency-Key, which the controller replays instead of executing again. A POST without a key is never repeated, since the outcome of a busy write can be unknown. The controller names its own back-off in the error document rather than a header: a rate_limited answer with details.retry_after_ms (a store too busy to check the credential, or every long-poll slot taken) is retried like any other overload, but only after that back-off plus up to half of it again of jitter, so refused clients spread out; every command and every sentinel mcp tool call rides out a brief overload this way. A named back-off is honored up to 30 s (a larger or overflowing value is cut to 30 s). After the three attempts, wait and log show --follow keep waiting the same way; any other command exits 6.

A closed standard output — the reader of a pipe exited, as sentinel run list --all --output ndjson | head -1 does — ends the command at once, quietly, with exit 0: nothing failed on Sentinel's side and there is no one left to tell. Any other failure to write standard output (for example a full disk behind a redirect) is reported and exits 1. Files the CLI reads — pipelines, token and grant files, the admin password on standard input — are bounded by the bytes actually read, so a device such as /dev/zero or an endless pipe is refused at the limit instead of exhausting memory.

Authentication and profiles

Implemented in crates/sentinel/src/{auth_cmd.rs, profile.rs, keystore/, loopback.rs, browser.rs}.

Command What
sentinel auth login --server URL [--profile P] [--scope "…"] [--no-browser] browser sign-in (authorization code + PKCE, loopback redirect)
sentinel auth login --device … device sign-in: approve a short code on another device
sentinel auth login --grant-file PATH|- … import a provisioned service grant (sntl_rt_…, see service accounts)
sentinel auth status [--offline] [--json] profile, account, grant, scopes, narrowing, expiry, store; never token material
sentinel auth logout [--profile P | --all] [--forget] revoke on the server, delete the local credential
sentinel context use TENANT [--profile P], sentinel context show [--json] the profile's default tenant for commands that take --tenant

Every sign-in starts from the server's metadata (/.well-known/oauth-authorization-server). Its issuer must equal the normalized --server exactly, and every endpoint it names must be on the issuer's origin; otherwise login stops (issuer mismatch exits 2, a foreign endpoint 1) before anything is sent. --server defaults to SENTINEL_SERVER, then to the profile's existing server. Scopes default to runs:read runs:write logs:read artifacts:read cache:read; each --scope name must be one the server offers. A successful login writes the profile, makes it current, and — when it replaced an older grant of the same profile — revokes that grant (best effort, 5 s bound).

  • Browser. A listener on 127.0.0.1:0 receives the redirect (http://127.0.0.1:{port}/callback). The URL — with state (32 random bytes, hex), an S256 code_challenge, scope and resource={issuer}/api/v1 — is always printed on stderr and, unless --no-browser, handed to rundll32 url.dll,FileProtocolHandler (Windows) or xdg-open (Linux) as one argv element with no shell. On Windows the launcher is named by its absolute path in the system directory (from GetSystemDirectoryW), so a rundll32.exe beside sentinel.exe is never run, and the launcher inherits no SENTINEL_* variable (SENTINEL_TOKEN included). The listener waits at most 5 minutes, reads at most 8 KiB and 10 s of each request head, serves each connection on its own thread (at most 32 at once), answers any other path 404 and keeps waiting. Only a /callback carrying this login's state (compared in constant time) decides; one with any other state — a stray local process, or a web page that found the port — is answered 400 and ignored, and an idle connection never holds the wait up. The deciding callback's iss must equal the issuer (RFC 9207), else the login stops with exit 3. The browser only ever sees a static "you can close this window" page. The code is exchanged with the verifier at the token endpoint.
  • Device. The verification URI and user code are printed on stderr (the device code never is); polling honours interval, adds 5 s on slow_down, continues on authorization_pending, and stops with exit 3 on access_denied, expired_token or when expires_in passes. Ctrl-C ends the process with the platform's default signal behaviour — no handler is installed — and nothing has been written by then; the pending request simply expires on the server after 10 minutes.
  • Grant import. --grant-file reads a sntl_rt_ refresh token (at most 4 KiB, from a file or stdin with -) and spends it at once: the provisioned text is dead afterwards and only its stored successor works, so the file can be deleted. --scope and --device do not combine with it.

After tokens arrive, GET /api/v1/me names the account (a failure here revokes the new grant and nothing is stored). Then, under the profile lock, the credential is stored first and the profile that points at it second. stderr reports Signed in to {server} as {user} (profile p, grant grt_…); stdout stays empty.

Status. auth status reads the profile (--profile, SENTINEL_PROFILE or the current one); without --offline it also calls /api/v1/me through the profile (refreshing if needed, once more on 401) to verify the grant and report its tenant/repository narrowing. --json prints sentinel.auth-status/1:

{ "schema": "sentinel.auth-status/1", "profile": "default", "current": true,
  "server": "https://ci.example.com", "issuer": "https://ci.example.com",
  "user": "usr_…", "username": "alice", "grant": "grt_…", "scopes": ["runs:read", "…"],
  "tenant": "acme", "narrowing": { "tenant": null, "repo": null },
  "access_expires_ms": 0, "refresh_expires_ms": 0, "store": "os",
  "signed_in": true, "verified": true }

narrowing is null with --offline; tenant is the context use default. Exit 0 when signed in, 3 when not (no credential, expired, or refused by the server — the document is still printed), 2 for a --server that is not the profile's.

Logout. For the chosen profile (or --all), under its lock: POST /oauth/revoke with the refresh token (which revokes the whole grant), then delete the stored credential. When the server cannot be reached or does not answer 200, the local credential is deleted anyway, a warning goes to stderr and the command exits 1 — the grant then stays valid on the server until it expires or is revoked from elsewhere. A credential that cannot be read cannot be sent for revocation either, and a retry would read the same thing, so it is deleted, a warning says so and the command exits 1 for the same reason: a loose or foreign-owned file, and a damaged blob in either store. A credential that the OS store did not hand over (busy or locked Credential Manager) is not deleted, since that would destroy the only copy of the refresh token: the command exits 6 and can be run again (--forget keeps that profile's entry, which the rerun needs to find the credential). --forget also removes the profile entry (and clears current if it pointed there); without it the profile stays and commands say "not signed in" (exit 3).

Configuration directory and profiles.json

The directory is SENTINEL_CONFIG_DIR, else %APPDATA%\Sentinel (Windows) or ${XDG_CONFIG_HOME:-$HOME/.config}/sentinel (Linux). A directory inside a Git work tree — any ancestor holding a .git directory or file — is refused (exit 2) so a credential is never committed by accident; this includes a home directory kept in Git, which then needs SENTINEL_CONFIG_DIR.

{ "schema": "sentinel.profiles/1", "current": "default",
  "profiles": { "default": { "server": "https://ci.example.com", "issuer": "https://ci.example.com",
    "client_id": "sentinel-cli", "user": "usr_…", "username": "alice", "grant": "grt_…",
    "scopes": "runs:read runs:write logs:read artifacts:read cache:read", "tenant": "acme",
    "store": "os", "key": "sentinel:https://ci.example.com:default:0f3a…", "created_ms": 1790000000000 } } }

It never holds a secret and is replaced atomically (write a sibling, fsync, rename) under locks/.profiles.lock — a name no profile can take, so signing in to a profile called profiles (which holds locks/profiles.lock while it updates the file) does not wait on itself. Profile names are 1–64 of A-Z a-z 0-9 . _ -, not starting with .. A schema other than sentinel.profiles/1 is refused.

Credential storage

The credential is one JSON blob per profile, {"refresh":"sntl_rt_…","access":"sntl_at_…","access_expires_ms":…,"refresh_expires_ms":…}:

Store Where Default on
os Windows Credential Manager (CRED_TYPE_GENERIC, CRED_PERSIST_LOCAL_MACHINE, target = the key; see below) Windows
file credentials/<profile>.json in the configuration directory Linux (by decision, below)

Decision: Linux keeps the owner-only file store; there is no Secret Service backend. The CLI's Linux users are mostly agents, CI jobs and servers with no D-Bus session bus, no unlocked keyring and no prompt UI, where a Secret Service backend would fall back to the file store anyway; the few desktop users gain only defence in depth against a same-user process, which the keyring does not stop either (any process of the same user can read an unlocked collection). A backend would need a D-Bus client (zbus with its async runtime, or libdbus through a C dependency) and the session-encrypted Secret exchange, which contradicts the small, fast, dependency-light CLI, and it could not be verified on the headless verification hosts. Linux therefore defaults to file, and SENTINEL_CREDENTIAL_STORE=os there is not a backend: the write is refused with a message that names SENTINEL_CREDENTIAL_STORE=file, and auth login then falls back to the file store with that message as a notice on stderr (below). The file store's protections are the 0700/0600 ownership checks below. Revisit only with a real desktop-keyring requirement and a host that can run the backend's tests; the profile's recorded store makes adding an os backend on Linux later a non-breaking change.

The OS store's key is sentinel:{issuer}:{profile}:{d}, {d} the first 16 hex digits of BLAKE3 over the configuration directory's canonical path, recorded in the profile as key at sign-in: the OS store is per user, so without {d} two configuration directories (a project's, a CI runner's, a test's) with the same profile for the same server would share one credential under two different refresh locks. Profiles without a key use the shared sentinel:{issuer}:{profile}; their next refresh moves them (below). Every Credential Manager call of this logon session's Sentinel processes takes a shared named mutex first: with several processes writing at once, CredReadW was measured answering not-found for an entry just written and never deleted (15 of 20 four-process probes lost a read; 0 of 8 with the mutex; a one-off Windows probe, no record is committed and it has not been re-run, see X2 in the tracker), which would make a parallel CLI report "not signed in".

SENTINEL_CREDENTIAL_STORE=file|os states the preferred store for a new sign-in (it is listed in auth login --help); any other value is refused (exit 2) before the sign-in starts, so no grant is minted for it. It is a preference, not a force: when the OS store fails (always on Linux, or a locked Credential Manager), the credential goes to the owner-only file store with a notice on stderr naming the cause, and the profile records "store": "file"; a profile is only ever read from the store it records. On Unix every directory Sentinel creates is 0700 and every file 0600; a configuration directory, profiles.json, credentials directory or credential file that another user can reach (mode & 0o077) or that another user owns is refused (exit 2) with the chmod/chown that fixes it. On Windows every directory Sentinel creates, and the credentials directory before every credential write, gets a protected DACL granting only the current user and SYSTEM (inherited by the files inside) — the equivalent of 0700, wherever SENTINEL_CONFIG_DIR points, not only under the per-user %APPDATA%. Every directory and file that already exists — the configuration directory (even one someone else made), profiles.json, the credentials directory and its files — is checked on its open handle like on Unix: it must be owned by the current user, SYSTEM or Administrators, and its DACL may allow no one else; otherwise it is refused (exit 2) with the icacls commands that fix it (and sentinel doctor reports the same). A credentials directory made by an older Sentinel is brought to owner-only before a credential write. Because profiles.json decides where refresh tokens are sent and which OS-store entry is read or deleted, a profile whose issuer is not its normalized server, or whose key is not this profile's own scoped key, is refused (exit 2) before any request.

A stored credential records the grant its tokens belong to. A legacy profile (OS store, no key) shares its entry with every configuration directory that has the same profile name and server. Its next refresh, under the profile lock, writes the successor back to the shared entry, then — when the token endpoint names this profile's grant — copies it to the directory's scoped key, records the key in profiles.json and deletes the shared entry (another directory that shared this very grant is then signed out rather than left to replay a spent refresh token). When the answer names another grant, the entry held another directory's newer sign-in: it keeps that successor and this profile is signed out (exit 3). Sign-in and auth logout revoke and delete a shared entry only when it holds the profile's own grant (asked of /api/v1/me, or learned from a refresh); another directory's is left alone, and a logout that cannot tell exits 6 without deleting anything. Because profile locks live in each directory, the refresh of a legacy entry also takes a lock named after the entry itself (a named mutex in this logon session, derived from the entry's key; the OS releases it if its holder dies), so two directories never present its refresh token at the same moment: the second waits, then finds the entry moved and is signed out (exit 3) instead of replaying the spent token. Credential files are replaced atomically: the new file is fsynced before the rename and, on Unix, the directory after it, so a process crash or a power loss never loses a rotated refresh token that was reported stored. On Windows the rename itself is not flushed, so a power loss right after a refresh can bring back the previous credential file; the spent refresh token is then refused and the user signs in again.

Sign-in changes the store in this order, under the profile lock: the new credential is stored first (atomically replacing a file-store entry), then profiles.json is updated, then the previous grant is revoked and its credential deleted, both best effort. When storing fails, the new grant is revoked, the previous sign-in is left as it was and the command fails; when profiles.json cannot be updated after the credential was stored, the previous grant is revoked on the way out (a file store has already replaced its credential) and the command fails, so sign in again; a refused SENTINEL_CREDENTIAL_STORE value fails before any request.

Refresh and the profile lock

A profile's access token is used from memory while more than 30 s of it remain. Otherwise the command takes locks/<profile>.lock — an OS file lock (File::try_lock, polled with back-off for at most 30 s, then exit 6) — re-reads the stored credential, and uses it as is when another process refreshed meanwhile; only if not does it spend the refresh token (grant_type=refresh_token at {issuer}/oauth/token) and store the successor before releasing the lock. After a 401, force_refresh(rejected) does the same but reuses the stored token only if it differs from the rejected one. So concurrent commands on one machine present each refresh token once, which the server's rotation requires (OAuth); two machines sharing one profile's credential would trip replay detection and revoke the grant. A refused refresh (invalid_grant, expired sign-in) is exit 3 naming sentinel auth login --server … --profile …; an unreachable token endpoint is exit 6. A busy one (temporarily_unavailable) is retried once after the server's one-second retry-after — safe, because a refused refresh spent nothing and a rotation that committed anyway is recovered by the second presentation within the grace window — and exit 6 if it is still busy. The API itself never answers 401 for an overloaded store (OAuth), so overload does not make the client spend its refresh token.

Tests: crates/sentinel/tests/profile.rs (portable; a scripted fake OAuth server on loopback, a temporary SENTINEL_CONFIG_DIR, the file store; Windows-only cases cover ACL refusals and the Credential Manager). Every CLI output is scanned for token material.

Error and exit contract

Every command returns Result<(), client::Error>; main.rs reports the error in the command's output mode and exits with error.exit as u8. A command calls args.client.output() (or Client::connect) before it can fail, so a failure is reported in the mode the user asked for.

Service accounts

For a tenant administrator (the credential needs tenant:admin; see OAuth). create, allow and grant mint or widen credentials, which needs a browser session that has stepped up (step-up); a CLI profile authenticates with an OAuth token or sntl_ credential, which can never step up, so these three are refused with forbidden and details.step_up; the web interface's tenant administration (Service accounts) offers them after step-up. A tenant administrator's token with tenant:admin can still list and revoke the grants it governs. --tenant SLUG defaults to the profile's context (sentinel context use); without either the command exits 2 before any request. Tenant, account, repository and grant arguments must be plain path segments (A-Z a-z 0-9 - . _ ~), checked locally.

Command Does
sentinel service-account create --name NAME [--role reader|operator] [--tenant SLUG] creates a service principal (default role operator); prints usr_…, name and role
sentinel service-account allow USR --repo NAME [--access read,run] [--tenant SLUG] sets its access to one repository (default read,run; --access "" withdraws)
sentinel service-account grant USR --name LABEL --scope "SCOPES" [--repo NAME] [--expires-in 30d] [--tenant SLUG] issues a service grant; --expires-in takes s, m, h or d, 1h..90d (server-checked), default 30d
sentinel service-account grants USR [--tenant SLUG] lists its grants as metadata (text: one line each; --output ndjson: one JSON object per grant; --json: the whole answer)
sentinel service-account revoke GRT revokes a grant (DELETE /api/v1/grants/{grt})

grant prints only the refresh token (sntl_rt_…) and a newline on stdout, so sentinel service-account grant … > agent.token captures exactly it; the grant handle, scope and expiry go to stderr (one JSON line in --json/--output ndjson mode), with the import command. The token is shown this once; the server keeps only its digest. On the agent's machine, sentinel auth login --server URL --grant-file agent.token spends it for the agent's own profile (then delete the file).

Exits follow the table above: a non-administrator is 3, an unknown account, repository or grant 4, a refused scope or lifetime (invalid_request) 1, a bad argument 2.

Failure output

In text mode a failure is error: <message> on stderr. In JSON and NDJSON modes it is exactly one line on stderr — the server's sentinel.error/1 document when there was one, otherwise {"schema":"sentinel.error/1","code":"client_…","message":…,"retryable":false} with client_usage, client_unauthenticated, client_unavailable, client_remote, client_not_found, client_conflict, client_timeout or client_run_failed — and stdout stays clean, so a pipeline never mistakes an error for data.

Commands

Every command below takes the shared client flags. --tenant SLUG defaults to the profile's context (sentinel context use); with a static credential and no context it is required (exit 2 names it). Identifiers, slugs and names that go into a request path must be [A-Za-z0-9._-]; anything else is refused locally (exit 2) rather than escaped into a different request.

Command Route Prints
sentinel run dispatch [--tenant] --repo NAME --pipeline FILE --source URL --sha SHA [--ref] [--idempotency-key] POST /tenants/{slug}/repos/{name}/runs the new run and its jobs; a retried dispatch with the same key is the same run
sentinel run status RUN, sentinel status RUN GET /runs/{id} the run and its jobs
sentinel run list [--tenant] --repo NAME [--limit N | --all] [--before RUN] GET …/runs?limit&before runs newest first (paging)
sentinel run cancel RUN POST /runs/{id}/cancel how many jobs were cancelled
sentinel run wait RUN [--timeout DUR], sentinel wait RUN [--timeout DUR] GET /runs/{id}/wait progress, then the final run; exit 0 passed (or skipped), 8 finished otherwise, 7 deadline
sentinel job cancel JOB, sentinel job rerun JOB POST /jobs/{id}/cancel|rerun the cancel outcome, or the rerun job's state
sentinel log show ATTEMPT [--follow] [--step N] GET /attempts/{id}/logs the log as the job wrote it; --follow waits until it is complete
sentinel log search ATTEMPT --text TEXT [--limit N] GET /attempts/{id}/logs/search matching lines (seq, step, stream, text), default 100, at most 10,000
(log show --output json) same one document of at most 10,000 frames and 16 MiB of frame text, continued with next_after when cut (the first frame is always included): the listing is held whole before it prints, so it is bounded in bytes as well as frames, since 10,000 full 32 KiB frames would be about 320 MiB. Raising either limit needs a matching change to the memory budget
sentinel workers list [--tenant] GET /workers?tenant pools and their workers with connection state and transport: {path} {rtt}ms, {path} rtt=- when the round trip was not measured, transport=- when the worker never reported (sentinel api workers prints the same lines)
sentinel workers drain|undrain WORKER POST /workers/{id}/drain|undrain the new drain state (platform admin)
sentinel queue [--tenant] [--limit N] GET /queue?tenant&limit waiting jobs, oldest first, with age and reason; the total when cut (--limit 1–500, default 100)
sentinel artifact list RUN, sentinel artifact show RUN ARTIFACT GET /runs/{id}/artifacts[/{arf}] artifact rows; one row with its manifest entries
sentinel artifact download RUN ARTIFACT --path ENTRY --out FILE [--tenant] the manifest, then GET /tenants/{slug}/objects/{digest} the entry's bytes in FILE, verified
sentinel cache show ATTEMPT GET /attempts/{id}/summary the attempt's cache records (hit or miss reason, files, bytes, publish verdict, costly hits), or that it has not reported yet
sentinel secret set NAME [--file FILE|--stdin] PUT /tenants/{slug}/secrets/{name} created or rotated metadata; a terminal prompts with echo disabled when input is not redirected
sentinel secret rotate NAME --if-version N [--file FILE|--stdin] same PUT route compare-and-set rotation metadata
sentinel secret list, sentinel secret describe NAME GET /tenants/{slug}/secrets[/{name}] metadata only; values cannot be read back
sentinel secret delete NAME --if-version N DELETE /tenants/{slug}/secrets/{name} deletion metadata; the name remains reserved and every version is revoked
sentinel secret import --env-file FILE [--preview | --if-versions NAME=V,...] POST /tenants/{slug}/secrets/import atomic import metadata, or (preview) names, expected versions, literal-value hints and the --if-versions pin
sentinel secret allow|deny NAME --repo REPO PUT|DELETE /tenants/{slug}/secrets/{name}/allow?repo allow a tenant secret in one repository, or stop (its bindings there are removed)
sentinel secret allowed NAME GET /tenants/{slug}/secrets/{name}/allow the repositories a tenant secret is allowed in
sentinel secret bind NAME --repo REPO [--job JOB [--step STEP]] [--from-tenant | --override-tenant] PUT /tenants/{slug}/secret-bindings/{name}?repo&job&step the binding: which secret (by ID) a job or step of the repository may receive under that name
sentinel secret unbind NAME --repo REPO [--job JOB [--step STEP]] DELETE of the same route the binding removed (absent is not an error)
sentinel secret bindings --repo REPO GET /tenants/{slug}/secret-bindings?repo the repository's bindings, metadata only
sentinel secret revoke-version NAME VERSION [--repo REPO] POST /tenants/{slug}/secrets/{name}/versions/{v}/revoke?repo revoke one version permanently; the current number is unchanged, and revoking the current version stops delivery until the next rotation

wait is a loop of long polls, not a stream: each request parks on the server for at most 25 s (or what is left of --timeout: 90s, 500ms, 10m, 2h; default no deadline) and returns as soon as anything a status reader can see changes, so an idle wait costs one request per 25 s and a change is seen within milliseconds. The server parks at most 32 subscribers at once, and at most 8 of one user (a parked poll holds no handler, re-authorizes when access changes and ends when its client leaves: API); when none is free the poll is rate_limited, and wait (like log show --follow) sleeps the server's retry_after_ms (at most 30 s) plus up to half of it again of jitter and polls again, so a crowd of waiters does not return in lockstep and is never reported as exit 6. Text prints RUN STATE (done/total jobs finished) per change and the job table at the end; NDJSON prints each {version, changed, finished, run} answer that changed something; JSON prints the final run once. A run that finished without passing exits 8 with its state on stderr; the deadline exits 7. finished is judged together with the run it came with: the controller reads both from one snapshot, and an answer whose run has a job that is not terminal (a rerun made it live again) is taken as progress, not the end.

artifact download looks the entry up in the artifact's manifest, streams the object into FILE.{first 16 digest hex}.sentinel-part beside FILE while hashing it (BLAKE3, the object store's digest) and counting bytes, and renames it over FILE only when the declared length, the byte count and the digest all match; any mismatch removes the partial file, leaves FILE untouched and exits 1. An entry the manifest does not list exits 4. A transfer has no overall deadline (an entry can be 4 GiB): every wait for the network — connecting, the answer's head, each read of the body — is bounded by 30 s instead. A stall or a dropped connection resumes with a Range request after the bytes already written; after three attempts in a row that bring no new byte the command exits 6 and keeps the partial file, and running it again hashes what is there and continues after it. If a resumed file's digest does not match, the entry is fetched once more from the start.

log search follows the server's bounded scans: each request reads at most 4 MiB of log, so a 256 MiB log is at most 64 short requests, resumed at next_after with the server's next_carry. A literal split across frames of the same step and stream is found exactly once (reported with the frame it ends in), including across a sealed segment and across the cut between two requests; text mode says on stderr when the log is still being written (later lines were not searched) or when --limit cut the matches.

cache reports per-attempt records only (the cache: entries of that attempt); there is no tenant-wide cache browser.

secret takes --tenant (or the profile context) and, where it applies, --repo. A repository name is any 1–128 bytes without control characters (acme/app included) and is percent-encoded into the query. set reads bytes from a protected regular file, redirected stdin, or a hidden terminal prompt; it never accepts a value argument, and a stray argument (secret set TOKEN hunter2, --value=…) is refused with a fixed message that does not repeat it (exit 2). File/stdin values preserve bytes (a trailing newline is part of the value). The prompt reads raw terminal input without echo and removes only its line ending; it accepts one line of at most 65,536 bytes and refuses, rather than truncates, a longer value or a paste of several lines — use --file or --stdin for those. Ctrl+C cancels and restores the terminal; on Windows, input still pending after the line is discarded, so pasted lines never reach the shell or its history. A terminal that is not a console (mintty/MSYS without winpty) is refused with the same advice. Values are 1–65,536 bytes. Input files must be regular, non-link, owner-only files: Unix checks owner and mode (fix: chmod 600); Windows requires the owner to be the current user or SYSTEM and a DACL granting access only to them (fix: icacls "FILE" /setowner "%USERNAME%" then icacls "FILE" /inheritance:r /grant:r "%USERNAME%:F"; any deny entry is refused too). Input errors and controller errors never print file contents. set --if-version 0 creates, and a positive version rotates; without the flag, set reads metadata then uses compare-and-set. rotate and delete require an explicit version. Text and JSON contain metadata only.

Retrying a secret write. Every write sends one idempotency key: --idempotency-key if given, otherwise a generated one. A write whose outcome is unknown (exit 6: unreachable, busy after retries, or outcome_unknown) may have been applied; its error names the retry that replays it, for example rerun with --idempotency-key K --if-version 3, and in JSON mode carries details.idempotency_key. Rerunning with that key and version returns the first result instead of rotating again; reusing a key for a different value is idempotency_mismatch (exit 5). An agent that retries should pass its own --idempotency-key from the start.

secret import uses a strict UTF-8 NAME=value format: a leading UTF-8 byte-order mark is skipped, blank lines and lines beginning with # are ignored, lines end with LF or CRLF (any other carriage return is refused), the first = separates name from literal value, and quotes, escapes, # comments after a value, interpolation and shell expansion are not applied: A="x" stores "x" with its quotes. export A=1 and whitespace-only lines are refused. A UTF-16 file (PowerShell 5.1's default >) is refused with a hint to save it as UTF-8. Values cannot be empty and names must match [A-Z_][A-Z0-9_]*. Errors name the line, never a value. An import has at most 100 names and 1 MiB of source bytes. --preview shows names, current expected versions, whether a name was deleted (the import would refuse it) and warnings for a value wrapped in quotes or surrounded by whitespace, then prints the pin --if-versions NAME=V,.... Passing that pin to the real import makes it fail with conflict (exit 5, nothing changed) if any name moved since the preview; without it, the import uses the versions current when it runs. The import sends the original file and the versions in one compare-and-set transaction: a conflict changes none of the names.

Bindings. A job receives a secret only through a binding. secret bind NAME --repo REPO binds the repository's own secret NAME for every job that declares it; --job narrows it to one job and --step to one step of that job. --from-tenant binds the tenant secret of that name instead, which a tenant administrator must first allow in that repository; --override-tenant lets a repository secret win over an allowed tenant secret of the same name. bind, unbind, allow, deny and revoke-version are idempotent state changes, safe to rerun.

sentinel pipeline validate|explain stay offline and take --output text|json (--json is --output json; there is no list to stream, so no ndjson). In JSON mode explain prints sentinel.explain/1 and validate prints {"file", "valid": true, "jobs"} (text validate prints nothing on success), each as one line; a failure is one sentinel.error/1 line on stderr (invalid_pipeline, including a file over the 256 KiB limit, or client_usage for an unreadable file) with stdout empty, like the networked commands. The limit bounds the bytes read, not a reported size, so validate /dev/zero fails at 256 KiB.

Legacy sentinel api

The sentinel api commands (me, run, status, runs, cancel, rerun, logs, workers, queue, drain, undrain; see API) run on the shared client: --token-file, --token or SENTINEL_TOKEN may hold an sntl_ credential or an sntl_at_ access token, the server is taken as given (a trailing / dropped), and exits follow the table above — so conflict exits 5 and a busy or unreachable controller 6.

Output

--output text|json|ndjson (--json is --output json) chooses one of three shapes; stdout carries only results and stderr only notes and failures, in every mode.

Mode One object (status, artifact show, cache show, …) A list (run list, log show, log search, artifact list, workers list, queue)
text lines for people; not a contract one line (or block) per item as each page arrives; notes such as more runs: continue with --before run_… on stderr
json the server's document, pretty-printed one document once the listing ends: {"runs": [...], "next": …}, {"frames": [...], "attempt", "complete", "gaps", "next_after"}, {"matches": [...], "attempt", "next_after", "complete"}, {"artifacts": [...]}, {"pools": [...]}, {"jobs": [...], "total", "truncated"}
ndjson the document as one compact line one compact line per item, printed as each page arrives, nothing else

Paging. A listing prints at most --limit N items (default 20 for run list) or, with --all, every item up to a hard cap of 10,000; it asks the server for pages of at most 500 and prints each page before asking for the next, so memory stays one page in text and NDJSON modes (JSON collects the listing it prints, bounded by the same cap). --before CURSOR starts after an item — for run list the next a previous listing returned, which JSON carries as "next" and text and NDJSON name on stderr (more runs: continue with --before …) whenever items remain (in NDJSON it is also the last line's id); the cursor is a run id, so pages stay stable while new runs arrive. log show --output json stops at 10,000 frames or 16 MiB of frame text, whichever comes first, with complete:false and next_after; log show --output ndjson and text stream the whole log.

Exit codes and failure output are the same in every mode (exit codes, failure output).

Security notes

No command prints token material except the one that issues it (service-account grant prints only the refresh token, on stdout). A static credential is read from a file so it stays out of the process list. profiles.json never holds a secret.

Doctor

sentinel doctor [--profile P] [--server URL] [--json | --output text|json|ndjson] diagnoses how this machine signs in, in crates/sentinel/src/doctor.rs. It runs seven checks in order; each failure names the exact command or change that fixes it, and no output ever holds token material.

Check Passes when A failure's fix
config_dir the configuration directory can be located, is outside every Git work tree, and it, credentials/ and profiles.json are owner-only (Unix mode and owner; Windows owner and DACL) — a directory that does not exist yet passes SENTINEL_CONFIG_DIR=… outside the repository, or the chmod/chown (Unix) or icacls/takeown (Windows) the refusal names
profile --profile, SENTINEL_PROFILE or the current profile exists, and a --server/SENTINEL_SERVER is that profile's server (checked before any request) sentinel auth login --server URL [--profile P], or drop the foreign --server
health GET {server}/api/v1/health answers {ok: true} within 10 s start the controller / check the network; sentinel auth login --server NEW_URL --profile P if it moved
issuer the metadata's issuer equals the server and the issuer the profile recorded set the controller's public_url, then sentinel auth login --server PUBLIC_URL --profile P
credential_store the stored credential is readable from the store the profile records and the sign-in has not expired sentinel auth login …; for an OS store failure, SENTINEL_CREDENTIAL_STORE=file sentinel auth login …
access_token GET /api/v1/me accepts the profile's access token (refreshed first when it is about to lapse, once more after a 401) sentinel auth login --server S --profile P
refresh a forced refresh under the profile lock rotates the refresh token sentinel auth login --server S --profile P, or retry when the controller answers

A check that cannot run because an earlier one failed is reported as skipped (ok: false, "skipped": true) with that failure's fix: the network checks need health, the token checks need health, issuer and credential_store; the local credential_store check always runs. The refresh check really spends the stored refresh token once — exactly what any command does when its access token runs low — so the profile continues with the successor.

--json (and --output ndjson, as one line) prints sentinel.doctor/1 on stdout, whether or not the checks passed:

{ "schema": "sentinel.doctor/1", "profile": "default", "server": "https://ci.example.com", "ok": false,
  "checks": [
    { "name": "config_dir", "ok": true, "detail": "/home/alice/.config/sentinel (owner-only, outside any Git work tree)", "fix": null },
    { "name": "health", "ok": false, "detail": "cannot reach https://ci.example.com: …",
      "fix": "start the controller or check the network; it must answer GET https://ci.example.com/api/v1/health (…)" },
    { "name": "issuer", "ok": false, "detail": "not checked: the health check failed", "fix": "…", "skipped": true } ] }

Text mode prints one ok/FAIL/skip line per check and a fix: line under each failure. Exit: 0 when every check passed; otherwise the exit of the first failed check from the table — 2 for the configuration directory, an unknown profile, a server/profile mismatch or an issuer mismatch; 3 when no profile is configured, no credential is stored, the sign-in expired or the server refuses the grant; 6 when the controller or its token endpoint cannot be reached; 1 for an answer that is not Sentinel's — after the report, with one sentinel.error/1 line on stderr in the JSON modes (error: doctor: the … check failed; fix: … in text).

Actionable failures elsewhere. The shared client names the next step in its messages: a missing scope (403 with details.scope) becomes forbidden: the sign-in of profile P lacks the logs:read scope; sign in again asking for it: sentinel auth login --server S --profile P --scope "…" with the profile's scopes plus the missing ones; a transport failure suggests sentinel doctor; a server/profile mismatch lists its three ways out; an answer that is not JSON names the server that sent it.

Verification

crates/sentinel/tests/client.rs (portable, a fake HTTP server on loopback): each sentinel.error/1 code and a proxy's non-JSON answer map to their exit; busy answers are retried three times before exit 6; an unreachable controller exits 6; --json failures are one error document on stderr with stdout empty; a malformed credential is exit 2 with nothing sent; a static credential needs a Sentinel shape and a loopback-or-https server, and connecting sends nothing. crates/sentinel/tests/oauth_e2e.rs (portable: Windows natively and Linux) runs the real binary against an in-process controller with a temporary SENTINEL_CONFIG_DIR and the file store, and scans every stdout and stderr for token material (sntl_… followed by 16 or more hex digits, or any sntl_dc_):

  • browser login with --no-browser (the test reads the URL from stderr, consents with a password session and follows the 303 to the CLI's loopback listener), then auth status --json and run list;
  • device login approved on /device;
  • untrusted redirects (no location), plain PKCE, a foreign resource, a wrong verifier (which spends the code), a refresh token as bearer and an access token as refresh token are refused, and the CLI ignores a callback with another login's state (400, the login keeps waiting) and refuses its own callback with the wrong iss (exit 3, nothing saved, no grant created);
  • four CLI processes on a nearly expired profile all succeed with exactly one successor refresh token and the grant intact;
  • a lost refresh response recovers inside the 60 s grace window, and the same token after the window (a store call with the clock 61 s ahead, since the server's clock cannot be moved) is replay that revokes the grant; a superseded or twice-used token revokes at once over HTTP;
  • removed membership exits 3 or 4, suspension exits 3 with not signed in to S (profile P); run: sentinel auth login --server S --profile P;
  • --server B or SENTINEL_SERVER=B with profile A (for run list, auth status and doctor) exits 2 and B's listener sees zero connections; a server whose metadata names another issuer is refused at login;
  • logout revokes on the server (old access token 401, refresh token invalid_grant, local credential gone, later commands exit 3 with the login hint), and offline logout deletes locally and exits 1;
  • a service grant imported with --grant-file runs commands with stdin closed;
  • a missing scope names the login that asks for it;
  • doctor: all seven checks pass for a working profile (and rotate the refresh token once), a removed credential exits 3, a stopped controller exits 6 with local checks still run, no profile exits 3, and a configuration directory inside a Git work tree exits 2 without creating anything.

crates/sentinel/tests/cli.rs (Linux, --features server) runs the legacy commands against a real sentinel server process, and one real-server auth login --device approved on that server's /device page, followed by auth status, doctor (all checks pass) and auth logout. The sign-in flow itself is exercised through --no-browser.

crates/sentinel/tests/browser.rs (portable; Windows natively and Linux) runs the real opener path without opening a browser. The test binary has no libtest harness: it copies itself as a runner and as a stand-in under the launcher's name (rundll32.exe or xdg-open). On Linux the runner calls browser::open unchanged with the stand-in first on PATH; on Windows, where production names the launcher by its absolute System32 path, it calls the same browser::open_with/command_with code with the stand-in's path. The runner carries SENTINEL_TOKEN and other SENTINEL_* variables; the stand-in records its argv and the SENTINEL_* names it inherited (none may arrive), and exits. A pure test pins that the Windows launcher is the absolute …\System32\rundll32.exe. On Windows the stand-in decodes its command line by the MSVC rules, which the real rundll32 does not, so there the test proves that no shell ran rather than exactly what rundll32 would receive (the real authorization URL has no spaces or quotes). The runner opens a URL with ;, &, |, ^, $(…), backticks, single and double quotes, a backslash-quote, spaces, %PATH% and $HOME, plus shell canaries. On Windows the stand-in receives exactly url.dll,FileProtocolHandler and the URL; elsewhere it receives the URL alone. Either way the URL is one argv element, byte for byte, and no canary file appears. A file:, javascript: or bare URL is printed but never launched, and without launch nothing starts. A pure test also pins the command construction per platform. Two more cases cover launch failure: a launcher that cannot start (a nonexistent program on every platform, and on Linux an empty PATH so xdg-open is absent) prints the fallback line ("Could not start a browser … open the URL above yourself"), never the "browser window should open" line, exits normally, and the URL appears exactly once on stderr, never on stdout, in the error line or in any file the runner leaves behind.