Field-diagnostics support bundle#
hpx doctor --bundle exists so a customer or field engineer can hand a
maintainer one sanitized archive instead of a screen-share, without either
side worrying it contains a model, firmware source, a credential, or a
device identifier that shouldn't leave the building.
Design goals#
- Never fail outright. A missing
--workspace, an unresolved--config, absent hardware, or a host with no network access must each degrade exactly the section that depends on them — the rest of the bundle still gets collected. SeeSupportBundleSection.available/.reasoninresults/support_bundle.py. - Safe by default. Absolute paths, URL credentials/tokens, common
credential/token shapes, secret-looking
KEY=VALUEassignments, and device serial numbers are redacted unless a caller explicitly opts out (--raw-probe-ids, which also prints a warning). Seeredact.py. - Reuse, don't reparse. Dependency lock provenance is read through the
Stage 5
read_dependency_lock_provenance()provider — the collector never re-parsesnsx.lockitself, and never resolves, synchronizes, or mutates a workspace. - Deterministic. Two bundles built from identical inputs produce
byte-identical members (except
manifest.json'sgenerated_attimestamp) and the same archive file name, so a diff between two bundles is a diff between two environments, not two invocations. - A distinct, versioned contract. The bundle's manifest
(
hpx.support-bundle-manifest, schema v1 —SupportBundleManifest) is notResultManifest: a support bundle has noRunStatus/ResultValidity/ comparability concept, since it isn't a profiling run. It reusesResultArtifactfor member entries because that shape (content-addressed path/size/sha256) is identical either way.
What is collected#
| Section | Source | Always available? |
|---|---|---|
checks |
doctor.inspect_environment(include_versions=True) |
Yes |
compatibility |
compatibility.load_compatibility_baseline() |
Yes (offline) |
dependencies / nsx.lock |
dependencies.read_dependency_lock_provenance() + a sanitized/redacted copy of the nsx.lock text |
Only with --workspace |
modules |
Baseline-qualified modules, plus exact resolved modules from the same provenance read | Yes (resolved half only with --workspace) |
config |
config.load_config() + pipeline._serialize_config() |
Only with --config |
probes |
target.probe.jlink.list_connected_probes() |
Unless --no-probes |
ports |
transport.ports.list_serial_ports() |
Unless --no-ports |
Host info (platform.system()/.release()/.machine()/.python_version(),
sys.platform) is embedded in the manifest directly. platform.node()
(hostname) and any username are deliberately excluded — neither is needed to
diagnose a toolchain/build problem, and both are more identifying than the
default redaction policy is designed to catch.
What is never collected#
Models, firmware/generated sources, ELF/binary build outputs, raw
proprietary payloads, credentials, and secret environment values are never
read or embedded — not merely redacted after the fact. The archive writer
and verify_support_bundle() both enforce a strict allow-list: every member
must be named exactly nsx.lock or end in .json; anything else — a zip
entry engineered to look like a model or binary, an absolute path, a ..
traversal — is rejected before any bytes are trusted.
Redaction#
See redact.py for the full pattern set. In short:
- Absolute paths keep only their final path component
(
/Users/alice/model.tflite-><redacted-path>/model.tflite) -- except a path that resolves to a home directory itself (/Users/alice,C:\Users\alice), whose final component is the account name, so nothing is kept there. - URL credentials (both the
user:password@hostform and a single bearer-style credential with no colon) are stripped, and every query-parameter value is redacted by default except a narrow allow-list of clearly non-sensitive names -- the same credential/token-shape and secret-assignment passes described below also run over URL text, so a token embedded in a URL's path or query is caught the same way it would be in plain text.file://URLs additionally get their path component run through path redaction. - Known credential/token shapes (GitHub PAT, AWS access key, Slack token, JWT, an HTTP bearer credential) are replaced wherever they appear, including inside a URL.
- Secret-looking
KEY=VALUE/KEY: VALUEtext assignments are replaced, and -- structurally, not just in free text -- any JSON mapping value whose key looks secret-shaped (api_key,NSX_SECRET, ...) is always replaced regardless of the value's own shape. Mapping keys themselves are also redacted. - Device serial numbers are redacted structurally (by field name --
serial,serial_number, ...) rather than by digit-pattern matching, to avoid false positives on ordinary counters and sizes, and by literal substitution everywhere else a known serial value recurs (for example a USBhwidstring'sSER=<serial>marker, or a device path whose basename is derived from it) so a sibling field can't leak the same value structural redaction already caught elsewhere.
Every redaction pass returns a RedactionCounts, summed into the
manifest's redaction object so a reviewer can see what categories of text
were found and rewritten, and how many times. Treat this as an audit
trail of what redaction did, not as a certificate that a bundle contains
nothing sensitive -- review a bundle before sharing it as you would any
other diagnostic output.
--raw-probe-ids is the one explicit opt-out: it keeps real probe/port
serial numbers in the bundle and prints a warning to make the choice hard to
miss in a script or CI log.
Deterministic archiving#
write_support_bundle() writes a zipfile.ZIP_STORED (uncompressed) archive
with:
- members sorted lexicographically,
manifest.jsonalways last; - a fixed
(1980, 1, 1, 0, 0, 0)timestamp andcreate_system = 0on every entry, so the archive doesn't encode the build host's clock or OS; - a filename derived from
content_fingerprint()— a SHA-256 over every other member's(name, sha256)pairs, deliberately excludingmanifest.json(the one member whose content always differs run to run, viagenerated_at).
Storage is deliberately uncompressed rather than ZIP_DEFLATED: DEFLATE's
exact output bytes depend on the zlib version/build doing the compressing,
which this module doesn't pin, so two hosts with different zlib builds
could otherwise produce different archive bytes for identical input. Since
bundle members are small JSON/text (not something worth compressing),
storing them uncompressed makes the byte-identical-members guarantee true
across hosts instead of only within one.
verify_support_bundle() re-derives and checks every declared artifact's
size and SHA-256, requires the declared and actual member sets match
exactly, and rejects unsafe member paths (absolute, ../empty segments,
backslashes, NUL bytes, duplicates) before trusting anything else in the
archive — defense in depth against a corrupted or hand-edited archive, even
though this module is the only thing that ever writes one.
Deferred / follow-up#
A validation-matrix "bundle on failure" hook (automatically attaching a
support bundle to a failed hpx validate case) was considered but not
implemented here to avoid conflicting with validation/matrix.py's existing
bundling and to keep this change scoped to hpx doctor. It remains a
natural follow-up once this collector has shipped.