Skip to content

feat(minimald,minimald-rpc): daemon bundle serving (DiagBundleTarZst) - #878

Merged
norrietaylor merged 10 commits into
mainfrom
feat/diag-unit6-daemon-bundle
Jul 22, 2026
Merged

feat(minimald,minimald-rpc): daemon bundle serving (DiagBundleTarZst)#878
norrietaylor merged 10 commits into
mainfrom
feat/diag-unit6-daemon-bundle

Conversation

@norrietaylor

@norrietaylor norrietaylor commented Jul 21, 2026

Copy link
Copy Markdown
Member

Unit 6 of the diagnostics series (spec: #802, epic: #801). Branches off main — depends on Units 1–2 and Unit 5 (#864), all merged. 1297 insertions, 25 deletions.

What

The daemon's own view, served as one zstd tar blob over the existing SSH subsystem transport, built with the same BundleWriter and the same manifest.json schema as the host bundle.

minimald-rpc (R6.1)DIAG_BUNDLE_SUBSYSTEM (minimald-v1-DiagBundleTarZst, wire contract) and #[non_exhaustive] DiagBundleRequest { log_tail_bytes, include_state_listing }; an empty JSON body decodes to the documented defaults. Mirror of the STREAM_WORKSPACE_FILES streaming pattern: the client writes one JSON request and half-closes, the daemon streams one tar.zst and closes. #[non_exhaustive] means no other crate can write a struct literal for the request, so two owned-style with_* setters ship with it — the supported way for Unit 7's client (and these tests) to depart from the defaults.

crates/minimald/src/diag.rs (R6.2) — collection and streaming through BundleWriter::stream (rootless): meta.json, tail-capped logs/, metadata-only state-listing.txt (on spawn_blocking), sessions/ records with redact_json applied, proc.txt, raw /proc/net tables, disk.json — finishing with manifest.json. Collector failures land in the manifest rather than aborting the stream.

Incident trio in-VM (R6.6)net/routes.txt (/proc/net/route + fib_trie: routing and addresses), proc/<pid>.stack.txt hang triage, proc/sockets.txt (fd→socket-inode join, the binary-free lsof), and env.json under an allowlist-plus-sensitive-key policy. All pure /proc — the microVM rootfs has no lsof/ss/ip.

Bounds (R6.3) — the request read is length-bounded (8 KiB), log_tail_bytes is clamped to a 64 MiB server ceiling (0 = daemon default), and log collection goes through add_file_tail's O_NOFOLLOW + fstat, so a symlink planted in a guest-writable log dir is refused and recorded.

Pre-stream errors (R6.4) — the request read and parse happen before the first payload byte; failures relay on extended-data stream 1 and close the channel with zero payload.

Porting deltas against the reference (77fc711e)

  • The reference hand-rolls the tar stream (Builder<ZstdEncoder<DuplexStream>>) and emits errors.json. Rebuilt on diagnostics::BundleWriter::stream; the guest bundle converges on manifest.json and errors.json is retired (nothing shipped — no compatibility window). ~150 lines of duplicated crate logic (append_bytes, read_tail, log selection, statvfs) are gone.
  • The russh channel writer is not Sync, so the duplex-pipe + tokio::io::copy pump stays; only the tar/manifest half moved into the crate.
  • The reference's inline marker matcher and its verbatim PROC_MARKERS copy are gone (R6.5): matching is diagnostics::procs, and the daemon's marker list is genuinely its own policy — inside the VM there is no minvmd, __krun-vmm or gvproxy to find.
  • The reference's guest network capture was listening tables + /proc/net/dev only; this adds routes, addresses, hang triage, the socket join, and env, closing the partial-wedge case (daemon responsive, a session child or transport binding stuck).

diagnostics extensions

Where Unit 5's merged mechanics did not cover an R6.6 need, the crate was extended rather than copied into minimald — mechanics in the crate, policy (paths, markers, destinations) in the daemon:

  • net::proc_net_tables(w, dest, name, tables) — raw /proc/net tables at a caller-named path; the private proc_net_listeners fallback now shares its text builder.
  • procs::all_processes — the whole process table, full scrubbed argv for marker-matched processes and comm alone for everyone else.
  • procs::hang_triage_including(.., always: &[u32]) — hang triage over the marker-matched family plus caller-named pids. A daemon has no marker to match itself on (argv0 is whatever it was exec'd as) and its identity is not in question, so always pids skip the pid-recycling re-pin. hang_triage delegates with an empty slice.
  • procs::open_sockets — the fd→socket-inode join against the /proc/net tables.
  • bundle::scoped — an empty dest group means bundle root, so the daemon's rootless stream does not emit absolute tar paths.

Proof Artifacts

# Artifact Status
1 Test: bundle fetched over the test harness asserts meta.json + manifest.json, session redaction, and the pre-stream error path (extended data, zero payload) — R6.1, R6.2, R6.4 diag_bundle_streams_meta_and_manifest, diag_bundle_redacts_session_records, malformed_request_reports_on_extended_data_with_no_payload
2 CLI: raw ssh -s subsystem invocation against a dev daemon returns a decompressible rootless tar.zst — R6.2 ⚠️ Not verified. Manual step against a live dev daemon; not run in this change. The equivalent path is exercised end-to-end in-process by artifact 1 (real russh transport, real dispatch, real zstd/tar decode), but the out-of-process ssh -s invocation has not been performed.
3 Test: oversized request body and oversized log_tail_bytes clamped/rejected — R6.3 oversized_request_body_is_rejected, log_tail_is_clamped_to_the_server_cap
4 Test: net/routes.txt, per-pid proc/<pid>.stack.txt for the daemon's own family, proc/sockets.txt with fd→inode-resolved sockets, env.json with a MINIMALD_TOKEN-style variable masked — R6.6 diag_bundle_captures_the_incident_trio_and_env, env_allowlist_masks_secret_shaped_names (see note below)

Note on artifact 4's planted variable. The masking claim is proved in two parts rather than by planting a live env var: env_allowlist_masks_secret_shaped_names asserts the policy rejects MINIMALD_TOKEN/MINIMALD_API_KEY/MINIMAL_AUTH_TOKEN (prefix-allowlisted names that are also secret-shaped), and the bundle test asserts that in the streamed env.json every value the policy does not allow is <redacted:len=N>. Planting the variable would need std::env::set_var, which is unsafe and racy against the parallel test harness — a data race with any concurrent vars_os reader, which masked_process_env is.

Verification

minimald cannot be built natively on macOS (its dep tree pulls procfs), so it was verified via cross, not natively:

Command Result
cross test -p minimald --target aarch64-unknown-linux-musl 155 + 3 passed, 0 failed (10 new diag::tests::* among them)
cross clippy -p minimald --all-targets --target aarch64-unknown-linux-musl -- -D warnings clean
cargo test -p minimald-rpc 15 passed
cargo test -p diagnostics 45 passed
cargo clippy -p diagnostics --all-targets -- -D warnings clean
cargo check -p minimal --lib clean (the diagnostics API change does not disturb Unit 5's host collectors)

Both cross invocations ran with CROSS_CONTAINER_OPTS="--env HOME=/tmp/xhome"; without a writable HOME some tests fail PermissionDenied on /.cache.

Refs: #801

🤖 Generated with Claude Code

Note

Add diagnostic bundle streaming over SSH subsystem in minimald

  • Adds the minimald-v1-DiagBundleTarZst SSH subsystem to minimald, streaming a zstd-compressed tar archive of diagnostic data (logs, sessions, env, proc/net/disk, kernel ring buffer) over the SSH channel.
  • Implements DiagBundleRequest in minimald-rpc with configurable log tail size and optional state listing, defaulting to safe values.
  • Adds shared helpers to the diagnostics crate: collect_step! macro for per-collector timeout/error recording, kmsg_tail for kernel ring buffer collection, all_processes/open_sockets for process and socket tables, and is_env_value_allowlisted for redaction.
  • Fixes BundleWriter to always finalize the tar+zstd encoder even when manifest serialization fails, and to reject further writes after a mid-record future cancellation.
  • Risk: bundle streaming enforces a hard 300s timeout; clients that read slowly will have the channel closed with an error on stderr.

Changes since #878 opened

  • Changed sessions async function to record missing sessions/index.json file via w.skip() and continue directory enumeration instead of returning early [66e57a4]

Macroscope summarized c5c8909.

Summary by CodeRabbit

  • New Features
    • Added a streaming “diagnostic bundle” RPC through the daemon, including logs, process and network state, session data, environment details, disk information, and kernel logs (when available).
    • Added optional state listings and configurable log-tail collection.
    • Extended coverage for Linux process/network capture, including socket mapping, plus broader kernel-ring extraction.
  • Bug Fixes
    • Prevented abandoned archive writes from producing corrupt bundles.
    • Improved behavior when kernel/system data is missing or restricted, recording clear skip reasons instead of errors.
    • Strengthened sensitive environment-value redaction and allowlisting rules.

norrietaylor and others added 3 commits July 21, 2026 14:36
The wire surface for the daemon's `min bug` contribution (R6.1):
`DIAG_BUNDLE_SUBSYSTEM` (`minimald-v1-DiagBundleTarZst`, never renamed)
and `DiagBundleRequest { log_tail_bytes, include_state_listing }`.

Streaming, not oneshot, and in the mirror direction of
`WorkspaceFilesTarZst`: the client writes one JSON request and
half-closes, the daemon streams one tar.zst and closes. An empty body
decodes to the documented defaults, so a bare `{}` probe still gets a
full bundle.

The request is `#[non_exhaustive]`, which means other crates cannot
write a struct literal for it, so the two owned-style `with_*` setters
are the supported way to depart from the defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The daemon serves its bundle from inside a microVM rootfs with no `ps`,
`lsof`, `ss` or `ip`, and Unit 5's merged mechanics assume a host that
has them. Rather than grow a private near-copy in `minimald`, the
mechanics land here and the daemon supplies the policy (R6.5, R6.6):

- `net::proc_net_tables` writes the named raw `/proc/net` tables at a
  caller-named path; `listening_sockets`' Linux fallback now shares its
  text builder instead of hardcoding the table list. `("routes",
  ["route", "fib_trie"])` is the binary-free routes-and-addresses
  capture.
- `procs::all_processes` writes the whole process table with full
  scrubbed argv for marker-matched processes and `comm` alone for
  everyone else.
- `procs::hang_triage_including` triages the marker-matched family plus
  caller-named pids. A daemon has no marker to match itself on — argv0
  is whatever it was exec'd as — and its own identity is not in
  question, so those pids skip the pid-recycling re-pin. `hang_triage`
  delegates with an empty slice.
- `procs::open_sockets` joins every `socket:[inode]` fd of the family
  against the `/proc/net` tables, keeping the matched row verbatim: the
  binary-free `lsof` equivalent for "who holds the transport socket".
- `bundle::scoped` treats an empty `dest` group as the bundle root, so
  the daemon's rootless stream cannot emit absolute tar paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The daemon's own view of an incident, streamed as one zstd tar over the
existing SSH subsystem transport and built with the same
`diagnostics::BundleWriter` and `manifest.json` schema as the host
bundle (R6.2). The dispatch arm sits beside `STREAM_WORKSPACE_FILES` in
both `handle_ssh_rpc` matches.

Contents: `meta.json` (version, uptime, microVM flag), tail-capped
`logs/` scoped to this daemon's own rotated files, metadata-only
`state-listing.txt` on `spawn_blocking`, `sessions/` records with
`redact_json` applied and read/parse failures noted in their place,
`proc.txt`, the raw `/proc/net` tables, and `disk.json` — plus, for the
partial-wedge case the previous guest capture was blind to (R6.6),
`net/routes.txt`, per-pid `proc/<pid>.stack.txt` hang triage,
`proc/sockets.txt`, and an allowlisted `env.json`. Collector failures
land in the manifest rather than aborting the stream.

Bounds (R6.3): the request read stops one byte past its limit,
`log_tail_bytes` is clamped to a server ceiling — `add_file_tail` seeks
`-(cap as i64)`, so an unclamped `u64` is a nonsense seek as well as an
unbounded read — and log collection inherits the writer's `O_NOFOLLOW`
plus fstat, so a symlink planted in a guest-writable log dir is refused
and recorded. Errors that predate the first payload byte relay on
extended-data stream 1 and close with no payload (R6.4).

The russh channel writer is not `Sync`, so the duplex-pipe plus
`tokio::io::copy` pump stays; only the tar and manifest half is the
crate's.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a min bug streaming diagnostic bundle RPC, daemon-side bundle assembly and redaction, new kernel/network/process collectors, centralized collector timeouts, and protection against abandoned tar writes.

Changes

Diagnostic bundle

Layer / File(s) Summary
Bundle reliability and shared collector contracts
crates/diagnostics/src/bundle.rs, crates/diagnostics/src/lib.rs, crates/diagnostics/src/redact.rs, crates/minimal/src/diag/*
Bundle writes detect abandoned records, collector timeout/error handling is centralized, and environment allowlisting uses shared fail-closed logic.
Kernel, network, and process collectors
crates/diagnostics/src/kmsg.rs, crates/diagnostics/src/net.rs, crates/diagnostics/src/procs.rs
Adds /dev/kmsg, generalized /proc/net, full process, expanded triage, and per-process socket capture with validation tests.
RPC contract and daemon bundle stream
crates/minimald-rpc/src/lib.rs, crates/minimald/src/diag.rs, crates/minimald/src/rpc.rs, crates/minimald/src/server.rs
Defines the diagnostic request, dispatches the SSH subsystem, streams a bounded zstd tar bundle, and collects metadata, logs, sessions, environment, process, network, disk, and optional guest data.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers: twitchyliquid64

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant SSHSubsystem
  participant DiagnosticsBuilder
  participant BundleWriter
  participant Channel
  Client->>SSHSubsystem: send DiagBundleRequest
  SSHSubsystem->>DiagnosticsBuilder: start bundle build
  DiagnosticsBuilder->>BundleWriter: collect diagnostics
  BundleWriter-->>DiagnosticsBuilder: finalized tar.zst stream
  DiagnosticsBuilder->>Channel: pump bundle bytes
  Channel-->>Client: receive diagnostic bundle
Loading

Poem

A rabbit hops through logs and kmsg bright,
Bundling secrets with care and light.
Pipes may stall, but writes now know,
Abandoned records cannot grow.
Over SSH the tar-stream flies—
A tidy diagnostic prize!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: daemon-side diagnostic bundle serving over SSH for minimald/minimald-rpc.
Description check ✅ Passed The description is detailed and covers summary and testing, but it does not follow the template headings and lacks a checklist section.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

Comment thread crates/minimald/src/diag.rs Outdated
let log_dir = state_dir.join("logs");
// Absence and inaccessibility are different facts: "no log directory" may
// only be claimed on a real NotFound, never on EACCES or an I/O error.
match tokio::fs::metadata(&log_dir).await {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High src/diag.rs:306

The log directory is resolved with tokio::fs::metadata, which follows symlinks. Because guest tasks can write the shared state volume, they can replace state_dir/logs with a symlink to an unrelated daemon-readable directory whose files happen to be named minimald.log.*; add_file_tail's O_NOFOLLOW only guards the final component of each file path, not the parent log_dir, so those unrelated files are copied into the diagnostic bundle. Open the log directory with O_NOFOLLOW | O_DIRECTORY and anchor read_dir traversal to that descriptor.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/minimald/src/diag.rs around line 306:

The log directory is resolved with `tokio::fs::metadata`, which follows symlinks. Because guest tasks can write the shared state volume, they can replace `state_dir/logs` with a symlink to an unrelated daemon-readable directory whose files happen to be named `minimald.log.*`; `add_file_tail`'s `O_NOFOLLOW` only guards the final component of each file path, not the parent `log_dir`, so those unrelated files are copied into the diagnostic bundle. Open the log directory with `O_NOFOLLOW | O_DIRECTORY` and anchor `read_dir` traversal to that descriptor.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 805727ba. Valid — and the comment sitting right above that code named this exact threat while guarding only the leaf: add_file_tail's O_NOFOLLOW refuses a symlinked log file, but nothing stopped logs/ itself from being a link, and every regular minimald.log.* under the target would then be a legitimate open.

Now resolved with symlink_metadata plus an explicit is_dir check, so a non-directory is skipped with a reason rather than followed.

Not claiming the full fix you described: this closes the persistent-symlink case, not the TOCTOU window between the check and newest_rotated's read_dir. That needs O_NOFOLLOW | O_DIRECTORY with openat-anchored traversal, which is a larger change than this PR should carry — the residual race is called out in the commit message so it is not lost.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the partial fix — the symlink_metadata + is_dir check closes the persistent-symlink case. Would you like me to address the remaining TOCTOU race (using O_NOFOLLOW | O_DIRECTORY with openat-anchored traversal) in a separate PR against main?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answering the question rather than taking the offer: no — and I don't think the residual race has an attacker.

Your finding's premise is that guest tasks can write the shared state volume. They can write part of it, and not the part that matters here. A session's sandbox gets exactly two bind mounts — home/home and working/workbench (crates/sandbox2/src/lib.rs:666-697) — and those resolve to <state>/sessions/<short>/home and <state>/sessions/<short>/tree (crates/sessions/src/store.rs:183-191). <state>/logs is not in the task's mount namespace at all, so there is no path a task can name to reach it, let alone replace it. The two other ways bytes get onto that volume are confined the same way: SFTP joins every client-supplied path to the session workspace and rejects anything that escapes the prefix (crates/minimald/src/sftp.rs:126-146), and the workspace upload unpacks through async_tar, which drops .. components outright (async-tar-0.6.1/src/entry.rs:473) and re-validates against the destination after canonicalizing.

So planting a symlink at <state>/logs requires code already running outside the sandbox — and at that point the diagnostic bundle is not the asset worth defending.

That makes what shipped in 805727ba what it is: cheap defense in depth on a path whose attacker is hypothetical, not a patch for a live hole. I'd rather label it honestly than carry a follow-up whose cost — an openat-anchored directory traversal, which in Rust means hand-rolled rustix plumbing or a new cap-std dependency — is paid against a threat neither of us can construct.

If a write primitive into the state root ever does appear (a broader mount, a wider SFTP root), the fix belongs in diagnostics::newest_rotated rather than in this daemon: every caller has the same exposure, and doing it there fixes the host-side collector in the same change. Happy to have that filed as a tracked issue against diagnostics — but not as a PR against main, and not as part of this one.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood — your security analysis is sound (verified the sandbox mount restrictions and session path confinement). The persistent-symlink case is closed, and the TOCTOU race lacks a practical attacker given the existing boundaries. The actionable part of the review has been addressed. Happy to resolve this thread if you agree.

Comment thread crates/diagnostics/src/procs.rs
Comment thread crates/minimald/src/diag.rs Outdated
Comment thread crates/minimald/src/diag.rs Outdated
Comment thread crates/minimald/src/diag.rs Outdated
norrietaylor and others added 4 commits July 21, 2026 16:03
…ntract

Unit 6 landed a second copy of two things Unit 2 already had, so the
host and daemon bundles could drift apart in exactly the places a
diagnostic bundle must not.

`collect_step!` and its 30s deadline were duplicated verbatim, doc
comment included. The failure-isolation contract — the deadline, the
manifest recording, the elapsed time — is now
`diagnostics::collect_step!`, owned once.

The two copies were not identical: the daemon logged a failed collector,
the CLI did not. `min` installs a fmt layer at info writing to
`ot::StdoutWriter` (main.rs), so adopting the daemon's version wholesale
would have started printing collector warnings into `min bug`'s output.
The shared macro therefore emits nothing, and takes an optional
`on_error` callback; the daemon attaches its own `tracing::warn!` through
a local shim, leaving both behaviors as they were.

The env-value allowlist was likewise duplicated. Only the resolving
mechanic moves — `redact::is_env_value_allowlisted`, including the
fail-closed override that makes a sensitive-shaped name lose even when
the allowlist admits it. The tables stay with each caller: this crate's
charter reserves "which env names are allowlisted" for the app.

The daemon's tables carried a rationale its own data contradicted —
"the interesting names differ" against a set identical to the CLI's,
member for member. Corrected to what is true: independent policies, free
to diverge as PROC_MARKERS already has, holding the same names today.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four findings from automated review, plus one the audit of them turned
up.

logs/ was resolved with `tokio::fs::metadata`, which follows symlinks.
`add_file_tail` refuses a symlinked log *file*, but the guest tasks that
share this volume could swap the whole directory for a link and have
every regular `minimald.log.*` under the target read out legitimately.
Resolved with `symlink_metadata` and an explicit directory check. A
swap between that check and the read is still possible; closing that
needs openat-anchored traversal.

`append_data` writes a tar header and its body in one await. Against the
daemon's bounded pipe a client that stops reading parks it mid-record,
and `collect_step!`'s deadline then drops the future — leaving a header,
possibly a partial body, and a following entry spliced into the middle
of it. A dropped future runs no code, so the guard is inverted: a flag
raised before the await and lowered after means an entry write that
never returned is visible to the next one, which refuses. A bundle that
ends short and says why beats one that arrives whole and will not parse.

`finish` could not report failure. `async_tar`'s Builder panics when
dropped unfinalized, so every error path that returned early — full
disk, broken pipe, and now an abandoned stream — aborted the process
instead. It now consumes the Builder on all paths and reports after.
This predates the diag subsystem.

Finalization was also unbounded: a client holding the channel open
without reading it stalled the pump, and through the bounded duplex,
collection and finalization behind it. The bound goes on the pump rather
than on `finish`, because cancelling a future that owns the Builder
would hit the panic above; on expiry the existing `drop(rx)` turns the
build task's next write into BrokenPipe and it unwinds normally.

`hang_triage_including`'s macOS branch built archive paths by
interpolation instead of `scoped`, which would emit a leading slash into
a rootless archive once `dest` is empty. `process_tree` had the same
latent defect in two more places. All four now go through `scoped`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…json

No bundle carried the guest kernel version. `meta.json` had the daemon's
own versions, pid, uptime and microVM flag; `diagnostics::system_info`
has a `kernel` field, but it is a host collector and shells out to
`uname`, a binary the microVM rootfs does not ship. A kernel bump under
a fixed daemon version is a whole class of "why did this start now"
(#869: 6.12.43 -> 6.12.94 turned a silent vsock condition into a fatal
connection reset), and it was invisible to every bundle.

Read it from `/proc` instead, needing no binary:

- `kernel` — `/proc/version` verbatim. The banner carries the release,
  the toolchain and the build time, and a raw capture cannot be wrong.
- `kernel_release` — the release parsed out of that banner. It rides
  beside the verbatim field rather than replacing it, so a parse that
  does not recognise its input costs a convenience and never evidence;
  it earns its place because "which release is this" is the question a
  bundle-to-bundle diff asks, and two banners full of toolchain noise
  are how that question gets answered wrongly.
- `kernel_cmdline` — `/proc/cmdline`, which now carries the forwarded
  `RUST_LOG` and is therefore how the guest's log level is set.

The boot line is world-readable inside the guest and nothing secret can
reach it today: it is `console=hvc0` plus an optional `RUST_LOG` whose
value minvmd rejects if it contains whitespace. That is an invariant
about the machine, though, not about the bundle, which leaves it — so
the line goes through the same fail-closed `key=value` scrub a process
argv does, and `meta.json` is recorded as `Redaction::Keys` rather than
a verbatim capture. `procs::scrub_flattened` is made public for it: a
kernel boot line is a space-joined command line with exactly the same
lost-boundary ambiguity.

Refs: #869
The guest bundle carried no kernel evidence at all. Kernel messages
reach the host only over `console=hvc0`, which the *host* collector
captures into `boot.log` — so a guest bundle fetched on its own has
nothing, and `boot.log` is `File::create`d per boot and tail-capped, so
the banner it opens with is the first thing a long-lived VM drops.

`diagnostics::kmsg::kmsg_tail` reads the ring buffer straight from
`/dev/kmsg`; the microVM rootfs has no `dmesg`, and it needs none — one
`read(2)` per record. The device is opened `O_RDONLY | O_NONBLOCK`,
which is what makes "read to the end" terminate at all: without it the
read that drains the buffer blocks until the kernel logs something new,
which on the quiet wedged guest this capture exists for may be never.
The end is therefore `EAGAIN`. `EPIPE` — records overwritten while the
loop runs — is not an error either: the position has already advanced
past them, and what was lost is what a tail drops anyway.

Records are kept whole under the same caller-controlled cap the log
tails honour, oldest dropped first, and recorded with the same honesty
as every other collector: collected when it read, `TailCapped` when the
cap or the ring bit, a manifest skip naming absence (no `/dev/kmsg`)
apart from unreadability (not root, or `kernel.dmesg_restrict`). None of
those is a collector failure, so a bundle from a host that will not show
its ring buffer still collects cleanly and says why it is missing.

Refs: #869
@norrietaylor

Copy link
Copy Markdown
Member Author

Two collectors added to the guest bundle, from a review of this epic against #869. Both are small and belong here rather than in a follow-up, because Unit 6 is what defines the daemon-side bundle. Nothing else in the PR is touched and it stays in draft.

Commit
e07037fc feat(minimald,diagnostics): record the guest kernel identity in meta.json
36cf3708 feat(diagnostics,minimald): tail /dev/kmsg into the guest bundle
 crates/diagnostics/src/kmsg.rs  | 247 ++++++++++++++++++++++++++++++++++++++++
 crates/diagnostics/src/lib.rs   |   3 +
 crates/diagnostics/src/procs.rs |   6 +-
 crates/minimald/src/diag.rs     | 165 ++++++++++++++++++++++++++-
 4 files changed, 419 insertions(+), 2 deletions(-)

1. The guest kernel version was in no bundle at all

The whole "why did this start now" of #869 is that the guest kernel moved 6.12.43 → 6.12.94 and crossed a release that turned a silent vsock condition into a fatal connection reset. No bundle carried that fact. meta.json had version, long_version, stdlib_version, pid, uptime_secs, in_microvm — no kernel. diagnostics::system_info does have a kernel field, but it is a host collector and shells out to uname, which the microVM rootfs does not ship.

Three fields added to the meta collector, all read from /proc directly — no external binary:

Field Source
kernel /proc/version verbatim
kernel_release the release parsed out of that banner
kernel_cmdline /proc/cmdline, key-scrubbed

Raw and parsed, not either/or

The banner stays verbatim and the parse rides beside it. Keeping only the banner is more honest and cheaper, but "which release is this" is precisely the question a bundle-to-bundle diff asks, and eyeballing two lines full of toolchain and build-time noise is how that question gets answered wrongly. Keeping only the parse would let a bad parse destroy evidence.

Riding beside it, the parse can only ever cost a convenience:

fn kernel_release(banner: &str) -> Option<&str> {
    let mut tokens = banner.split_whitespace();
    match (tokens.next(), tokens.next(), tokens.next()) {
        (Some(_sysname), Some("version"), release) => release,
        _ => None,
    }
}

The kernel writes that banner from one fixed format string (linux_proc_banner, "%s version %s …" over utsname()->sysname and ->release), so the third token is the release whenever the second is literally version. Anything else yields None rather than a guess.

/proc/cmdline — checked before collecting it verbatim, then wired to the redaction engine anyway

Confirmed against main: the guest boot line is BASE_KERNEL_CMDLINE (console=hvc0) plus an optional RUST_LOG=<value> from #872, whose kernel_cmdline rejects an empty value, any value containing whitespace, and anything that would overrun COMMAND_LINE_SIZE. Nothing secret can reach it today, and #872's own doc comment states the invariant explicitly.

But that is an invariant about this machine — where /proc/cmdline is world-readable and therefore not a secret from anyone already inside the guest — not about the bundle, which leaves the machine and gets mailed to whoever is helping. A boot parameter added later that happens to be secret-shaped would otherwise ship verbatim. So the line goes through the same fail-closed key=value scrub a process argv does, and meta.json is now recorded as Redaction::Keys rather than a verbatim capture. procs::scrub_flattened was made pub for it — a kernel boot line is a space-joined command line with exactly the same lost-boundary ambiguity ps output has, so it wants that rule and not a new one.

Sample output

meta.json, the new fields (values as collected under a real Linux kernel in the cross test container; in the guest the banner is the microVM kernel and the boot line is console=hvc0 plus the forwarded filter):

{
  "kernel": "Linux version 6.12.76-linuxkit (root@buildkitsandbox) (gcc (Alpine 15.2.0) 15.2.0, GNU ld (GNU Binutils) 2.45.1) #1 SMP Fri May  1 14:35:41 UTC 2026",
  "kernel_release": "6.12.76-linuxkit",
  "kernel_cmdline": "init=/initd loglevel=1 root=/dev/vdb rootfstype=erofs ro vsyscall=emulate panic=0 eth0.dhcp eth1.dhcp linuxkit.unified_cgroup_hierarchy=1 console=hvc0 virtio_net.disable_csum=1 slub_min_order=2 page_reporting.page_reporting_order=2 vpnkit.connect=connect://2/1999 com.docker.VMID=a2621183-8326-4da9-8856-0a7fa2c03882"
}

and the manifest entry for it:

{ "path": "meta.json", "redaction": "keys", "bytes": 1131 }

Had #869's two kernels each produced a bundle, kernel_release alone would have read 6.12.43 and 6.12.94.


2. The guest bundle carried no kernel evidence whatsoever

Kernel messages reach the host only via console=hvc0boot.log, which the host bundle collects, so a guest bundle fetched on its own had nothing. And boot.log is File::created per boot and tail-capped, so the kernel banner — the first thing written — is the first thing dropped on a long-lived VM. The one artefact that would have named the kernel is the one that ages out first.

diagnostics::kmsg::kmsg_tail reads the ring buffer straight from /dev/kmsg into logs/kmsg.txt, beside the daemon's own logs and under the same caller-controlled log_tail_bytes cap (so the existing R6.3 clamp bounds it too). The microVM rootfs has no dmesg and needs none: one read(2) per record.

O_RDONLY | O_NONBLOCK, read until EAGAIN. The non-blocking flag is what makes "read to the end" terminate at all — without it the read that drains the buffer blocks until the kernel logs something new, which on the quiet wedged guest this capture exists for may be never. The end of the buffer is EAGAIN, not EOF. EPIPE is not an error either: it means records were overwritten while the loop ran, the file position has already advanced past them, and what was lost is exactly what a tail drops anyway.

Manifest honesty, matching the other log collectors:

Outcome Recorded as
read collected, redaction: none
capped, or the ring wrapped under us collected, redaction: tail-capped
no /dev/kmsg skipped"no /dev/kmsg — this kernel does not expose the ring buffer here"
present but unreadable (not root, kernel.dmesg_restrict) skipped"unreadable: <errno>"

None of those is a collector failure, so a bundle from a host that will not show its ring buffer still collects cleanly and explains the gap. kernel_ring_buffer_is_collected_or_explained asserts the archive and the manifest agree on which of the two happened — silence is not an option either way.

Records are kept whole when the cap bites (oldest dropped first): half a kmsg record is a corrupt line rather than a shorter one, and a single record larger than the whole cap is kept rather than losing the newest message.

Sample output

logs/kmsg.txt is the kernel's raw record format — priority,sequence,timestamp,flags;message — not decoded, for the same reason net/routes.txt keeps the raw /proc/net tables: the sequence numbers are themselves evidence of what the ring dropped. Sampled from a live Linux ring buffer:

6,0,0,-;Booting Linux on physical CPU 0x0000000000 [0x610f0000]
5,1,0,-;Linux version 6.12.76-linuxkit (root@buildkitsandbox) (gcc (Alpine 15.2.0) 15.2.0, GNU ld (GNU Binutils) 2.45.1) #1 SMP Fri May  1 14:35:41 UTC 2026
6,2,0,-;OF: reserved mem: Reserved memory: No reserved-memory node in the DT
6,3,0,-;Zone ranges:

Note record 1 — the banner, which is exactly what a tail-capped boot.log loses first.

/dev/kmsg does not exist in the cross test container, so the test run there exercises the absent path and the manifest carries:

{ "what": "logs/kmsg.txt",
  "reason": "no /dev/kmsg — this kernel does not expose the ring buffer here" }

Spec amendments needed

Neither addition changes the bundle layout incompatibly, so schema_version stays at 1 — R1.3's bump condition is not met, both are additive. But R6.2 should be amended on #802 rather than left to diverge, on two points:

  1. R6.2's meta.json enumeration currently reads "meta.json (version, uptime, microVM flag)". It should name the kernel identity — kernel / kernel_release / kernel_cmdline — because the guest kernel is a fact no other collector in the spec captures (Unit 2's system_info is host-side and uname-based), and because meta.json is no longer a verbatim capture: the boot line makes it Redaction::Keys, which is R1.5's contract talking.
  2. R6.2's entry list should name logs/kmsg.txt. The spec's threat/evidence discussion already leans on "capture (boot.log) independent of the volume" for the wedged-daemon case; that argument has a hole the spec does not state — boot.log is host-side, per-boot and tail-capped, so the guest half of the kernel picture is unavailable from a guest bundle and ages out of the host one. Worth stating and closing in the spec, not only in code.

Neither is a Unit 8 concern: R8.1 keys guest-bundle detection on meta.json presence and R8.2 reads errors from manifest.json, so both additions are transparent to it.

Verification

minimald cannot be built natively on macOS, so it was verified via cross, as this PR already does. Both cross invocations ran with CROSS_CONTAINER_OPTS="--env HOME=/tmp".

Command Result
cross test -p minimald --target aarch64-unknown-linux-musl 159 + 3 passed, 0 failed (was 155 + 3; the 4 new tests are diag::tests::*)
cross clippy -p minimald --all-targets --target aarch64-unknown-linux-musl -- -D warnings clean
cross test -p diagnostics --target aarch64-unknown-linux-musl 51 passed, 0 failed
cross clippy -p diagnostics --all-targets --target aarch64-unknown-linux-musl -- -D warnings clean
cargo test -p diagnostics (macOS host) 49 passed, 0 failed
cargo clippy -p diagnostics --all-targets -- -D warnings (macOS host) clean
cargo check -p minimal --lib (macOS host) clean — the diagnostics additions do not disturb Unit 5's host collectors

New tests:

test diag::tests::meta_carries_the_guest_kernel_identity ... ok
test diag::tests::kernel_release_reads_the_proc_version_banner ... ok
test diag::tests::kernel_cmdline_is_scrubbed_before_it_travels ... ok
test diag::tests::kernel_ring_buffer_is_collected_or_explained ... ok

test kmsg::tests::kmsg_is_either_collected_or_explained ... ok
test kmsg::tests::record_tail_drops_whole_records_from_the_oldest_end ... ok
test kmsg::tests::record_tail_keeps_a_record_larger_than_the_cap ... ok
test kmsg::tests::record_tail_under_the_cap_drops_nothing ... ok

kernel_release_reads_the_proc_version_banner parses #869's two banners specifically (6.12.43 and 6.12.94).

Gating

/proc/version, /proc/cmdline and /dev/kmsg are Linux-only. meta's /proc reads follow the collector's existing best-effort shape (Option, alongside the /proc/uptime and /proc/mounts reads already there) rather than a cfg, so nothing new is Linux-conditional in minimald. In diagnostics, kmsg is a #[cfg(unix)] module whose reader is Linux-only with a non-Linux skip arm — the same shape procs::all_processes already has — so crates/minimal (which builds on macOS and does not call it) is unaffected, and the pure RecordTail tests still run on the macOS host.

Not verified

No live guest fetch: the in-VM values were not observed on a running microVM, which was deliberately left alone. What is proven is the collectors' behaviour under a real Linux kernel in the cross container, plus the parse against #869's literal banners.

@norrietaylor
norrietaylor marked this pull request as ready for review July 22, 2026 04:54
@norrietaylor

Copy link
Copy Markdown
Member Author

@macroscope review

@macroscopeapp

macroscopeapp Bot commented Jul 22, 2026

Copy link
Copy Markdown

Manual reviews triggered for commit 36cf370:

All prior checks · these links stay valid even if you push more commits.

@macroscopeapp

macroscopeapp Bot commented Jul 22, 2026

Copy link
Copy Markdown

Just FYI for future @mentions, I'm Macroscope-App, not Macroscope.

Review is underway. Feedback will be posted through the check runs on this PR when they complete:

Comment thread crates/minimald/src/diag.rs Outdated
Comment thread crates/minimald/src/diag.rs
@macroscopeapp

macroscopeapp Bot commented Jul 22, 2026

Copy link
Copy Markdown

Approvability

Verdict: Needs human review

3 blocking correctness issues found. This PR adds a new daemon diagnostic bundle streaming feature with significant new code and security-sensitive patterns. Three high-severity unresolved review comments identify potential vulnerabilities (symlink following in log directory, missing request timeout, unbounded file reads) that warrant human attention before merging.

You can customize Macroscope's approvability policy. Learn more.

Three findings from the second review round on the daemon bundle, all in
the collectors added by this change.

The request read had a size bound but no deadline. `read_to_end` waits
for EOF, not for bytes, so a client that writes half an object and holds
the channel open parks the handler for the life of the connection.
Nothing else reclaims it: the daemon runs `inactivity_timeout: None` by
design, and SSH keepalives only detect a peer that has stopped
answering, not one that answers and stays silent. `STREAM_TIMEOUT` sits
downstream and is never reached. `read_request` now runs under a 30s
`REQUEST_TIMEOUT`; cancelling it is safe because, unlike `finish`, it
owns no `async_tar::Builder` whose Drop would panic. It takes the reader
and the deadline as parameters so the deadline is exercisable against a
plain `AsyncRead`.

`sessions` read `index.json` and each `record.json` whole with
`tokio::fs::read` — unbounded, and symlink-following. The reported cause
(a guest task plants a huge file) does not hold: a session's sandbox
binds only `home` and `tree` from under `sessions/<short>/`, so
`record.json` one level up is outside its mount namespace, SFTP rejects
paths escaping the workspace prefix, and the workspace upload refuses
`..`. The bound is still worth having without an attacker — it was the
last caller-triggered read in this subsystem without one, in a pid-1
process with a fixed RAM budget. Both reads now go through
`open_regular_nofollow` + `take` at 8 MiB. A record cannot be
tail-capped, since it must arrive whole to parse and redact, so
exceeding the cap is a refusal that names its bound. Routing through
`open_regular_nofollow` also closes the symlink case, unraised in
review, with O_NOFOLLOW rather than a check-then-open.

`sessions` also had three silent exits where the manifest contract
requires an explanation: a failed `read_dir`, a failed `next_entry`
mid-walk, and a failed `file_type` folded into "not a directory" by
`is_ok_and`. All three now record a skip naming what will be missing.
`NotFound` stays silent, because no sessions directory is a fact rather
than an omission.

Verified with `cross test -p minimald --target aarch64-unknown-linux-musl`
(163 passed, 0 failed) and matching `cross clippy --all-targets -- -D
warnings` (clean). The unlistable-directory test builds its fixture with
mode 0o111 and skips itself under a root test runner, where the mode
bits would not bite; reproduction under the cross container was
confirmed by temporarily asserting the fixture held.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@norrietaylor

Copy link
Copy Markdown
Member Author

Review round worked — 4 High, 3 Medium

All seven threads are answered in place. Four were fixed in 805727ba before this round; three are fixed here in ba4d9508; one of those three had been marked resolved while still live. One finding is rebutted: its effect is real but its stated cause is not, so I've argued the severity down rather than quietly implementing the suggestion.

Finding Verdict Origin
diag.rs:306 🟠 log dir resolved with metadata, follows symlinks Partial fix stands, follow-up declined — no attacker for the residual TOCTOU This PR
diag.rs:92 🟠 collect_step! drops $fut mid-tar-record Fixed (805727ba) This PR
diag.rs:166 🟠 read_request has no deadline Fixed (ba4d9508) This PR
diag.rs:481 🟠 unbounded tokio::fs::read of session files Cause rebutted; bound added anyway (ba4d9508) This PR
diag.rs:84 🟡 non-reading client blocks finalization Fixed (805727ba) This PR
diag.rs:388 🟡 sessions swallows a read_dir failure Fixed (ba4d9508) — was wrongly marked resolved This PR
procs.rs:285 🟡 macOS branch bypasses scoped Fixed (805727ba) Half pre-existing

Which of these this PR introduced. Everything except the procs.rs scoped defect lands on the bundle-serving code this PR adds (bc36afa7). Nothing in the round touches the two collectors stacked on top of the original review base — the guest kernel identity in meta.json and the /dev/kmsg tail. The procs.rs finding is the split one: hang_triage_including is new here, but the two identical sites in process_tree shipped in Unit 5 (#864) and were surfaced by this round rather than introduced by it — all four sites were fixed together because splitting them would have left a known defect on main to be re-found later.

The one I pushed back on

:481 rests on "the state directory is writable by guest tasks". It isn't, in the part that matters. A session's sandbox receives two bind mounts, home/home and working/workbench (sandbox2/src/lib.rs:666-697), which resolve to <state>/sessions/<short>/home and .../tree (sessions/src/store.rs:183-191). record.json is their sibling one level up — outside the task's mount namespace, unnameable from inside the sandbox. SFTP rejects any client path escaping the workspace prefix (minimald/src/sftp.rs:126-146), and the workspace upload unpacks through async_tar, which drops .. components (async-tar-0.6.1/src/entry.rs:473). There is no write primitive, so there is no multi-gigabyte plant and no OOM.

The same premise underpins :306, which is why I declined the offered follow-up PR for the logs/ TOCTOU instead of accepting it. An openat-anchored traversal means hand-rolled rustix plumbing or a new cap-std dependency, spent against a threat that cannot be constructed against this deployment. The symlink_metadata check that shipped stays as what it honestly is — cheap defense in depth, not a patch for a live hole. If a write primitive into the state root ever appears, the fix belongs in diagnostics::newest_rotated, where it also covers the host collector.

I bounded the session reads regardless, because the half of :481 that needs no attacker stands on its own: it was the last caller-triggered read in this subsystem without a bound, in a change whose stated contract (R6.3) is that they all have one, in a pid-1 process with a fixed RAM budget. Routing it through open_regular_nofollow also closed a symlink hole nobody had raised, for free.

Verification

minimald cannot be built natively on macOS (its dep tree pulls procfs), so as with the rest of this PR it was verified through cross:

$ CROSS_CONTAINER_OPTS="--env HOME=/tmp" cross test -p minimald --target aarch64-unknown-linux-musl
test result: ok. 163 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 83.73s
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s

$ ... cross clippy -p minimald --all-targets --target aarch64-unknown-linux-musl -- -D warnings
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 18s

Four new tests, all passing:

test diag::tests::a_request_that_never_ends_is_abandoned_on_the_deadline ... ok
test diag::tests::a_symlinked_session_record_is_refused ... ok
test diag::tests::an_oversized_session_record_is_refused_not_buffered ... ok
test diag::tests::an_unlistable_sessions_dir_is_recorded_not_silent ... ok

Two caveats on that evidence, so it is not read as more than it is:

  • an_unlistable_sessions_dir_is_recorded_not_silent builds its fixture from mode bits (sessions/ at 0o111), which root ignores, so it skips itself when it detects the fixture did not take. Reproduction under the cross container was confirmed by temporarily replacing the skip with an assertion and watching it pass; the skip is back in place for the benefit of any root test runner.
  • Docker died partway through this round and had to be restarted. The first cross invocation afterwards exited 0 while doing nothing at allcross reports success when it cannot reach the Docker daemon. Worth knowing before trusting a green cross run: check for a test result: line, not an exit code.

Comment thread crates/minimald/src/diag.rs Outdated
…n-bundle

# Conflicts:
#	crates/diagnostics/src/lib.rs
…#913)

Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/diagnostics/src/procs.rs`:
- Around line 397-444: The socket enumeration in socket_join must revalidate
each triage_pids result with still_matches before reading /proc/{pid}/fd. Skip
any pid that no longer matches its original marker/identity, while preserving
socket processing for validated pids to prevent recycled processes from
contributing unrelated descriptors.

In `@crates/minimald/src/diag.rs`:
- Around line 63-68: Replace the whole-stream STREAM_TIMEOUT enforcement around
the bundle copy with an idle-based timeout, or set it above the maximum
sequential collector budget so slow-but-progressing bundles are not truncated.
Update the timeout error handling to avoid unconditionally blaming the peer and
accurately describe a stalled or exceeded transfer deadline.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 21052174-ec95-4694-abab-562b2f350fa9

📥 Commits

Reviewing files that changed from the base of the PR and between 132369f and c5c8909.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • crates/diagnostics/src/bundle.rs
  • crates/diagnostics/src/kmsg.rs
  • crates/diagnostics/src/lib.rs
  • crates/diagnostics/src/net.rs
  • crates/diagnostics/src/procs.rs
  • crates/diagnostics/src/redact.rs
  • crates/minimal/src/diag/mod.rs
  • crates/minimal/src/diag/redact.rs
  • crates/minimald-rpc/src/lib.rs
  • crates/minimald/Cargo.toml
  • crates/minimald/src/diag.rs
  • crates/minimald/src/lib.rs
  • crates/minimald/src/rpc.rs
  • crates/minimald/src/server.rs

Comment on lines +397 to +444
#[cfg(target_os = "linux")]
async fn socket_join<W: BundleSink>(
w: &mut BundleWriter<W>,
path: &str,
markers: &[&str],
always: &[u32],
) -> Result<(), anyhow::Error> {
use std::fmt::Write as _;

let pids = triage_pids(markers, always).await?;
if pids.is_empty() {
w.skip(path, "no marker-matched processes holding sockets");
return Ok(());
}
let mut tables = Vec::with_capacity(crate::net::PROC_NET_SOCKET_TABLES.len());
for table in crate::net::PROC_NET_SOCKET_TABLES {
if let Ok(text) = tokio::fs::read_to_string(format!("/proc/net/{table}")).await {
tables.push((*table, text));
}
}
let sockets = index_by_inode(&tables);

let mut text = String::from("pid\tfd\tinode\ttable\tentry\n");
for (pid, _) in pids {
let Ok(mut entries) = tokio::fs::read_dir(format!("/proc/{pid}/fd")).await else {
let _ = writeln!(text, "{pid}\t-\t-\t-\t<fd directory unreadable>");
continue;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let Ok(target) = tokio::fs::read_link(entry.path()).await else {
continue;
};
let Some(inode) = socket_inode(&target.to_string_lossy()) else {
continue; // not a socket fd — files and pipes are `stack.txt`'s job
};
let fd = entry.file_name().to_string_lossy().into_owned();
let (table, row) = sockets
.get(&inode)
.map(|(t, l)| (t.as_str(), l.as_str()))
// A socket with no table row is itself a finding: it belongs to
// a family /proc/net does not list (netlink) or to a network
// namespace this process cannot see.
.unwrap_or(("?", "<no /proc/net entry>"));
let _ = writeln!(text, "{pid}\t{fd}\t{inode}\t{table}\t{row}");
}
}
w.add_bytes(path, text.as_bytes(), Redaction::None).await
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C3 'still_matches|from_snapshot|triage_pids' crates/diagnostics/src/procs.rs

Repository: gominimal/minimal

Length of output: 2417


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '240,470p' crates/diagnostics/src/procs.rs
printf '\n--- tests ---\n'
sed -n '720,820p' crates/diagnostics/src/procs.rs

Repository: gominimal/minimal

Length of output: 10630


Re-pin snapshot pids before walking /proc/{pid}/fd. socket_join still treats triage_pids snapshot pids as live identities, but it never calls still_matches before reading fd links. A recycled pid can splice an unrelated process’s sockets into sockets.txt.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/diagnostics/src/procs.rs` around lines 397 - 444, The socket
enumeration in socket_join must revalidate each triage_pids result with
still_matches before reading /proc/{pid}/fd. Skip any pid that no longer matches
its original marker/identity, while preserving socket processing for validated
pids to prevent recycled processes from contributing unrelated descriptors.

Comment on lines +63 to +68
/// Ceiling on the whole client-facing transfer. Every collector is deadlined
/// individually, but those deadlines only bound work — a client that holds the
/// channel open and stops reading it parks the pump instead, and through the
/// bounded duplex that stalls collection and finalization behind it. This is
/// the bound on the peer rather than on ourselves.
const STREAM_TIMEOUT: Duration = Duration::from_secs(300);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP 'COLLECTOR_TIMEOUT\s*:\s*Duration' crates/diagnostics/src
rg -nP 'macro_rules!\s+collect_step' -A25 crates/diagnostics/src
rg -nP 'collect_step!\(' -c crates/minimald/src/diag.rs

Repository: gominimal/minimal

Length of output: 2044


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- crates/minimald/src/diag.rs outline ---'
ast-grep outline crates/minimald/src/diag.rs --view expanded || true

echo
echo '--- relevant ranges in crates/minimald/src/diag.rs ---'
nl -ba crates/minimald/src/diag.rs | sed -n '1,260p'

echo
echo '--- collect_step call sites in crates/minimald/src/diag.rs ---'
rg -n 'collect_step!\(' crates/minimald/src/diag.rs

echo
echo '--- STREAM_TIMEOUT usage ---'
rg -n 'STREAM_TIMEOUT|tokio::time::timeout|tokio::io::copy|finish\(' crates/minimald/src/diag.rs

Repository: gominimal/minimal

Length of output: 4031


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- stream wrapper and bundle assembly ---'
sed -n '123,270p' crates/minimald/src/diag.rs

echo
echo '--- collector call sites in build_bundle ---'
rg -n 'collect_step!\(' crates/minimald/src/diag.rs

echo
echo '--- timeout and copy path ---'
sed -n '140,190p' crates/minimald/src/diag.rs

echo
echo '--- collector timeout definition ---'
sed -n '1,120p' crates/diagnostics/src/lib.rs

Repository: gominimal/minimal

Length of output: 13832


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '245,320p' crates/minimald/src/diag.rs | cat -n

Repository: gominimal/minimal

Length of output: 3668


Stream timeout can cut off slow-but-progressing bundles

tokio::time::timeout(STREAM_TIMEOUT, tokio::io::copy(...)) caps the whole bundle stream, not just reader idleness. build_bundle runs 13 sequential collectors here (14 when net.gvproxy is enabled), and each collector already gets up to COLLECTOR_TIMEOUT, so a legitimate run can exceed 300s even while the client is reading. The timeout message also blames the peer unconditionally.

Consider making this idle-based, raising it above the maximum collector budget, or softening the error when the stream stalls for other reasons.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/minimald/src/diag.rs` around lines 63 - 68, Replace the whole-stream
STREAM_TIMEOUT enforcement around the bundle copy with an idle-based timeout, or
set it above the maximum sequential collector budget so slow-but-progressing
bundles are not truncated. Update the timeout error handling to avoid
unconditionally blaming the peer and accurately describe a stalled or exceeded
transfer deadline.

Source: Learnings

@norrietaylor
norrietaylor merged commit c9d664c into main Jul 22, 2026
29 checks passed
@norrietaylor
norrietaylor deleted the feat/diag-unit6-daemon-bundle branch July 22, 2026 17:53
@bryan-minimal

Copy link
Copy Markdown
Member

Heads-up: main is red as of this merge — E0271 in crates/minimald/src/rpc.rs (:665, :770, :788): a connection future is expected to resolve to Result<(), ConnectionError> but resolves to (). Looks like a semantic conflict with #895 (merged 3 minutes earlier, also touching rpc.rs): each PR was green on its own base, the combination doesn't type-check. Failing main run: https://github.com/gominimal/minimal/actions/runs/29945701386 — a forward fix reconciling the two should clear it (also currently failing all open PR merge-ref builds, e.g. #912).

norrietaylor added a commit that referenced this pull request Jul 22, 2026
#918)

PR #895 changed the serve! dispatch so every handler returns
Result<(), ConnectionError> for outcome logging; PR #878 merged
alongside it with serve_stream_diag_bundle still returning (). The two
were each green against a main that lacked the other, and the type
mismatch only surfaced on branches built after both landed.

Failure still relays the message over the channel's extended-data
stream before surfacing as ConnectionError::Internal, mirroring
serve_stream_workspace_files.

Refs: #878, #895

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
norrietaylor added a commit that referenced this pull request Jul 22, 2026
…880)

* feat(minimal): guest fetch and degraded-mode fallback for `min bug`

Per provider, `min bug` now performs the staged socket probe and — when
the probe handshakes — downloads the daemon's own bundle over the
DiagBundleTarZst subsystem, nesting it under providers/<name>/guest/.
--no-guest skips daemon contact entirely; --guest-timeout-secs bounds
each provider's download. Host-side log-prefix skips are deferred until
the provider loop settles whether the daemon's logs reached the bundle
another way, so the manifest never claims an absence the archive does
not back.

Squashed rebuild of the original branch onto main after #878 landed
there, replacing the merge-heavy history whose #889 squash title also
failed commitlint.

Refs: #802

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(minimald): return the served contract from the diag bundle handler

PR #895 changed the serve! dispatch so every handler returns
Result<(), ConnectionError> for outcome logging; PR #878 merged
alongside it with serve_stream_diag_bundle still returning (). The two
were each green against a main that lacked the other, and the type
mismatch only surfaced on branches built after both landed.

Failure still relays the message over the channel's extended-data
stream before surfacing as ConnectionError::Internal, mirroring
serve_stream_workspace_files.

Refs: #878, #895

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants