diff --git a/crates/kernel/src/kernel.rs b/crates/kernel/src/kernel.rs index abb5ab2481..830ea02700 100644 --- a/crates/kernel/src/kernel.rs +++ b/crates/kernel/src/kernel.rs @@ -426,12 +426,14 @@ enum ProcNode { CpuInfoFile, MemInfoFile, LoadAvgFile, + StatFile, UptimeFile, VersionFile, SelfLink { pid: u32 }, PidDir { pid: u32 }, PidFdDir { pid: u32 }, PidCmdline { pid: u32 }, + PidComm { pid: u32 }, PidEnviron { pid: u32 }, PidCwdLink { pid: u32 }, PidStatFile { pid: u32 }, @@ -3767,15 +3769,20 @@ impl KernelVm { self.filesystem .check_virtual_path(FsOperation::Read, path) .map_err(KernelError::from)?; - return Ok(self - .proc_read_dir(current_pid, &proc_node)? - .into_iter() - .map(|name| VirtualDirEntry { + let mut typed = Vec::new(); + for name in self.proc_read_dir(current_pid, &proc_node)? { + let child_path = join_child_path(&normalize_path(path), &name); + let child = self + .resolve_proc_node(&child_path, current_pid)? + .ok_or_else(|| proc_not_found_error(&child_path))?; + let filetype = proc_filetype(&child); + typed.push(VirtualDirEntry { name, - is_directory: false, - is_symbolic_link: false, - }) - .collect()); + is_directory: filetype == FILETYPE_DIRECTORY, + is_symbolic_link: filetype == FILETYPE_SYMBOLIC_LINK, + }); + } + return Ok(typed); } Ok(self.filesystem.read_dir_with_types(path)?) @@ -3819,6 +3826,7 @@ impl KernelVm { ["cpuinfo"] => Some(ProcNode::CpuInfoFile), ["meminfo"] => Some(ProcNode::MemInfoFile), ["loadavg"] => Some(ProcNode::LoadAvgFile), + ["stat"] => Some(ProcNode::StatFile), ["uptime"] => Some(ProcNode::UptimeFile), ["version"] => Some(ProcNode::VersionFile), _ => None, @@ -3840,6 +3848,7 @@ impl KernelVm { [_pid] => ProcNode::PidDir { pid }, [_pid, "fd"] => ProcNode::PidFdDir { pid }, [_pid, "cmdline"] => ProcNode::PidCmdline { pid }, + [_pid, "comm"] => ProcNode::PidComm { pid }, [_pid, "environ"] => ProcNode::PidEnviron { pid }, [_pid, "cwd"] => ProcNode::PidCwdLink { pid }, [_pid, "stat"] => ProcNode::PidStatFile { pid }, @@ -3887,9 +3896,11 @@ impl KernelVm { ProcNode::CpuInfoFile => Ok(self.proc_cpuinfo_bytes()), ProcNode::MemInfoFile => Ok(self.proc_meminfo_bytes()), ProcNode::LoadAvgFile => Ok(self.proc_loadavg_bytes()), + ProcNode::StatFile => Ok(self.proc_system_stat_bytes()), ProcNode::UptimeFile => Ok(self.proc_uptime_bytes()), ProcNode::VersionFile => Ok(self.proc_version_bytes()), ProcNode::PidCmdline { pid } => Ok(self.proc_cmdline_bytes(*pid)), + ProcNode::PidComm { pid } => Ok(self.proc_comm_bytes(*pid)), ProcNode::PidEnviron { pid } => Ok(self.proc_environ_bytes(*pid)), ProcNode::PidStatFile { pid } => Ok(self.proc_stat_bytes(*pid)), ProcNode::PidStatusFile { pid } => Ok(self.proc_status_bytes(*pid)), @@ -3942,6 +3953,10 @@ impl KernelVm { proc_inode(node), self.proc_loadavg_bytes().len() as u64, )), + ProcNode::StatFile => Ok(proc_file_stat( + proc_inode(node), + self.proc_system_stat_bytes().len() as u64, + )), ProcNode::UptimeFile => Ok(proc_file_stat( proc_inode(node), self.proc_uptime_bytes().len() as u64, @@ -3954,6 +3969,10 @@ impl KernelVm { proc_inode(node), self.proc_cmdline_bytes(*pid).len() as u64, )), + ProcNode::PidComm { pid } => Ok(proc_file_stat( + proc_inode(node), + self.proc_comm_bytes(*pid).len() as u64, + )), ProcNode::PidEnviron { pid } => Ok(proc_file_stat( proc_inode(node), self.proc_environ_bytes(*pid).len() as u64, @@ -4014,6 +4033,7 @@ impl KernelVm { entries.push(String::from("meminfo")); entries.push(String::from("mounts")); entries.push(String::from("self")); + entries.push(String::from("stat")); entries.push(String::from("uptime")); entries.push(String::from("version")); entries.sort(); @@ -4021,6 +4041,7 @@ impl KernelVm { } ProcNode::PidDir { .. } => Ok(vec![ String::from("cmdline"), + String::from("comm"), String::from("cwd"), String::from("environ"), String::from("fd"), @@ -4080,12 +4101,14 @@ impl KernelVm { ProcNode::CpuInfoFile => String::from("/proc/cpuinfo"), ProcNode::MemInfoFile => String::from("/proc/meminfo"), ProcNode::LoadAvgFile => String::from("/proc/loadavg"), + ProcNode::StatFile => String::from("/proc/stat"), ProcNode::UptimeFile => String::from("/proc/uptime"), ProcNode::VersionFile => String::from("/proc/version"), ProcNode::SelfLink { pid } => format!("/proc/{pid}"), ProcNode::PidDir { pid } => format!("/proc/{pid}"), ProcNode::PidFdDir { pid } => format!("/proc/{pid}/fd"), ProcNode::PidCmdline { pid } => format!("/proc/{pid}/cmdline"), + ProcNode::PidComm { pid } => format!("/proc/{pid}/comm"), ProcNode::PidEnviron { pid } => format!("/proc/{pid}/environ"), ProcNode::PidCwdLink { pid } => format!("/proc/{pid}/cwd"), ProcNode::PidStatFile { pid } => format!("/proc/{pid}/stat"), @@ -4118,24 +4141,86 @@ impl KernelVm { ) } + fn proc_comm_bytes(&self, pid: u32) -> Vec { + let entry = self + .processes + .get(pid) + .expect("process must exist while procfs path is resolved"); + let mut bytes = proc_task_comm(&entry.command).into_bytes(); + bytes.push(b'\n'); + bytes + } + fn proc_stat_bytes(&self, pid: u32) -> Vec { let entry = self .processes .get(pid) .expect("process must exist while procfs path is resolved"); - let command = entry.command.replace(')', "]"); + let command = proc_task_comm(&entry.command); let state = match entry.status { ProcessStatus::Running => 'R', ProcessStatus::Stopped => 'T', ProcessStatus::Exited => 'Z', }; - format!( - "{pid} ({command}) {state} {ppid} {pgid} {sid} 0 0 0 0 0 0 0 0 0 0 20 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0", - ppid = entry.ppid, - pgid = entry.pgid, - sid = entry.sid, - ) - .into_bytes() + // Linux's exact field order and unescaped parenthesized comm come from + // proc_pid_stat(5) and fs/proc/array.c::do_task_stat(). procps-ng's + // library/readproc.c consumes this positionally and finds the final + // ") " delimiter, so rewriting ')' inside comm corrupts real names. + // https://man7.org/linux/man-pages/man5/proc_pid_stat.5.html + // https://github.com/torvalds/linux/blob/master/fs/proc/array.c + // https://gitlab.com/procps-ng/procps/-/blob/master/library/readproc.c + let tail = [ + entry.ppid.to_string(), // 4 ppid + entry.pgid.to_string(), // 5 pgrp + entry.sid.to_string(), // 6 session + String::from("0"), // 7 tty_nr + String::from("-1"), // 8 tpgid + String::from("0"), // 9 flags + String::from("0"), // 10 minflt + String::from("0"), // 11 cminflt + String::from("0"), // 12 majflt + String::from("0"), // 13 cmajflt + String::from("0"), // 14 utime + String::from("0"), // 15 stime + String::from("0"), // 16 cutime + String::from("0"), // 17 cstime + String::from("20"), // 18 priority + String::from("0"), // 19 nice + String::from("1"), // 20 num_threads + String::from("0"), // 21 itrealvalue + String::from("0"), // 22 starttime + String::from("0"), // 23 vsize + String::from("0"), // 24 rss + String::from("0"), // 25 rsslim + String::from("0"), // 26 startcode + String::from("0"), // 27 endcode + String::from("0"), // 28 startstack + String::from("0"), // 29 kstkesp + String::from("0"), // 30 kstkeip + String::from("0"), // 31 signal + String::from("0"), // 32 blocked + String::from("0"), // 33 sigignore + String::from("0"), // 34 sigcatch + String::from("0"), // 35 wchan + String::from("0"), // 36 nswap + String::from("0"), // 37 cnswap + String::from("17"), // 38 exit_signal (SIGCHLD) + String::from("0"), // 39 processor + String::from("0"), // 40 rt_priority + String::from("0"), // 41 policy + String::from("0"), // 42 delayacct_blkio_ticks + String::from("0"), // 43 guest_time + String::from("0"), // 44 cguest_time + String::from("0"), // 45 start_data + String::from("0"), // 46 end_data + String::from("0"), // 47 start_brk + String::from("0"), // 48 arg_start + String::from("0"), // 49 arg_end + String::from("0"), // 50 env_start + String::from("0"), // 51 env_end + entry.exit_code.unwrap_or(0).to_string(), // 52 exit_code + ]; + format!("{pid} ({command}) {state} {}\n", tail.join(" ")).into_bytes() } fn proc_mounts_bytes(&self) -> Vec { @@ -4207,6 +4292,31 @@ impl KernelVm { format!("0.00 0.00 0.00 {running}/{total} {last_pid}\n").into_bytes() } + fn proc_system_stat_bytes(&self) -> Vec { + // proc_stat(5) defines USER_HZ CPU columns, btime, process counters, + // and runnable/blocked counts. procps consumes these labels directly. + // https://man7.org/linux/man-pages/man5/proc_stat.5.html + // https://gitlab.com/procps-ng/procps/-/blob/master/library/stat.c + let cpu_count = self.proc_cpu_count(); + let idle_ticks = (self.boot_instant.elapsed().as_secs_f64() * 100.0) as u64; + let aggregate_idle = idle_ticks.saturating_mul(cpu_count as u64); + let processes = self.processes.list_processes(); + let running = processes + .values() + .filter(|process| process.status == ProcessStatus::Running) + .count(); + let mut body = format!("cpu 0 0 0 {aggregate_idle} 0 0 0 0 0 0\n"); + for cpu in 0..cpu_count { + body.push_str(&format!("cpu{cpu} 0 0 0 {idle_ticks} 0 0 0 0 0 0\n")); + } + body.push_str(&format!( + "intr 0\nctxt 0\nbtime {}\nprocesses {}\nprocs_running {running}\nprocs_blocked 0\nsoftirq 0 0 0 0 0 0 0 0 0 0 0\n", + self.boot_time_ms / 1000, + processes.len(), + )); + body.into_bytes() + } + fn proc_uptime_bytes(&self) -> Vec { let uptime = self.boot_instant.elapsed().as_secs_f64(); format!("{uptime:.2} {uptime:.2}\n").into_bytes() @@ -4234,7 +4344,7 @@ impl KernelVm { "Name:\t{name}\nState:\t{state_code} ({state_name})\nPid:\t{pid}\nPPid:\t{ppid}\nUid:\t{uid}\t{euid}\t{euid}\t{euid}\nGid:\t{gid}\t{egid}\t{egid}\t{egid}\nVmSize:\t{:>8} kB\nVmRSS:\t{:>9} kB\nThreads:\t1\n", 0, 0, - name = entry.command, + name = proc_status_task_name(&entry.command), ppid = entry.ppid, uid = entry.identity.uid, euid = entry.identity.euid, @@ -5101,9 +5211,11 @@ fn proc_filetype(node: &ProcNode) -> u8 { | ProcNode::CpuInfoFile | ProcNode::MemInfoFile | ProcNode::LoadAvgFile + | ProcNode::StatFile | ProcNode::UptimeFile | ProcNode::VersionFile | ProcNode::PidCmdline { .. } + | ProcNode::PidComm { .. } | ProcNode::PidEnviron { .. } | ProcNode::PidStatFile { .. } | ProcNode::PidStatusFile { .. } => FILETYPE_REGULAR_FILE, @@ -5117,20 +5229,37 @@ fn proc_inode(node: &ProcNode) -> u64 { ProcNode::CpuInfoFile => 0xfffe_0003, ProcNode::MemInfoFile => 0xfffe_0004, ProcNode::LoadAvgFile => 0xfffe_0005, - ProcNode::UptimeFile => 0xfffe_0006, - ProcNode::VersionFile => 0xfffe_0007, + ProcNode::StatFile => 0xfffe_0006, + ProcNode::UptimeFile => 0xfffe_0007, + ProcNode::VersionFile => 0xfffe_0008, ProcNode::SelfLink { pid } => 0xfffe_1000 + u64::from(*pid), ProcNode::PidDir { pid } => 0xfffe_2000 + u64::from(*pid), ProcNode::PidFdDir { pid } => 0xfffe_3000 + u64::from(*pid), ProcNode::PidCmdline { pid } => 0xfffe_4000 + u64::from(*pid), - ProcNode::PidEnviron { pid } => 0xfffe_5000 + u64::from(*pid), - ProcNode::PidCwdLink { pid } => 0xfffe_6000 + u64::from(*pid), - ProcNode::PidStatFile { pid } => 0xfffe_7000 + u64::from(*pid), - ProcNode::PidStatusFile { pid } => 0xfffe_8000 + u64::from(*pid), + ProcNode::PidComm { pid } => 0xfffe_5000 + u64::from(*pid), + ProcNode::PidEnviron { pid } => 0xfffe_6000 + u64::from(*pid), + ProcNode::PidCwdLink { pid } => 0xfffe_7000 + u64::from(*pid), + ProcNode::PidStatFile { pid } => 0xfffe_8000 + u64::from(*pid), + ProcNode::PidStatusFile { pid } => 0xfffe_9000 + u64::from(*pid), ProcNode::PidFdLink { pid, fd } => 0xffff_0000 + ((u64::from(*pid)) << 8) + u64::from(*fd), } } +fn proc_task_comm(command: &str) -> String { + let basename = command.rsplit('/').next().unwrap_or(command); + let mut end = basename.len().min(15); + while !basename.is_char_boundary(end) { + end -= 1; + } + basename[..end].to_owned() +} + +fn proc_status_task_name(command: &str) -> String { + proc_task_comm(command) + .replace('\\', "\\\\") + .replace('\n', "\\n") +} + fn null_separated_bytes(parts: Vec) -> Vec { if parts.is_empty() { return Vec::new(); diff --git a/crates/kernel/tests/api_surface.rs b/crates/kernel/tests/api_surface.rs index 23d7651670..6fa6e0f22f 100644 --- a/crates/kernel/tests/api_surface.rs +++ b/crates/kernel/tests/api_surface.rs @@ -1023,6 +1023,16 @@ fn proc_filesystem_exposes_live_process_metadata_and_fd_symlinks() { assert!(proc_entries.contains(&String::from("mounts"))); assert!(proc_entries.contains(&process.pid().to_string())); + let proc_typed = kernel + .read_dir_with_types_for_process("shell", process.pid(), "/proc") + .expect("read typed /proc"); + assert!(proc_typed + .iter() + .any(|entry| { entry.name == process.pid().to_string() && entry.is_directory })); + assert!(proc_typed + .iter() + .any(|entry| entry.name == "self" && entry.is_symbolic_link)); + assert_eq!( kernel .read_link_for_process("shell", process.pid(), "/proc/self") diff --git a/crates/kernel/tests/fixtures/procfs/generate_linux_task_name.py b/crates/kernel/tests/fixtures/procfs/generate_linux_task_name.py new file mode 100755 index 0000000000..8a16b6cf2a --- /dev/null +++ b/crates/kernel/tests/fixtures/procfs/generate_linux_task_name.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Regenerate linux-*-task-name.json on a real Linux host.""" + +import ctypes +import json +import os +import platform + + +TASK_NAME = "agent)os\nproc" +PR_SET_NAME = 15 + +libc = ctypes.CDLL(None) +if libc.prctl(PR_SET_NAME, TASK_NAME.encode(), 0, 0, 0) != 0: + raise OSError(ctypes.get_errno(), "prctl(PR_SET_NAME) failed") + +pid = os.getpid() +with open(f"/proc/{pid}/comm", encoding="utf-8") as source: + comm = source.read() +with open(f"/proc/{pid}/stat", encoding="utf-8") as source: + stat = source.read() +with open(f"/proc/{pid}/status", encoding="utf-8") as source: + status = source.read() + +right_paren = stat.rfind(") ") +status_name = next( + line.split("\t", 1)[1] for line in status.splitlines() if line.startswith("Name:\t") +) +print( + json.dumps( + { + "source": f"{platform.system()} {platform.release()} {platform.machine()}", + "task_name_input": TASK_NAME, + "comm": comm, + "stat_comm": stat[stat.find(" (") + 2 : right_paren], + "status_name": status_name, + }, + indent=2, + ) +) diff --git a/crates/kernel/tests/fixtures/procfs/linux-6.1-task-name.json b/crates/kernel/tests/fixtures/procfs/linux-6.1-task-name.json new file mode 100644 index 0000000000..9a035cdaba --- /dev/null +++ b/crates/kernel/tests/fixtures/procfs/linux-6.1-task-name.json @@ -0,0 +1,7 @@ +{ + "source": "Linux 6.1.0-41-amd64 x86_64", + "task_name_input": "agent)os\nproc", + "comm": "agent)os\nproc\n", + "stat_comm": "agent)os\nproc", + "status_name": "agent)os\\nproc" +} diff --git a/crates/kernel/tests/identity.rs b/crates/kernel/tests/identity.rs index 541245114c..eba4c94542 100644 --- a/crates/kernel/tests/identity.rs +++ b/crates/kernel/tests/identity.rs @@ -159,6 +159,7 @@ fn procfs_exposes_linux_like_identity_and_system_files() { assert!(proc_entries.contains(&String::from("meminfo"))); assert!(proc_entries.contains(&String::from("mounts"))); assert!(proc_entries.contains(&String::from("self"))); + assert!(proc_entries.contains(&String::from("stat"))); assert!(proc_entries.contains(&String::from("uptime"))); assert!(proc_entries.contains(&String::from("version"))); assert!(proc_entries.contains(&pid.to_string())); @@ -167,6 +168,7 @@ fn procfs_exposes_linux_like_identity_and_system_files() { .read_dir(&format!("/proc/{pid}")) .expect("read /proc/"); assert!(pid_entries.contains(&String::from("status"))); + assert!(pid_entries.contains(&String::from("comm"))); let status = read_utf8(&mut kernel, &format!("/proc/{pid}/status")); let self_status = @@ -213,4 +215,57 @@ fn procfs_exposes_linux_like_identity_and_system_files() { .stat(&format!("/proc/{pid}/status")) .expect("stat proc status"); assert_eq!(status_stat.size, status.len() as u64); + + let system_stat = read_utf8(&mut kernel, "/proc/stat"); + assert!(system_stat.starts_with("cpu ")); + assert!(system_stat.contains("\ncpu0 ")); + assert!(system_stat.contains("\nbtime ")); + assert!(system_stat.contains("\nprocs_running 1\n")); +} + +#[test] +fn procfs_task_names_match_captured_real_linux_fixture() { + // Captured by fixtures/procfs/generate_linux_task_name.py on the real-Linux + // host named in the fixture. This covers fs/proc/array.c's intentionally + // different escaping rules for comm, stat, and status. + let fixture: serde_json::Value = + serde_json::from_str(include_str!("fixtures/procfs/linux-6.1-task-name.json")) + .expect("parse captured Linux procfs fixture"); + let task_name = fixture["task_name_input"] + .as_str() + .expect("fixture task name"); + let mut kernel = configured_kernel(); + let process = kernel + .create_virtual_process( + "identity-driver", + "identity-driver", + task_name, + Vec::new(), + VirtualProcessOptions::default(), + ) + .expect("create fixture-named process"); + let pid = process.pid(); + + assert_eq!( + read_utf8(&mut kernel, &format!("/proc/{pid}/comm")), + fixture["comm"].as_str().expect("fixture comm") + ); + let stat = read_utf8(&mut kernel, &format!("/proc/{pid}/stat")); + let (stat_comm, stat_tail) = stat + .strip_prefix(&format!("{pid} (")) + .and_then(|body| body.rsplit_once(") ")) + .expect("parse emitted stat comm"); + assert_eq!( + stat_comm, + fixture["stat_comm"].as_str().expect("fixture stat comm") + ); + assert_eq!(stat_tail.split_whitespace().count(), 50); + + let status = read_utf8(&mut kernel, &format!("/proc/{pid}/status")); + assert_eq!( + parse_status_fields(&status)["Name"], + fixture["status_name"] + .as_str() + .expect("fixture status name") + ); } diff --git a/docs-internal/registry-networking-handoff.md b/docs-internal/registry-networking-handoff.md index 7a6878fcd5..c8985ef734 100644 --- a/docs-internal/registry-networking-handoff.md +++ b/docs-internal/registry-networking-handoff.md @@ -10,10 +10,9 @@ Linux semantics**, make the **codex build reproducible**, and then continue into - ssh is built and its default suite is **8/8 green**, including real git-over-SSH clone + push through the runtime's synthetic-pipe/host-net poll path (§4). -- the kernel already has a **partial synthetic procfs**, but it has not met this - program's acceptance bar: no authoritative-format citations at the emitters, - no real-Linux golden fixture, no real procps-ng/psmisc packages, and no real - `ps`/`pgrep`/`pkill` e2e (§5). +- the kernel procfs runtime surface is complete and backed by authoritative + citations plus a captured real-Linux task-name fixture. Real procps-ng/psmisc + packages and `ps`/`pgrep`/`pkill` e2e remain next (§5). - the Node replacement spec is ready in the separate `node-stdlib` workspace; its final backend decision is real OpenSSL 3.5.5 wasm, not an invented mbedTLS-backed OpenSSL façade (§5). @@ -162,12 +161,13 @@ pnpm --dir software/ssh test # with AGENTOS_SIDECAR_BIN pinned (see § 1. **DONE — close git-over-SSH:** the runtime synthetic-pipe + host-net poll/flush bug is fixed, `AGENTOS_SSH_GIT_E2E` is removed, and real clone + push pass by default with a pinned `AGENTOS_SIDECAR_BIN` (§4). -2. **Finish `/proc` from the existing partial implementation:** retain the - process-table-backed design in `crates/kernel/src/kernel.rs`, then add the - missing consumer surface (`/proc//comm`, `/proc/stat`, and any further - fields/files demonstrated necessary by procps-ng), correct `stat` field order - and `comm` escaping, and cite `proc(5)`, Linux `fs/proc/array.c`, and procps-ng - `readproc.c` at the emitters. +2. **DONE — finish `/proc` from the existing partial implementation:** the + process-table-backed design now provides `/proc//comm`, `/proc/stat`, + typed directory entries, all 52 `stat` fields in order, and Linux-compatible + task-name escaping. Emitters cite `proc(5)`, Linux `fs/proc/array.c`, and + procps-ng; a Linux 6.1 fixture and regeneration script cover raw `comm` edge + cases. Full kernel crate proof: + `~/progress/agent-os/2026-07-09-registry-networking/2026-07-09T17-18-40-0700-proc-full-kernel-suite.log`. 3. **Prove and ship the consumers:** add a captured real-Linux golden fixture and regeneration script; build real upstream procps-ng + psmisc with the patched sysroot; ship `ps`, `pgrep`, `pkill` (plus directly unlocked commands); run diff --git a/docs-internal/registry-parity-worklist.md b/docs-internal/registry-parity-worklist.md index dcff5ce09b..a61aefdf1a 100644 --- a/docs-internal/registry-parity-worklist.md +++ b/docs-internal/registry-parity-worklist.md @@ -848,15 +848,13 @@ above). (kill, sleep, timeout, env, nohup, nproc, nice/renice are coreutils — confirm they're shipped via uutils rather than re-adding.) -**⚙️ Runtime prerequisite — `/proc` is PARTIAL, not done.** The kernel already -exposes a process-table-backed `/proc`, including per-PID `stat`, `status`, -`cmdline`, `environ`, `cwd`, and `fd`, plus several system files. Completion -still requires the procps-consumed gaps (`comm`, `/proc/stat`, and anything else -demonstrated by the real parser), authoritative `proc(5)` + Linux -`fs/proc/array.c` + procps-ng `readproc.c` citations at the emitters, a captured -real-Linux golden fixture, and unskipped real procps-ng/psmisc e2e for at least -`ps`, `pgrep`, and `pkill`. Unit tests of the synthetic files are necessary but -not sufficient. Finish the runtime surface before packaging the consumers. +**✅ Runtime prerequisite — `/proc` surface complete; consumers next.** The +process-table-backed procfs now includes per-PID `stat`, `status`, `comm`, +`cmdline`, `environ`, `cwd`, and `fd`, plus `/proc/stat` and the prior system +files. The emitters cite `proc(5)`, Linux `fs/proc/array.c`, and procps-ng; a +captured Linux 6.1 fixture proves raw/escaped task-name behavior. The remaining +deliverable is packaging real procps-ng/psmisc and unskipped VM e2e for at least +`ps`, `pgrep`, and `pkill`; unit tests alone are not the final acceptance proof. **Excluded — not worth it / not possible here:** - **TUI / visual-only:** gitui, lazygit, eza, dust, ncdu, bat, delta, broot, k9s,