nx_tool_run_owned_candidate_t218.nx source
↩ module page · 638 lines · 33419 B
1// nx_tool_run.nx -- R0 of the EXECUTABLE-API rung: the sovereign exec+capture primitive the ecosystem
2// is missing. Today nishifamily.com/api/tools + /mcp only LIST tools (a read-only registry) and MCP
3// tools/call returns a SAFE STUB ("invoked X (capability-authorized)") -- there is NO way to actually
4// RUN an organ and hand back its real stdout. This organ is that missing capability, built hardware-up
5// from raw syscalls (fork/pipe/dup3/execve/wait4), reusing the EXACT proven wrappers from nx_hostctl's
6// hc_dfork_exec so it inherits the same never-brick discipline. NO /bin/sh, NO shell string, NO PATH
7// search -- callers pass an ABSOLUTE ELF path (the allowlist layer that maps tool-name -> path is R1,
8// nx_tool_registry). Synchronous (wait4), unlike hc_dfork_exec's detach -- because an API tools/call
9// needs the child's OUTPUT and EXIT CODE, not a fire-and-forget daemon.
10// license_tier: ORIGINAL
11import "nx_syscalls.nx"
12import "nx_buf_dyn_owned_candidate_t218.nx"
13
14// EINTR is -4. sys_read returns -errno, so a NEGATIVE result is an ERROR and r==0 alone is EOF.
15
16const TR_ERR_DRAIN: i64 = 0 - 8
17const TR_ERR_FD_SETUP: i64 = 0 - 12
18const TR_ERR_PARENT_LIFETIME: i64 = 0 - 11
19const TR_DRAIN_BYTES: i64 = 4096
20
21const TR_EINTR: i64 = 0 - 4
22// bounded so a genuinely unreadable fd cannot spin forever (same shape as sys_sleep_ms's guard)
23const TR_EINTR_MAX: i64 = 4096
24
25// ---- EXEC FAILURE MUST SPEAK (2026-08-28, 503 lane) ----------------------------------------------
26// WHAT THIS DELETES. Both capture primitives below used to end the child with a bare sys_exit(127)
27// when execve returned, writing NOTHING to the pipe -- and the header above records that as acceptable
28// (a bad path can only produce a 127 exit + empty capture). But the tools daemon's async lane writes
29// that empty capture to _jobs/job_<id>.out, and its own receipt tells every caller that an EMPTY
30// ARTIFACT MEANS THE JOB IS STILL RUNNING. So a tool that never started and a tool still working are
31// THE SAME OBSERVATION, forever: the caller either waits without bound or concludes the work landed.
32// A PROCESS THAT DIES BEFORE IT CAN SPEAK IS INDISTINGUISHABLE FROM ONE THAT IS STILL THINKING, AND
33// THE SILENCE IS READ AS THE MORE FLATTERING OF THE TWO.
34// MEASURED 2026-08-28 with a control pair: a 130000-byte single argument writes its file and returns
35// OK, while a 132000-byte one produced an empty artifact and no diagnostic anywhere. The CAUSE is not
36// ours -- Linux caps ONE argv element at MAX_ARG_STRLEN = 32 pages = 131072 bytes, a PER-ARGUMENT
37// limit no larger total-argv budget relaxes -- but the SILENCE was ours, and that is what this fixes.
38// The child is already past dup3 here, so fd 1 IS the capture pipe: the parent drains this text and the
39// caller reads a named cause instead of nothing. ONE message, TWO call sites, so they cannot drift.
40const TR_EXIT_EXECFAIL: i64 = 127
41const TR_MODE_0644: i64 = 420
42
43func tr_exec_failed(path: *u8) -> i64 {
44 let m1: *u8 = "NX-EXEC-FAILED rc=127 path=" as *u8
45 var n1: i64 = 0
46 while m1[n1] != (0 as u8) { n1 = n1 + 1 }
47 sys_write(1, m1, n1)
48 var pn: i64 = 0
49 while path[pn] != (0 as u8) { pn = pn + 1 }
50 sys_write(1, path, pn)
51 let m2: *u8 = " -- execve RETURNED instead of replacing this process, so the tool NEVER RAN. This capture is empty BY CAUSE, not because work is still in flight: do NOT read it as RUNNING. Two causes produce it here. (1) The binary is missing, not executable, or not an ELF -- check nx_catalog <name>, and nx_offc_install <name> promoted if a runner forks the _offc mirror. (2) A SINGLE argument exceeds the kernel MAX_ARG_STRLEN of 32 pages = 131072 bytes; that cap is PER-ARGUMENT and no total-size budget relaxes it -- split the payload, or push a large source as anchored edits rather than one whole-file argument. MEASURED 2026-08-28: a 130000-byte argument succeeds and a 132000-byte argument lands here.\n" as *u8
52 var n2: i64 = 0
53 while m2[n2] != (0 as u8) { n2 = n2 + 1 }
54 sys_write(1, m2, n2)
55 sys_exit(TR_EXIT_EXECFAIL)
56 return TR_EXIT_EXECFAIL
57}
58
59// tr_run_capture: fork -> child wires its stdout(+stderr) to a pipe and execve's `path` with `argv`
60// (a NUL-terminated *i64 array of *u8-as-i64, argv[0] conventionally = path) -> parent closes the write
61// end, drains the pipe into out[0..cap), wait4's the child, and returns wait_exit_code (0..255), or a
62// negative sentinel on a harness failure. On child execve failure the child exits 127 (captured as such).
63// *outlen (if non-null) receives the number of bytes captured.
64//
65// never-brick: path is an absolute ELF chosen by the caller's allowlist; a bad path can only produce a
66// 127 exit + empty capture, never a shell injection and never a write to persistent hardware state.
67// RUN A TOOL WHOSE STDOUT IS ITS PRODUCT, NOT ITS COMMENTARY.
68//
69// WHY THIS EXISTS BESIDE tr_run_capture RATHER THAN INSTEAD OF IT. Every capture helper in this lib
70// merges the child's stdout and stderr onto ONE pipe, which is exactly right when the output is a
71// report to be scanned: a diagnostic and a result belong in the same buffer and the caller reads
72// both. It is exactly WRONG when stdout carries an ARTIFACT. The sovereign compiler writes assembly
73// to stdout and progress to stderr, so a caller that captures it merged and writes the buffer to a
74// .s file produces a file with diagnostics glued to the front -- which the assembler then rejects
75// with an error about the SOURCE, sending the reader at the compiler instead of at the plumbing.
76// Measured 2026-09-04: that mistake cost a gate five failing teeth and read exactly like a broken
77// compiler while the compiler was correct.
78//
79// THE ESTATE ALREADY HAD THIS AND COULD NOT REACH IT. `sbr_run` inside nx_sov_build_run does fork +
80// redirect + execve correctly, but it lives inside a PROGRAM, so every other consumer must either
81// duplicate it or work around it -- the duplicate-ruler defect in its most common form. This is the
82// same mechanism lifted into the lib both consumers already import, so there is one of it.
83//
84// Contract: stdout -> out_path (created/truncated), stderr -> err_path when non-zero, else the
85// child's stderr is left on the parent's. Returns the child's exit status, or 128+signal if it died
86// to one -- because a SEGFAULTED tool whose status decodes as 0 is a silent fake success, which is
87// the defect the shell convention exists to prevent. Negative returns are the same named plumbing
88// failures tr_run_capture uses, so a caller can tell "the tool failed" from "I could not run it".
89func tr_run_redirect(path: *u8, argv: *i64, out_path: *u8, err_path: *u8) -> i64 {
90 let ofd: i64 = sys_openat_wr(out_path, TR_MODE_0644)
91 if ofd < 0 { return 0 - 5 } // TR_ERR_OUTOPEN
92 var efd: i64 = 0 - 1
93 if (err_path as i64) != 0 {
94 efd = sys_openat_wr(err_path, TR_MODE_0644)
95 if efd < 0 { sys_close(ofd); return 0 - 6 } // TR_ERR_ERROPEN
96 }
97 let pid: i64 = sys_fork()
98 if pid < 0 {
99 sys_close(ofd)
100 if efd >= 0 { sys_close(efd) }
101 return 0 - 3
102 }
103 if pid == 0 {
104 sys_dup3(ofd, 1, 0)
105 if efd >= 0 { sys_dup3(efd, 2, 0) }
106 sys_close(ofd)
107 if efd >= 0 { sys_close(efd) }
108 var fdc: i64 = 3
109 while fdc < 256 { sys_close(fdc); fdc = fdc + 1 }
110 let envp: *i64 = sys_mmap(16) as *i64
111 envp[0] = "PATH=/usr/bin:/bin" as *u8 as i64
112 envp[1] = 0
113 sys_execve_clean(path, argv, envp)
114 // execve failed. There is no pipe to name the cause on here, so exit with the shell's
115 // not-executable status and let the caller's own existence check say which file it was.
116 sys_exit(127)
117 return 0
118 }
119 sys_close(ofd)
120 if efd >= 0 { sys_close(efd) }
121 let stp: *i64 = sys_mmap(16) as *i64
122 let w: i64 = sys_wait4(pid, stp, 0)
123 if w < 0 { return 0 - 4 }
124 return wait_status_rc(stp[0])
125}
126
127func tr_run_capture(path: *u8, argv: *i64, out: *u8, cap: i64, outlen: *i64) -> i64 {
128 if (outlen as i64) != 0 { outlen[0] = 0 }
129 let fds: *i64 = sys_mmap(16) as *i64
130 if sys_pipe2(fds, 0) != 0 { return 0 - 2 } // TR_ERR_PIPE
131 // pipe2 writes int[2] (TWO 32-bit fds) into the first 8 bytes -> read end = low 32 bits of fds[0],
132 // write end = high 32 bits. Reading them as two i64 slots leaves the write fd un-tracked (never closed),
133 // so the reader never sees EOF and blocks forever. Unpack the 32-bit fds explicitly.
134 let packed: i64 = fds[0]
135 let rfd: i64 = packed & 0xFFFFFFFF
136 let wfd: i64 = (packed >> 32) & 0xFFFFFFFF
137
138 let pid: i64 = sys_fork()
139 if pid < 0 { sys_close(rfd); sys_close(wfd); return 0 - 3 } // TR_ERR_FORK
140 if pid == 0 {
141 // ---- CHILD ---- wire stdout(1) + stderr(2) to the pipe write end, close both raw ends, exec.
142 sys_dup3(wfd, 1, 0)
143 sys_dup3(wfd, 2, 0)
144 sys_close(rfd)
145 sys_close(wfd)
146 var fdc: i64 = 3
147 while fdc < 256 { sys_close(fdc); fdc = fdc + 1 }
148 let envp: *i64 = sys_mmap(16) as *i64
149 envp[0] = "PATH=/usr/bin:/bin" as *u8 as i64
150 envp[1] = 0
151 sys_execve_clean(path, argv, envp)
152 tr_exec_failed(path) // execve failed -> NAMED cause on the pipe, then 127
153 return 0
154 }
155
156 // ---- PARENT ---- close the write end (so read() sees EOF when the child exits), drain the pipe.
157 sys_close(wfd)
158 var total: i64 = 0
159 var run: i64 = 1
160 var eintr: i64 = 0
161 while run == 1 {
162 if total >= cap { run = 0 } else {
163 let r: i64 = sys_read(rfd, ((out as i64) + total) as *u8, cap - total)
164 // r < 0 is an ERROR, NOT EOF. `r <= 0` conflated them: EINTR -- overwhelmingly SIGCHLD from a
165 // reaped child, which THIS function creates -- ended the drain, returning a SHORT capture that
166 // outlen then reported as COMPLETE. That is how one organ's output arrives truncated at a
167 // DIFFERENT point every run and reads as a flaky gate (MEASURED 2026-08-08: 11 runs of one
168 // nx_coa_gate binary -> 4 different prefixes, ZERO FAIL lines). RESUME on EINTR exactly as
169 // sys_sleep_ms does for clock_nanosleep, whose comment already names this cause.
170 if r > 0 { total = total + r } else {
171 if r == 0 { run = 0 } else {
172 if r == TR_EINTR { if eintr > TR_EINTR_MAX { run = 0 } else { eintr = eintr + 1 } } else { run = 0 }
173 }
174 }
175 }
176 }
177 sys_close(rfd)
178 if (outlen as i64) != 0 { outlen[0] = total }
179
180 let stp: *i64 = sys_mmap(16) as *i64
181 let w: i64 = sys_wait4(pid, stp, 0)
182 if w < 0 { return 0 - 4 } // TR_ERR_WAIT
183 return wait_status_rc(stp[0])
184}
185
186// ---- BOUNDED EXEC (seq1412) ----------------------------------------------------------------
187// tr_run_capture has NO timeout: the parent blocks in the read() drain until EOF. A child that never
188// exits -- or that forks something holding stdout open -- hangs its caller forever. That primitive has
189// 51+ call sites including nx_seat (every session boot), nx_gate_rollup, and the tools-daemon exec path
190// (tea_run), so ONE hanging organ can wedge tools/call for every MCP client.
191//
192// ADDITIVE ON PURPOSE: tr_run_capture's signature and behaviour are untouched, so none of those 51
193// callers change. New/critical callers opt in here.
194//
195// Bounded capture shares a deadline-polled drain and an owned process-group
196// watchdog. Unbounded and promotable capture retain their existing contracts.
197const TR_ERR_TIMEOUT: i64 = 0 - 5
198const TR_SIGKILL: i64 = 9
199
200// timeout_ms <= 0 -> delegates to the unbounded tr_run_capture (explicit opt-out, never a silent one).
201// Returns the child's exit code, TR_ERR_TIMEOUT if the deadline fired, or the -2/-3/-4 harness sentinels.
202func tr_run_capture_to(path: *u8, argv: *i64, out: *u8, cap: i64, outlen: *i64, timeout_ms: i64) -> i64 {
203 return tr_run_capture_tr(path, argv, out, cap, outlen, timeout_ms, 0 as *i64)
204}
205
206// ---- BOUNDED, PROMOTABLE DRAIN (2026-08-22) -----------------------------------------------------
207// tr_run_capture_to's header above records that bounding the drain "would need O_NONBLOCK on the read
208// end, and there is no sys_fcntl in nx_syscalls -- so the non-blocking design is not buildable." That
209// premise is FALSE, and it cost the estate every synchronous-lane 503: poll(2) needs no O_NONBLOCK, and
210// sys_poll ships in nx_syscalls with 50 call sites (corpus_complete=1). A retrieval failure was written
211// into the source as an impossibility, and the watchdog+SIGKILL below it DESTROYED THE ANSWER of every
212// call that outran the window while the work itself ran on and landed.
213// ★A LAW RECORDED IN A HEADER IS STILL A HYPOTHESIS -- THIS ONE WAS REFUTED BY ONE GREP.
214// The promotable implementation remains separate. Bounded and cwd capture now
215// share tr_run_capture_core; callers keep their existing signatures.
216// * deadline_ms is a WHOLE-CALL budget, not a per-read idle timer -- the distinction the edge's
217// SO_RCVTIMEO gets wrong. remaining = deadline_ms - (now - t0), recomputed every pass.
218// * poll > 0 -> read (POLLIN is set, so it cannot block); the r>0 / r==0 / EINTR discipline is
219// copied from tr_run_capture_to verbatim, truncation-at-a-different-point bug included.
220// * poll == 0 -> THE DEADLINE. DO NOT KILL. The caller receives TR_PROMOTE plus the live worker pid and
221// the live read end, and now owns a worker that will finish and a pipe that will carry its
222// tail. No watchdog fork exists on this path: one FEWER process per sync call.
223// * deadline_ms <= 0 -> delegates to the unbounded tr_run_capture -- the explicit opt-out contract
224// tr_run_capture_to already has, never a silent one.
225// pollfd is the kernel ABI struct { i32 fd; i16 events; i16 revents } = 8 bytes.
226// ⚠INCUMBENT NAMED, NOT HIDDEN: nx_ts_drain_lib.nx carries tsd_pollfd_set / tsd_pollfd_ready for this
227// same struct. It is deliberately NOT imported here: nx_tool_run sits in the closure of 51 consumers
228// and that lib transitively imports nx_resmon_lib + nx_itoa_lib, so a symbol collision in any ONE of
229// those closures would break a build nobody in this lane can see. The consolidation is nx_oo_extract
230// lifting BOTH copies into a tiny nx_pollfd_lib -- that is the named remedy; this is the named debt.
231const TR_PROMOTE: i64 = 0 - 7 // distinct from TR_ERR_TIMEOUT (-5) and TR_ERR_CHDIR (-6)
232const TR_POLLFD_BYTES: i64 = 8 // sizeof(struct pollfd)
233const TR_POLLIN: i64 = 1 // POLLIN
234const TR_PF_EV_OFF: i64 = 4 // offsetof(struct pollfd, events)
235const TR_PF_RE_OFF: i64 = 6 // offsetof(struct pollfd, revents)
236const TR_PF_FD_BYTES: i64 = 4 // sizeof(i32 fd)
237const TR_BITS_PER_BYTE: i64 = 8
238const TR_BYTE_MASK: i64 = 0xff
239func tr_pollfd_set(p: *u8, fd: i64) -> i64 {
240 var k: i64 = 0
241 while k < TR_PF_FD_BYTES { p[k] = ((fd >> (k * TR_BITS_PER_BYTE)) & TR_BYTE_MASK) as u8; k = k + 1 }
242 p[TR_PF_EV_OFF] = TR_POLLIN as u8
243 p[TR_PF_EV_OFF + 1] = 0 as u8
244 p[TR_PF_RE_OFF] = 0 as u8
245 p[TR_PF_RE_OFF + 1] = 0 as u8
246 return 0
247}
248// Returns the child's exit code if it finished inside the deadline, TR_PROMOTE (with *out_pid / *out_rfd
249// filled) if the deadline fired first, or the -2/-3/-4 harness sentinels. On TR_PROMOTE the caller OWNS
250// the worker and the read end: it must drain rfd to EOF (the tail of the answer) or close it.
251func tr_run_capture_deadline(path: *u8, argv: *i64, out: *u8, cap: i64, outlen: *i64, deadline_ms: i64, out_pid: *i64, out_rfd: *i64) -> i64 {
252 if (out_pid as i64) != 0 { out_pid[0] = 0 }
253 if (out_rfd as i64) != 0 { out_rfd[0] = 0 - 1 }
254 if deadline_ms <= 0 { return tr_run_capture(path, argv, out, cap, outlen) }
255 if (outlen as i64) != 0 { outlen[0] = 0 }
256 let fds: *i64 = sys_mmap(16) as *i64
257 if sys_pipe2(fds, 0) != 0 { return 0 - 2 }
258 let packed: i64 = fds[0]
259 let rfd: i64 = packed & 0xFFFFFFFF
260 let wfd: i64 = (packed >> 32) & 0xFFFFFFFF
261 let t0: i64 = sys_now_ms()
262 let pid: i64 = sys_fork()
263 if pid < 0 { sys_close(rfd); sys_close(wfd); return 0 - 3 }
264 if pid == 0 {
265 sys_default_signal(13)
266 sys_dup3(wfd, 1, 0)
267 sys_dup3(wfd, 2, 0)
268 sys_close(rfd)
269 sys_close(wfd)
270 var fdc: i64 = 3
271 while fdc < 256 { sys_close(fdc); fdc = fdc + 1 }
272 let envp: *i64 = sys_mmap(16) as *i64
273 envp[0] = "PATH=/usr/bin:/bin" as *u8 as i64
274 envp[1] = 0
275 sys_execve_clean(path, argv, envp)
276 tr_exec_failed(path)
277 return 0
278 }
279 sys_close(wfd)
280 let pfd: *u8 = sys_mmap(TR_POLLFD_BYTES)
281 var total: i64 = 0
282 var run: i64 = 1
283 var eintr: i64 = 0
284 var promoted: i64 = 0
285 while run == 1 {
286 if total >= cap { run = 0 } else {
287 var remaining: i64 = deadline_ms - (sys_now_ms() - t0)
288 if remaining < 0 { remaining = 0 }
289 tr_pollfd_set(pfd, rfd)
290 let pr: i64 = sys_poll(pfd, 1, remaining)
291 if pr > 0 {
292 let r: i64 = sys_read(rfd, ((out as i64) + total) as *u8, cap - total)
293 if r > 0 { total = total + r } else {
294 if r == 0 { run = 0 } else {
295 if r == TR_EINTR { if eintr > TR_EINTR_MAX { run = 0 } else { eintr = eintr + 1 } } else { run = 0 }
296 }
297 }
298 } else {
299 if pr == 0 { promoted = 1; run = 0 } else {
300 if pr == TR_EINTR { if eintr > TR_EINTR_MAX { run = 0 } else { eintr = eintr + 1 } } else { run = 0 }
301 }
302 }
303 }
304 }
305 if (outlen as i64) != 0 { outlen[0] = total }
306 if promoted == 1 {
307 if (out_pid as i64) != 0 { out_pid[0] = pid }
308 if (out_rfd as i64) != 0 { out_rfd[0] = rfd }
309 return TR_PROMOTE
310 }
311 sys_close(rfd)
312 let stp: *i64 = sys_mmap(16) as *i64
313 let w: i64 = sys_wait4(pid, stp, 0)
314 if w < 0 { return 0 - 4 }
315 return wait_status_rc(stp[0])
316}
317
318// ---- SANDBOXED RUN (2026-08-06) ---------------------------------------------------------------
319// ***CONTAINMENT BEATS ENUMERATION.*** On 2026-08-06 a coverage sweep ran nx_cap_grant_e2e_gate -- a
320// destructive end-to-end test that exercises the REAL capability system in place. It rewrote
321// nishihost/tool_allowlist.conf with a 52-byte fixture (750 GREEN rows lost) and left
322// tools_cap_secret.key ABSENT, so nx_tools_api fell back to its forgeable placeholder and EVERY
323// capability in the estate was denied. A denylist of such gates was written afterwards, and a denylist
324// is an ENUMERATION -- it only ever protects against the instances somebody already thought of, and its
325// detector is a source-literal proxy, so it is a FLOOR not a total.
326// This is the containment: run the child with its OWN cwd, so a gate reaching for ../<production-file>
327// lands inside a scratch tree instead of the live one. It cannot stop an ABSOLUTE path -- nothing short
328// of a namespace can -- so it composes with the denylist rather than replacing it. Defence in depth,
329// stated honestly, because a containment that oversells itself is how the next one gets skipped.
330// ***FAIL-CLOSED: if the chdir does not take, the child EXITS rather than running in the wrong tree.***
331// Running the subject in the directory you were trying to protect is the exact failure this prevents,
332// so "could not chdir, so proceeded" must never be reachable.
333// ⚠<path> is resolved AFTER the chdir -- pass it absolute, or relative to <cwd> (e.g. ../_build/x.elf).
334const TR_ERR_CHDIR: i64 = 0 - 6
335func tr_run_capture_cwd(path: *u8, argv: *i64, out: *u8, cap: i64, outlen: *i64, timeout_ms: i64, cwd: *u8) -> i64 {
336 if (cwd as i64) == 0 { if (outlen as i64) != 0 { outlen[0]=0 }; return TR_ERR_CHDIR }
337 return tr_run_capture_core(path,argv,out,cap,outlen,timeout_ms,0 as *i64,cwd)
338}
339
340// Bounded capture retains the caller-sized prefix while draining excess bytes.
341// FIT/CUT is based on observed output, not whether the buffer filled exactly.
342const TR_FIT: i64 = 0
343const TR_CUT: i64 = 1
344func tr_drain_tr(rfd: i64, out: *u8, cap: i64, trunc: *i64) -> i64 {
345 if (trunc as i64) != 0 { trunc[0] = TR_FIT }
346 if cap < 0 { return TR_ERR_DRAIN }
347 let scratch: *u8 = sys_mmap(TR_DRAIN_BYTES)
348 var total: i64 = 0
349 var result: i64 = 0
350 var run: i64 = 1
351 var eintr: i64 = 0
352 while run == 1 {
353 var dest: *u8 = scratch
354 var room: i64 = TR_DRAIN_BYTES
355 if total < cap { dest = ((out as i64)+total) as *u8; room = cap-total }
356 let r: i64 = sys_read(rfd,dest,room)
357 if r > 0 {
358 eintr = 0
359 if total < cap { total = total+r } else {
360 if (trunc as i64) != 0 { trunc[0] = TR_CUT }
361 }
362 } else {
363 if r == 0 { run = 0 } else {
364 if r == TR_EINTR {
365 eintr = eintr+1
366 if eintr > TR_EINTR_MAX { result = TR_ERR_DRAIN; run = 0 }
367 } else { result = TR_ERR_DRAIN; run = 0 }
368 }
369 }
370 }
371 sys_munmap(scratch,TR_DRAIN_BYTES)
372 if result < 0 { return result }
373 return total
374}
375
376// tr_run_capture_tr: bounded capture that REPORTS whether it was cut off. trunc may be null.
377func tr_run_capture_tr(path: *u8, argv: *i64, out: *u8, cap: i64, outlen: *i64, timeout_ms: i64, trunc: *i64) -> i64 {
378 return tr_run_capture_core(path,argv,out,cap,outlen,timeout_ms,trunc,0 as *u8)
379}
380
381// Capture owns one private workspace and one shared control page per invocation.
382// The control page distinguishes a fired deadline from an unrelated SIGKILL.
383const TR_CONTROL_BYTES: i64 = 4096
384const TR_ERR_GROUP: i64 = 0 - 9
385const TR_ERR_ALLOC: i64 = 0 - 10
386const TR_ECHILD: i64 = 0 - 10 // Linux errno, distinct from the public result namespace
387func tr_clock_ms(ts: *i64) -> i64 {
388 if sys_clock_gettime_mono(ts) < 0 { return TR_ERR_DRAIN }
389 return ts[0]*1000 + ts[1]/SYS_MAGIC_1000000
390}
391// Observation keeps the PID reserved until every watchdog capable of signalling it is gone.
392func tr_observe_exit(pid: i64, info: *u8) -> i64 {
393 var rc: i64=sys_waitid(NX_WAIT_P_PID,pid,info,NX_WAIT_EXITED | NX_WAIT_NOWAIT)
394 while rc == TR_EINTR { rc=sys_waitid(NX_WAIT_P_PID,pid,info,NX_WAIT_EXITED | NX_WAIT_NOWAIT) }
395 return rc
396}
397func tr_reap(pid: i64, status: *i64) -> i64 {
398 var rc: i64=sys_wait4(pid,status,0)
399 while rc == TR_EINTR { rc=sys_wait4(pid,status,0) }
400 return rc
401}
402// A pipe can stay open after its direct producer exits. Poll against the whole-call
403// deadline; neither a full capture nor continuous output resets that deadline.
404// Fixed-buffer callers retain the original contract; owned capture shares the same drain and deadline.
405func tr_drain_until(rfd:i64,out:*u8,cap:i64,trunc:*i64,outlen:*i64,deadline:i64)->i64{
406 return tr_drain_until_owned(rfd,out,cap,trunc,outlen,deadline,0 as *NxBufOwned,0)
407}
408func tr_drain_until_owned(rfd:i64,out:*u8,cap:i64,trunc:*i64,outlen:*i64,deadline:i64,owned:*NxBufOwned,max_bytes:i64)->i64 {
409 let scratch: *u8=sys_mmap(TR_DRAIN_BYTES)
410 let state: *u8=sys_mmap(TR_CONTROL_BYTES)
411 let ts: *i64=((state as i64)+TR_POLLFD_BYTES) as *i64
412 var total: i64=0
413 var result: i64=0
414 var run: i64=1
415 var eintr: i64=0
416 while run == 1 {
417 var remaining: i64=0-1
418 if deadline > 0 {
419 let now: i64=tr_clock_ms(ts)
420 if now < 0 { result=TR_ERR_DRAIN; run=0 } else {
421 remaining=deadline-now
422 if remaining <= 0 { result=TR_ERR_TIMEOUT; run=0 }
423 }
424 }
425 if run == 1 {
426 tr_pollfd_set(state,rfd)
427 let ready: i64=sys_poll(state,1,remaining)
428 if ready > 0 {
429 var dest: *u8=scratch
430 var room: i64=TR_DRAIN_BYTES
431 if (owned as i64)==0 { if total < cap { dest=((out as i64)+total) as *u8; room=cap-total } }
432 let count: i64=sys_read(rfd,dest,room)
433 if count > 0 {
434 eintr=0
435 if (owned as i64)!=0 {
436 let appended:i64=nx_bo_append(owned,scratch,count,max_bytes)
437 if appended!=0 {result=appended;run=0}else{total=owned.len}
438 }else{
439 if total < cap { total=total+count } else {
440 if (trunc as i64) != 0 { trunc[0]=TR_CUT }
441 }
442 }
443 } else {
444 if count == 0 { run=0 } else {
445 if count == TR_EINTR { eintr=eintr+1 } else { result=TR_ERR_DRAIN; run=0 }
446 }
447 }
448 } else {
449 if ready == 0 { result=TR_ERR_TIMEOUT; run=0 } else {
450 if ready == TR_EINTR { eintr=eintr+1 } else { result=TR_ERR_DRAIN; run=0 }
451 }
452 }
453 if eintr > TR_EINTR_MAX { result=TR_ERR_DRAIN; run=0 }
454 }
455 }
456 if (outlen as i64) != 0 { outlen[0]=total }
457 sys_munmap(state,TR_CONTROL_BYTES)
458 sys_munmap(scratch,TR_DRAIN_BYTES)
459 return result
460}
461func tr_capture_stop(pid: i64, control: *i64) -> i64 {
462 // Only signal a group established by this invocation, never the caller's group.
463 if control[1] == 1 { nx_kill(0-pid,TR_SIGKILL) }
464 nx_kill(pid,TR_SIGKILL)
465 return 0
466}
467// The watchdog acknowledges lifetime binding before the parent relies on it.
468// Its startup pipe is created after the worker fork, so the worker cannot retain it.
469func tr_watchdog_child(owner: i64, pid: i64, control: *i64, deadline: i64, wfd: i64, scratch: *u8) -> i64 {
470 if sys_bind_parent_lifetime(owner,TR_SIGKILL) < 0 { control[2]=TR_ERR_PARENT_LIFETIME; return 126 }
471 if wfd != 1 { if sys_dup3(wfd,1,0) < 0 { control[2]=TR_ERR_FD_SETUP; return 126 } }
472 sys_close(0); sys_close(2)
473 if sys_close_inherited(3) < 0 { control[2]=TR_ERR_FD_SETUP; return 126 }
474 scratch[128]=82 as u8
475 if sys_write(1,scratch+128,1) != 1 { control[2]=TR_ERR_FD_SETUP; return 126 }
476 sys_close(1)
477 let ts: *i64=(scratch+160) as *i64
478 var running: i64=1
479 while running == 1 {
480 let now: i64=tr_clock_ms(ts)
481 if now < 0 { control[2]=TR_ERR_DRAIN; running=0 } else {
482 if now >= deadline { control[0]=1; running=0 } else {
483 if sys_sleep_ms(deadline-now) < 0 { control[2]=TR_ERR_DRAIN; running=0 }
484 }
485 }
486 }
487 tr_capture_stop(pid,control)
488 return 0
489}
490func tr_watchdog_start(owner: i64, pid: i64, control: *i64, deadline: i64) -> i64 {
491 let scratch: *u8=sys_mmap(TR_CONTROL_BYTES)
492 let fds: *i64=scratch as *i64
493 if sys_pipe2(fds,0) < 0 { sys_munmap(scratch,TR_CONTROL_BYTES); return 0-2 }
494 let rfd: i64=fds[0] & 0xFFFFFFFF
495 let wfd: i64=(fds[0] >> 32) & 0xFFFFFFFF
496 let wd: i64=sys_fork()
497 if wd == 0 {
498 let rc: i64=tr_watchdog_child(owner,pid,control,deadline,wfd,scratch)
499 sys_exit(rc); return rc
500 }
501 sys_close(wfd)
502 if wd < 0 { sys_close(rfd); sys_munmap(scratch,TR_CONTROL_BYTES); return 0-3 }
503 let length: *i64=(scratch+16) as *i64
504 let cut: *i64=(scratch+32) as *i64
505 let status: *i64=(scratch+64) as *i64
506 var result: i64=tr_drain_until(rfd,scratch+128,1,cut,length,deadline)
507 sys_close(rfd)
508 if result == 0 {
509 if length[0] != 1 || cut[0] != 0 || scratch[128] != 82 as u8 { result=TR_ERR_PARENT_LIFETIME }
510 }
511 if control[2] < 0 { result=control[2] }
512 if result < 0 { nx_kill(wd,TR_SIGKILL); tr_reap(wd,status) }
513 sys_munmap(scratch,TR_CONTROL_BYTES)
514 if result < 0 { return result }
515 return wd
516}
517func tr_run_capture_core(path:*u8,argv:*i64,out:*u8,cap:i64,outlen:*i64,timeout_ms:i64,trunc:*i64,cwd:*u8)->i64{
518 return tr_run_capture_core_owned(path,argv,out,cap,outlen,timeout_ms,trunc,cwd,0 as *NxBufOwned,0)
519}
520// The caller owns partial evidence even on error and releases it with nx_bo_release.
521// A new capture requires an empty owner; never re-execute a child to discover its output length.
522func tr_run_capture_owned(path:*u8,argv:*i64,owned:*NxBufOwned,timeout_ms:i64,max_bytes:i64,cwd:*u8)->i64{
523 if (owned as i64)<=0||max_bytes<0{return TR_ERR_DRAIN}
524 if (owned.buf as i64)!=0||owned.len!=0||owned.cap!=0{return TR_ERR_DRAIN}
525 var length:i64=0;var cut:i64=TR_FIT
526 return tr_run_capture_core_owned(path,argv,0 as *u8,0,&length,timeout_ms,&cut,cwd,owned,max_bytes)
527}
528func tr_run_capture_core_owned(path:*u8,argv:*i64,out:*u8,cap:i64,outlen:*i64,timeout_ms:i64,trunc:*i64,cwd:*u8,owned:*NxBufOwned,max_bytes:i64)->i64 {
529 if (outlen as i64) != 0 { outlen[0]=0 }
530 if (trunc as i64) != 0 { trunc[0]=TR_FIT }
531 if cap < 0 { return TR_ERR_DRAIN }
532 let control: *i64=sys_mmap_shared(TR_CONTROL_BYTES) as *i64
533 if (control as i64) <= 0 { return TR_ERR_ALLOC }
534 let fds: *i64=((control as i64)+32) as *i64
535 let stp: *i64=((control as i64)+48) as *i64
536 let wstp: *i64=((control as i64)+64) as *i64
537 let ts: *i64=((control as i64)+80) as *i64
538 var deadline: i64=0
539 if timeout_ms > 0 {
540 let now: i64=tr_clock_ms(ts)
541 if now < 0 { sys_munmap(control as *u8,TR_CONTROL_BYTES); return TR_ERR_DRAIN }
542 deadline=now+timeout_ms
543 if deadline < now { sys_munmap(control as *u8,TR_CONTROL_BYTES); return TR_ERR_DRAIN }
544 }
545 let owner: i64=__syscall(172,0,0,0,0,0,0)
546 if owner <= 0 { sys_munmap(control as *u8,TR_CONTROL_BYTES); return TR_ERR_PARENT_LIFETIME }
547 if sys_pipe2(fds,0) != 0 { sys_munmap(control as *u8,TR_CONTROL_BYTES); return 0-2 }
548 let packed: i64=fds[0]
549 let rfd: i64=packed & 0xFFFFFFFF
550 let wfd: i64=(packed >> 32) & 0xFFFFFFFF
551 let pid: i64=sys_fork()
552 if pid < 0 {
553 sys_close(rfd); sys_close(wfd)
554 sys_munmap(control as *u8,TR_CONTROL_BYTES)
555 return 0-3
556 }
557 if pid == 0 {
558 sys_default_signal(13)
559 if sys_bind_parent_lifetime(owner,TR_SIGKILL) < 0 { control[2]=TR_ERR_PARENT_LIFETIME; sys_exit(126); return 0 }
560 if sys_setpgid(0,0) != 0 { control[2]=TR_ERR_GROUP; sys_exit(126); return 0 }
561 control[1]=1
562 // Close the read end first: it may occupy stdout/stderr when inherited fds are closed.
563 sys_close(rfd)
564 if wfd != 1 { sys_dup3(wfd,1,0) }
565 if wfd != 2 { sys_dup3(wfd,2,0) }
566 if wfd > 2 { sys_close(wfd) }
567 let envp: *i64=((control as i64)+96) as *i64
568 envp[0]="PATH=/usr/bin:/bin" as *u8 as i64; envp[1]=0
569 // Setup failure travels independently of the program's legitimate exit status.
570 if (cwd as i64) != 0 {
571 if sys_chdir(cwd) != 0 { control[2]=TR_ERR_CHDIR; sys_exit(126); return 0 }
572 }
573 sys_execve_clean(path,argv,envp)
574 tr_exec_failed(path)
575 return 0
576 }
577 sys_close(wfd)
578 var wd: i64=0
579 if timeout_ms > 0 {
580 wd=tr_watchdog_start(owner,pid,control,deadline)
581 if wd < 0 {
582 sys_close(rfd); tr_capture_stop(pid,control); tr_reap(pid,stp)
583 sys_munmap(control as *u8,TR_CONTROL_BYTES)
584 return wd
585 }
586 }
587 let drained: i64=tr_drain_until_owned(rfd,out,cap,trunc,outlen,deadline,owned,max_bytes)
588 sys_close(rfd)
589 if drained < 0 { tr_capture_stop(pid,control) }
590 let info: *u8=((control as i64)+128) as *u8
591 let observed: i64=tr_observe_exit(pid,info)
592 var watchwait: i64=0
593 if wd > 0 { nx_kill(wd,TR_SIGKILL); watchwait=tr_reap(wd,wstp) }
594 // ECHILD means ownership is already absent: never signal a potentially reused PID.
595 if observed < 0 { if observed != TR_ECHILD { tr_capture_stop(pid,control) } }
596 let waited: i64=tr_reap(pid,stp)
597 var result: i64=wait_status_rc(stp[0])
598 if observed < 0 || waited < 0 || watchwait < 0 { result=0-4 }
599 if control[2] < 0 { result=control[2] }
600 if control[0] == 1 { result=TR_ERR_TIMEOUT }
601 if drained < 0 { result=drained }
602 sys_munmap(control as *u8,TR_CONTROL_BYTES)
603 return result
604}
605
606// tr_run1: convenience for the common "run ELF with a single string arg" case. Builds argv = [path, arg, 0].
607// arg may be null -> argv = [path, 0].
608func tr_run1(path: *u8, arg: *u8, out: *u8, cap: i64, outlen: *i64) -> i64 {
609 let argv: *i64 = sys_mmap(32) as *i64
610 argv[0] = path as i64
611 if (arg as i64) == 0 { argv[1] = 0 } else { argv[1] = arg as i64; argv[2] = 0 }
612 return tr_run_capture(path, argv, out, cap, outlen)
613}
614
615// tr_run1_to: bounded twin of tr_run1. Same argv shaping, with a deadline.
616func tr_run1_to(path: *u8, arg: *u8, out: *u8, cap: i64, outlen: *i64, timeout_ms: i64) -> i64 {
617 let argv: *i64 = sys_mmap(32) as *i64
618 argv[0] = path as i64
619 if (arg as i64) == 0 { argv[1] = 0 } else { argv[1] = arg as i64; argv[2] = 0 }
620 return tr_run_capture_to(path, argv, out, cap, outlen, timeout_ms)
621}
622
623// tr_contains: 1 if the NUL-terminated needle occurs in buf[0..n), else 0. For gates asserting on captured stdout.
624func tr_contains(buf: *u8, n: i64, needle: *u8) -> i64 {
625 var nl: i64 = 0
626 while needle[nl] != (0 as u8) { nl = nl + 1 }
627 if nl == 0 { return 1 }
628 if n < nl { return 0 }
629 var i: i64 = 0
630 while i <= n - nl {
631 var m: i64 = 1
632 var c: i64 = 0
633 while c < nl { if buf[i + c] != needle[c] { m = 0; c = nl } else { c = c + 1 } }
634 if m == 1 { return 1 }
635 i = i + 1
636 }
637 return 0
638}