Skip to content

Errors & credentials

The error model: exit code is the category

Every exception the SDK raises from a process failure derives from the exit code alone — never from matching stderr text, which is exactly the kind of heuristic that breaks quietly when a message's wording changes upstream. OcxError is the root; every subclass's __str__ carries an actionable next step, not just a fact, because the message is what shows up in a CI log and a bare fact leaves the reader guessing what to do about it.

from ocx_sdk import DataError

error = DataError(65, ["ocx", "add", "--bad-flag"], stderr="unexpected identifier")
assert "next step" not in str(error)  # every message names one; this just isn't it verbatim
assert "stderr:" in str(error)

Two catch shapes cover almost everything:

  • except OcxExecutionError — a process that failed and one that never finished (a non-zero exit and a timeout share this parent), the shape most callers actually want.
  • except OcxProcessError — a non-zero exit specifically, with .exit_code (a plain int, because a signal-killed process exits with a code ocx never assigns — 137 for SIGKILL, say — and building an error object must never itself raise) and .retryable, which reports what ocx said about the failure (fixed: True only for exit 75), independent of whatever RetryPolicy a caller happened to pass.

The full exit-code-to-exception table, and which codes retry by default, lives on Environment & exit codes. BootstrapError and its children (DownloadError, ChecksumMismatchError, DistManifestError, UnsupportedPlatformError) are a separate, non-process branch under OcxError — failures while resolving, downloading, or installing a binary, before any typed command ever runs.

The error envelope

Since ocx 0.6.1 a hard failure that printed no report prints a structured error envelope on stdout instead — {schema_version, command, exit_code, error: {kind, detail?, message, remediation?, context}}, a contract frozen separately from the report schemas. The exit code is still the category; the envelope is the detail, and error_envelope is how a caller reads it off a caught OcxProcessError:

from ocx_sdk import DataError, error_envelope

error = DataError(
    65,
    ["ocx", "package", "claim", "acme/widget"],
    stderr="",
    stdout='{"schema_version": 1, "command": "package claim", "exit_code": 65, '
    '"error": {"kind": "data_error", "detail": "package_already_claimed", '
    '"message": "package already claimed: p/acme/widget.json exists on main for acme/widget", "context": {}}}',
)
envelope = error_envelope(error)
assert envelope is not None
assert (envelope.kind, envelope.detail) == ("data_error", "package_already_claimed")

None when stdout is empty, not JSON, or a report — the dual of partial_report, which recovers the report a report-then-fail command wrote; a failure never carries both. kind is the exit-code category's serde name; detail is the fine-grained variant slug, frozen by ocx so a caller can branch on it — "package_already_claimed" is how an idempotent CI step recognizes the claim it already made. Not every failure path assigns one yet, so treat detail as optional; message is for humans and never a branch.

Exit 86, ForgeCapabilityUnavailableError, joins the table with 0.6.1: a forge write whose credential is valid but whose target refuses job-token push. It is never retryable — the fix is an administrator's allowlist entry, not another attempt.

Verification makes install and pull fail where they used to pass

ocx 0.6 verifies a package's Sigstore signature before installing it, whenever a [[trust.policy]] in the host's config.toml covers that package. This SDK's 0.2.0 floor bump to ocx 0.6.0 is what puts that gate in every caller's path, so package.install and package.pull can now raise where the identical call succeeded before — no code change on your side, only a newer binary underneath. The failures are the ordinary exit-code-mapped ones: DataError (65) for a signature that did not hold up, PermissionDeniedError (77) for an identity or issuer the policy refuses, NotFoundError (79) when a covered package carries no signature at all, and TransparencyLogUnavailableError (83) when Rekor is unreachable. None of them retry by default — the default retry_on is exit 75 alone — and 83 in particular is deliberately excluded, because retrying a transparency-log outage amplifies it for everyone else.

The trust policy is ambient host state: it lives in a config file the SDK does not own, read, or write, so the SDK cannot tell you in advance whether the gate applies to your packages. That is why this is documented rather than detected. To find out, run the install and catch the error.

The verify parameter on both methods is a three-state override — True demands the check, False (--no-verify) skips it, None leaves ocx's own default, which is on. True is not enforcement: against a package no policy covers there is nothing to verify against, and the flag is a documented no-op rather than a promise that unsigned content will be refused. False is the only way to skip verification through this SDK: the ambient OCX_NO_VERIFY escape hatch is neutralized on every spawn, so an opt-out has to be visible at the call site rather than in whatever exported a variable three CI layers up.

PackageRef: carried, never parsed

ocx owns the package-identifier grammar. PackageRef stores whatever identifier string it was given and hands it back byte for byte — the SDK never re-derives or re-validates the grammar itself:

from ocx_sdk import PackageRef

ref = PackageRef("ocx.sh/astral-sh/uv:0.9.7")
carried_forward = PackageRef("ocx.sh/astral-sh/uv:0.9.7", metadata={"source": "lock"})

assert str(ref) == "ocx.sh/astral-sh/uv:0.9.7"
assert ref == carried_forward  # identity is the identifier alone; metadata is decoration

Identity is the identifier string alone — metadata is whatever context came along with the ref from the JSON row that produced it, so two refs to the same package parsed out of two different commands must compare equal even when their metadata differs. Any parameter typed PackageLike accepts either a bare str or a PackageRef and coerces with str().

Credential handling

One vocabulary for both bootstrap and runtime auth: BasicAuth(user, password) and BearerAuth(token) — None elsewhere means anonymous, matching ocx's own type set. Their secret fields are field(repr=False), with a hand-written __repr__ that masks the value — the dataclass-generated repr would otherwise leak into logs, tracebacks, and pytest diffs:

from ocx_sdk import BasicAuth, BearerAuth

assert repr(BasicAuth("ci", "hunter2")) == "BasicAuth(user='ci', password=***)"
assert repr(BearerAuth("ghp_secret")) == "BearerAuth(token=***)"

Beyond the repr mask, every secret value the SDK composed into a spawn environment is exact-string-redacted from captured stderr, on_log lines, logged argv, and exception text — the choke point is _env.build_spawn_env(), which returns the finished environment paired with a redact callable that _process applies to every outbound surface before it leaves the SDK. A caller who puts a token into invoke's raw argv gets it scrubbed the same way, even though doing so is documented against.

One caveat the redaction can't reach: at log_level="trace", ocx itself may print secrets it holds that never passed through the SDK's own composition — the scrub only catches values the SDK was given.

Blast radius under exec

ocx does not scrub non-forwarded environment variables from a spawned child — so a tool started through Project.exec or package.exec inherits OCX_AUTH_*, whatever set it to (ambient environment or explicit OcxConfig.auth). This is pinned by a contract test against the real binary specifically so an upstream change to that behavior breaks loudly rather than silently. The credential-free pattern: authenticate for the pull(), then run the build step through a config with credentials cleared —

# illustrative: needs a real Project handle.
project.pull()                                       # needs the token
project.with_config(auth={}).exec(["task", "build"])  # the build step doesn't see it

— covered in full, with the rest of the hermetic-CI threat model, in Hermetic CI.

Persistent credentials

Ocx.login writes credentials through ocx's own store — the token travels on stdin via --password-stdin, never in argv, and is redacted for the call's duration like any other secret. OcxConfig.docker_config points ocx at an isolated credential store directory; keep it 0700, since it holds registry credentials on disk.