feat(minimald,minimald-rpc): daemon bundle serving (DiagBundleTarZst) - #878
Conversation
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>
📝 WalkthroughWalkthroughAdds a ChangesDiagnostic bundle
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: 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
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
Comment |
| 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 { |
There was a problem hiding this comment.
🟠 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…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
|
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.
1. The guest kernel version was in no bundle at allThe 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. Three fields added to the
Raw and parsed, not either/orThe 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 (
|
| 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:
- R6.2's
meta.jsonenumeration 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'ssystem_infois host-side anduname-based), and becausemeta.jsonis no longer a verbatim capture: the boot line makes itRedaction::Keys, which is R1.5's contract talking. - 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.logis 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.
|
@macroscope review |
|
Manual reviews triggered for commit All prior checks · these links stay valid even if you push more commits. |
|
Just FYI for future @mentions, I'm Review is underway. Feedback will be posted through the check runs on this PR when they complete: |
ApprovabilityVerdict: 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>
Review round worked — 4 High, 3 MediumAll seven threads are answered in place. Four were fixed in
Which of these this PR introduced. Everything except the The one I pushed back on
The same premise underpins I bounded the session reads regardless, because the half of Verification
Four new tests, all passing: Two caveats on that evidence, so it is not read as more than it is:
|
…n-bundle # Conflicts: # crates/diagnostics/src/lib.rs
…#913) Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (14)
crates/diagnostics/src/bundle.rscrates/diagnostics/src/kmsg.rscrates/diagnostics/src/lib.rscrates/diagnostics/src/net.rscrates/diagnostics/src/procs.rscrates/diagnostics/src/redact.rscrates/minimal/src/diag/mod.rscrates/minimal/src/diag/redact.rscrates/minimald-rpc/src/lib.rscrates/minimald/Cargo.tomlcrates/minimald/src/diag.rscrates/minimald/src/lib.rscrates/minimald/src/rpc.rscrates/minimald/src/server.rs
| #[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 | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -nP -C3 'still_matches|from_snapshot|triage_pids' crates/diagnostics/src/procs.rsRepository: 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.rsRepository: 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.
| /// 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); |
There was a problem hiding this comment.
🩺 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.rsRepository: 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.rsRepository: 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.rsRepository: gominimal/minimal
Length of output: 13832
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '245,320p' crates/minimald/src/diag.rs | cat -nRepository: 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
|
Heads-up: main is red as of this merge — E0271 in |
#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>
…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>
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
BundleWriterand the samemanifest.jsonschema 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 theSTREAM_WORKSPACE_FILESstreaming 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-stylewith_*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 throughBundleWriter::stream(rootless):meta.json, tail-cappedlogs/, metadata-onlystate-listing.txt(onspawn_blocking),sessions/records withredact_jsonapplied,proc.txt, raw/proc/nettables,disk.json— finishing withmanifest.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.txthang triage,proc/sockets.txt(fd→socket-inode join, the binary-freelsof), andenv.jsonunder an allowlist-plus-sensitive-key policy. All pure/proc— the microVM rootfs has nolsof/ss/ip.Bounds (R6.3) — the request read is length-bounded (8 KiB),
log_tail_bytesis clamped to a 64 MiB server ceiling (0= daemon default), and log collection goes throughadd_file_tail'sO_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)Builder<ZstdEncoder<DuplexStream>>) and emitserrors.json. Rebuilt ondiagnostics::BundleWriter::stream; the guest bundle converges onmanifest.jsonanderrors.jsonis retired (nothing shipped — no compatibility window). ~150 lines of duplicated crate logic (append_bytes,read_tail, log selection,statvfs) are gone.Sync, so the duplex-pipe +tokio::io::copypump stays; only the tar/manifest half moved into the crate.PROC_MARKERScopy are gone (R6.5): matching isdiagnostics::procs, and the daemon's marker list is genuinely its own policy — inside the VM there is nominvmd,__krun-vmmorgvproxyto find./proc/net/devonly; 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).diagnosticsextensionsWhere 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/nettables at a caller-named path; the privateproc_net_listenersfallback now shares its text builder.procs::all_processes— the whole process table, full scrubbed argv for marker-matched processes andcommalone 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, soalwayspids skip the pid-recycling re-pin.hang_triagedelegates with an empty slice.procs::open_sockets— the fd→socket-inode join against the/proc/nettables.bundle::scoped— an emptydestgroup means bundle root, so the daemon's rootless stream does not emit absolute tar paths.Proof Artifacts
meta.json+manifest.json, session redaction, and the pre-stream error path (extended data, zero payload) — R6.1, R6.2, R6.4diag_bundle_streams_meta_and_manifest,diag_bundle_redacts_session_records,malformed_request_reports_on_extended_data_with_no_payloadssh -ssubsystem invocation against a dev daemon returns a decompressible rootless tar.zst — R6.2ssh -sinvocation has not been performed.log_tail_bytesclamped/rejected — R6.3oversized_request_body_is_rejected,log_tail_is_clamped_to_the_server_capnet/routes.txt, per-pidproc/<pid>.stack.txtfor the daemon's own family,proc/sockets.txtwith fd→inode-resolved sockets,env.jsonwith aMINIMALD_TOKEN-style variable masked — R6.6diag_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_namesasserts the policy rejectsMINIMALD_TOKEN/MINIMALD_API_KEY/MINIMAL_AUTH_TOKEN(prefix-allowlisted names that are also secret-shaped), and the bundle test asserts that in the streamedenv.jsonevery value the policy does not allow is<redacted:len=N>. Planting the variable would needstd::env::set_var, which isunsafeand racy against the parallel test harness — a data race with any concurrentvars_osreader, whichmasked_process_envis.Verification
minimaldcannot be built natively on macOS (its dep tree pullsprocfs), so it was verified viacross, not natively:cross test -p minimald --target aarch64-unknown-linux-musldiag::tests::*among them)cross clippy -p minimald --all-targets --target aarch64-unknown-linux-musl -- -D warningscargo test -p minimald-rpccargo test -p diagnosticscargo clippy -p diagnostics --all-targets -- -D warningscargo check -p minimal --libdiagnosticsAPI change does not disturb Unit 5's host collectors)Both
crossinvocations ran withCROSS_CONTAINER_OPTS="--env HOME=/tmp/xhome"; without a writableHOMEsome tests failPermissionDeniedon/.cache.Refs: #801
🤖 Generated with Claude Code
Note
Add diagnostic bundle streaming over SSH subsystem in minimald
minimald-v1-DiagBundleTarZstSSH subsystem tominimald, streaming a zstd-compressed tar archive of diagnostic data (logs, sessions, env, proc/net/disk, kernel ring buffer) over the SSH channel.DiagBundleRequestinminimald-rpcwith configurable log tail size and optional state listing, defaulting to safe values.diagnosticscrate:collect_step!macro for per-collector timeout/error recording,kmsg_tailfor kernel ring buffer collection,all_processes/open_socketsfor process and socket tables, andis_env_value_allowlistedfor redaction.BundleWriterto always finalize the tar+zstd encoder even when manifest serialization fails, and to reject further writes after a mid-record future cancellation.Changes since #878 opened
sessionsasync function to record missingsessions/index.jsonfile viaw.skip()and continue directory enumeration instead of returning early [66e57a4]Macroscope summarized c5c8909.
Summary by CodeRabbit