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 plainint, because a signal-killed process exits with a code ocx never assigns —137forSIGKILL, say — and building an error object must never itself raise) and.retryable, which reports what ocx said about the failure (fixed:Trueonly for exit75), independent of whateverRetryPolicya 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.