Skip to main content

max / makenotwork

ops-exec: carry the remote exit code in-band, never trust ssh's Tailscale SSH closes the channel without an exit-status message, so the local ssh exits 0 however the remote command ended. Verified 2026-07-16: `ssh mbp 'exit 42'` reports 0, `ssh astra 'exit 42'` (regular sshd) reports 42. Neither -t, -T, nor a bash -c wrapper changes it. Any host behind a transport like that turns every remote failure into a silent success, which for a release path means a green step and no artifact. Stop trusting the transport's status. Wrap a remote script so it prints its own $? as the last line of stdout under a per-run nonced marker, pull that line back out before the sink or the caller sees it, and prefer it over whatever ssh claimed. With no sentinel and a non-zero status (ssh exits 255 when it cannot connect) report the status; with no sentinel and a *successful* status, fail closed -- that is the ambiguous case this exists for, and it must never read as a pass. The script runs in a subshell: callers send `set -e` scripts, and an errexit at the top level of the remote shell would take it down before the printf ran, losing the code on exactly the failures that matter. Local `sh -c` reports status honestly and is left alone. Verified live against both hosts through SshExec: mbp now reports 42 where it reported 0, astra unchanged, no sentinel in the output.
Author: Max Johnson <me@maxj.phd> · 2026-07-16 23:44 UTC
Commit: 8060d1abe8552cf36f71cdaa89117bd6dc9c72ac
Parent: c1fc090
4 files changed, +408 insertions, -34 deletions
@@ -152,9 +152,16 @@ pub trait Executor: Send + Sync {
152 152 /// across two spawned tasks via `Arc<Mutex<_>>`), this drains both pipes in one
153 153 /// task with `select!` so it can take a borrowed `&mut dyn` sink — exactly the
154 154 /// shape the [`Executor`] trait exposes.
155 + ///
156 + /// `sentinel` must be the one [`crate::remote::RemoteHost::command_for`] handed
157 + /// back with `cmd`: `Some` for a remote host, whose reported status is not
158 + /// trustworthy (see [`crate::remote::RcSentinel`]), `None` for local. `host`
159 + /// only names the target in errors.
155 160 pub(crate) async fn run_command_into_sink(
156 161 mut cmd: Command,
157 162 sink: &mut dyn LogSink,
163 + sentinel: Option<crate::remote::RcSentinel>,
164 + host: &str,
158 165 ) -> Result<RunOutput> {
159 166 use std::process::Stdio;
160 167 use tokio::io::AsyncReadExt;
@@ -172,13 +179,21 @@ pub(crate) async fn run_command_into_sink(
172 179 let mut eb = [0u8; 4096];
173 180 let mut out_done = out.is_none();
174 181 let mut err_done = err.is_none();
182 + // Only stdout carries the sentinel; stderr streams untouched.
183 + let mut filter = crate::remote::RcFilter::new(sentinel.clone());
175 184
176 185 while !(out_done && err_done) {
177 186 tokio::select! {
178 187 r = async { out.as_mut().unwrap().read(&mut ob).await }, if !out_done => {
179 188 match r {
180 189 Ok(0) | Err(_) => out_done = true,
181 - Ok(n) => { crate::remote::push_bounded(&mut stdout_buf, &ob[..n], crate::remote::OUTPUT_TAIL_CAP); sink.write_chunk(&ob[..n]).await; }
190 + Ok(n) => {
191 + let chunk = filter.feed(&ob[..n]);
192 + if !chunk.is_empty() {
193 + crate::remote::push_bounded(&mut stdout_buf, &chunk, crate::remote::OUTPUT_TAIL_CAP);
194 + sink.write_chunk(&chunk).await;
195 + }
196 + }
182 197 }
183 198 }
184 199 r = async { err.as_mut().unwrap().read(&mut eb).await }, if !err_done => {
@@ -190,6 +205,16 @@ pub(crate) async fn run_command_into_sink(
190 205 }
191 206 }
192 207
208 + let (rest, code) = filter.finish();
209 + if !rest.is_empty() {
210 + crate::remote::push_bounded(&mut stdout_buf, &rest, crate::remote::OUTPUT_TAIL_CAP);
211 + sink.write_chunk(&rest).await;
212 + }
213 +
193 214 let status = child.wait().await.map_err(|e| anyhow::anyhow!("waiting on child: {e}"))?;
215 + let status = match sentinel {
216 + Some(_) => crate::remote::resolve_remote_status(host, status, code)?,
217 + None => status,
218 + };
194 219 Ok(RunOutput { status, stdout: stdout_buf, stderr: stderr_buf })
195 220 }
@@ -92,6 +92,193 @@ pub(crate) fn push_bounded(buf: &mut Vec<u8>, chunk: &[u8], cap: usize) {
92 92 }
93 93 }
94 94
95 + /// Rebuild an `ExitStatus` from a plain process exit code (0..=255).
96 + ///
97 + /// On Unix the code lives in the high byte of the wait-status encoding. All a
98 + /// recipe branches on — `.success()` and the non-zero distinction — round-trips
99 + /// exactly.
100 + #[cfg(unix)]
101 + pub(crate) fn exit_status_from_code(code: i32) -> ExitStatus {
102 + use std::os::unix::process::ExitStatusExt;
103 + ExitStatus::from_raw((code & 0xff) << 8)
104 + }
105 +
106 + #[cfg(not(unix))]
107 + pub(crate) fn exit_status_from_code(code: i32) -> ExitStatus {
108 + use std::os::windows::process::ExitStatusExt;
109 + ExitStatus::from_raw(code as u32)
110 + }
111 +
112 + /// A per-run marker used to carry a remote command's real exit code back to us
113 + /// in its own stdout.
114 + ///
115 + /// **Why this exists.** `ssh` is supposed to relay the remote command's exit
116 + /// status, and a regular sshd does. Tailscale SSH does not: it closes the
117 + /// channel without an `exit-status` message, so the local `ssh` exits 0 no
118 + /// matter how the remote command ended. Verified 2026-07-16 — `ssh mbp 'exit
119 + /// 42'` reports 0 while `ssh astra 'exit 42'` (regular sshd) reports 42. Neither
120 + /// `-t`, `-T`, nor a `bash -c` wrapper changes it. That turns every remote
121 + /// failure into a silent success, which for a release pipeline means shipping a
122 + /// green step that produced no artifact.
123 + ///
124 + /// So we stop trusting the transport's status and have the remote shell tell us
125 + /// the code in-band: [`RcSentinel::wrap`] appends a `printf` of `$?` to the
126 + /// script, and [`RcFilter`] pulls that line back out of the stream before anyone
127 + /// sees it. When the sentinel is missing we fail closed rather than guess.
128 + ///
129 + /// The marker carries a per-run nonce so build output cannot forge it, and the
130 + /// real sentinel is always the last thing on stdout, so parsing is anchored to
131 + /// the end of the stream rather than searching the whole log.
132 + #[derive(Debug, Clone)]
133 + pub(crate) struct RcSentinel {
134 + /// `__ops_exec_rc_<nonce>=`, the shell-safe body of the marker.
135 + body: String,
136 + /// `\n__ops_exec_rc_<nonce>=` — what to scan the byte stream for.
137 + marker: Vec<u8>,
138 + }
139 +
140 + impl RcSentinel {
141 + pub(crate) fn new() -> Self {
142 + let body = format!("__ops_exec_rc_{}=", nonce());
143 + let marker = format!("\n{body}").into_bytes();
144 + Self { body, marker }
145 + }
146 +
147 + /// Wrap `script` so its exit code is printed as the final line of stdout.
148 + ///
149 + /// The script runs in a **subshell**, which is load-bearing: our callers
150 + /// send `set -e` scripts, and an `exit`/errexit at the top level of the
151 + /// remote shell would take the whole shell down before the `printf` ran,
152 + /// costing us the sentinel on exactly the failures we care about most. A
153 + /// subshell contains both, and the parent (which never sets `-e`) lives to
154 + /// report `$?`.
155 + pub(crate) fn wrap(&self, script: &str) -> String {
156 + format!("(\n{script}\n)\n__ops_exec_status=$?\nprintf '\\n{}%s\\n' \"$__ops_exec_status\"\n", self.body)
157 + }
158 +
159 + /// How many trailing bytes must be held back to be sure a complete sentinel
160 + /// is never split across the boundary: the marker, the digits, and the
161 + /// closing newline.
162 + fn tail_len(&self) -> usize {
163 + self.marker.len() + 16
164 + }
165 +
166 + /// Split a stream tail into (bytes that are real output, the parsed code).
167 + /// Returns `None` for the code when no complete sentinel is present.
168 + fn split<'a>(&self, tail: &'a [u8]) -> (&'a [u8], Option<i32>) {
169 + let Some(idx) = last_index_of(tail, &self.marker) else {
170 + return (tail, None);
171 + };
172 + let rest = &tail[idx + self.marker.len()..];
173 + let Some(end) = rest.iter().position(|b| *b == b'\n') else {
174 + return (tail, None);
175 + };
176 + // The sentinel is the last thing written; anything after it means this
177 + // is not the line we appended.
178 + if !rest[end + 1..].is_empty() {
179 + return (tail, None);
180 + }
181 + match std::str::from_utf8(&rest[..end]).ok().and_then(|s| s.trim().parse::<i32>().ok()) {
182 + Some(code) => (&tail[..idx], Some(code)),
183 + None => (tail, None),
184 + }
185 + }
186 + }
187 +
188 + /// A short, shell-safe, per-run token. Time plus a counter is plenty: this only
189 + /// has to be unpredictable to the *script*, not to an attacker with our source.
190 + fn nonce() -> String {
191 + use std::sync::atomic::{AtomicU64, Ordering};
192 + static COUNTER: AtomicU64 = AtomicU64::new(0);
193 + let n = COUNTER.fetch_add(1, Ordering::Relaxed);
194 + let t = std::time::SystemTime::now()
195 + .duration_since(std::time::UNIX_EPOCH)
196 + .map(|d| d.as_nanos() as u64)
197 + .unwrap_or(0);
198 + format!("{t:016x}{:04x}", n & 0xffff)
199 + }
200 +
201 + fn last_index_of(haystack: &[u8], needle: &[u8]) -> Option<usize> {
202 + if needle.is_empty() || haystack.len() < needle.len() {
203 + return None;
204 + }
205 + (0..=haystack.len() - needle.len()).rev().find(|&i| &haystack[i..i + needle.len()] == needle)
206 + }
207 +
208 + /// Strips an [`RcSentinel`] off the tail of a stdout stream while it is still
209 + /// being streamed.
210 + ///
211 + /// Holds back the last [`RcSentinel::tail_len`] bytes so the sentinel is never
212 + /// forwarded to the sink (the operator's live log) or into the captured buffer
213 + /// that callers grep for banners. Only the tail is delayed, and only until the
214 + /// stream ends, so live output is unaffected in practice.
215 + ///
216 + /// `None` = a transport that reports exit status honestly (local `sh -c`), where
217 + /// there is no sentinel and every byte passes straight through.
218 + pub(crate) struct RcFilter {
219 + sentinel: Option<RcSentinel>,
220 + hold: Vec<u8>,
221 + }
222 +
223 + impl RcFilter {
224 + pub(crate) fn new(sentinel: Option<RcSentinel>) -> Self {
225 + Self { sentinel, hold: Vec::new() }
226 + }
227 +
228 + /// Feed a freshly-read chunk; returns the bytes that are safe to forward now.
229 + pub(crate) fn feed(&mut self, chunk: &[u8]) -> Vec<u8> {
230 + let Some(s) = &self.sentinel else { return chunk.to_vec() };
231 + self.hold.extend_from_slice(chunk);
232 + let cap = s.tail_len();
233 + if self.hold.len() > cap {
234 + let cut = self.hold.len() - cap;
235 + self.hold.drain(..cut).collect()
236 + } else {
237 + Vec::new()
238 + }
239 + }
240 +
241 + /// End of stream: returns the last real output bytes plus the parsed code.
242 + pub(crate) fn finish(self) -> (Vec<u8>, Option<i32>) {
243 + match &self.sentinel {
244 + None => (self.hold, None),
245 + Some(s) => {
246 + let (rest, code) = s.split(&self.hold);
247 + (rest.to_vec(), code)
248 + }
249 + }
250 + }
251 + }
252 +
253 + /// Decide the status to report from what the transport claimed and what the
254 + /// remote shell actually said.
255 + ///
256 + /// The sentinel wins whenever we have it: it is the remote command's own `$?`,
257 + /// while `status` is only the transport's opinion of it. With no sentinel, a
258 + /// non-zero transport status is still a real failure worth surfacing (ssh exits
259 + /// 255 when it cannot connect, and no sentinel is written). A *successful*
260 + /// transport status with no sentinel is the dangerous case this whole mechanism
261 + /// exists for, so it is an error rather than a green step.
262 + pub(crate) fn resolve_remote_status(
263 + host: &str,
264 + status: ExitStatus,
265 + sentinel_code: Option<i32>,
266 + ) -> Result<ExitStatus> {
267 + if let Some(code) = sentinel_code {
268 + return Ok(exit_status_from_code(code));
269 + }
270 + if !status.success() {
271 + return Ok(status);
272 + }
273 + anyhow::bail!(
274 + "{host}: the remote command produced no exit-status sentinel, and ssh reported success — \
275 + so its real exit code is unknown and cannot be trusted. This is what Tailscale SSH does \
276 + (it closes the channel without an exit-status message, making every command look like it \
277 + passed); it also happens if the remote shell is not POSIX or the connection dropped \
278 + mid-stream. Check `ssh {host} 'exit 42'`: a healthy host reports 42."
279 + )
280 + }
281 +
95 282 impl RemoteHost {
96 283 /// `"local"` (or empty) runs commands directly via `sh -c`; anything else
97 284 /// is an SSH target, on ssh's default port until [`RemoteHost::with_port`].
@@ -151,46 +338,80 @@ impl RemoteHost {
151 338 /// exit status plus captured bytes. `kill_on_drop` means cancelling the
152 339 /// caller's task (e.g. a newer build superseding this one) SIGKILLs the
153 340 /// child and — for SSH — drops the connection.
341 + /// An SSH host cannot be trusted to report exit status (see [`RcSentinel`]),
342 + /// so remote scripts carry their code back in-band. Local `sh -c` reports it
343 + /// honestly and needs no sentinel.
344 + pub(crate) fn rc_sentinel(&self) -> Option<RcSentinel> {
345 + (!self.is_local()).then(RcSentinel::new)
346 + }
347 +
348 + /// The `Command` to run `script` on this host, plus the sentinel its output
349 + /// will carry (if any). The one place a script is prepared for a host, so
350 + /// no execution path can forget to wrap it.
351 + pub(crate) fn command_for(&self, script: &str) -> (Command, Option<RcSentinel>) {
352 + let sentinel = self.rc_sentinel();
353 + let script = match &sentinel {
354 + Some(s) => s.wrap(script),
355 + None => script.to_string(),
356 + };
357 + (self.command(&script), sentinel)
358 + }
359 +
154 360 pub async fn run_streaming<S>(&self, script: &str, sink: Arc<Mutex<S>>) -> Result<RunOutput>
155 361 where
156 362 S: LogSink + Send + 'static,
157 363 {
158 - let mut child = self
159 - .command(script)
364 + let (mut cmd, sentinel) = self.command_for(script);
365 + let mut child = cmd
160 366 .stdout(Stdio::piped())
161 367 .stderr(Stdio::piped())
162 368 .kill_on_drop(true)
163 369 .spawn()
164 370 .with_context(|| format!("spawning command on {}", self.ssh_target))?;
165 371
166 - let stdout_task = tokio::spawn(drain(child.stdout.take(), sink.clone()));
167 - let stderr_task = tokio::spawn(drain(child.stderr.take(), sink.clone()));
372 + // Only stdout carries the sentinel; stderr streams untouched.
373 + let stdout_task =
374 + tokio::spawn(drain(child.stdout.take(), sink.clone(), RcFilter::new(sentinel.clone())));
375 + let stderr_task = tokio::spawn(drain(child.stderr.take(), sink.clone(), RcFilter::new(None)));
168 376 let status = child.wait().await.context("waiting on child")?;
169 - let stdout = stdout_task.await.unwrap_or_default();
170 - let stderr = stderr_task.await.unwrap_or_default();
377 + let (stdout, code) = stdout_task.await.unwrap_or_default();
378 + let (stderr, _) = stderr_task.await.unwrap_or_default();
379 + let status = match sentinel {
380 + Some(_) => resolve_remote_status(&self.ssh_target, status, code)?,
381 + None => status,
382 + };
171 383 Ok(RunOutput { status, stdout, stderr })
172 384 }
173 385 }
174 386
175 - /// Drain `stream` into the shared sink and return the concatenated bytes.
176 - async fn drain<R, S>(stream: Option<R>, sink: Arc<Mutex<S>>) -> Vec<u8>
387 + /// Drain `stream` into the shared sink and return the concatenated bytes plus
388 + /// any exit code the sentinel carried.
389 + async fn drain<R, S>(stream: Option<R>, sink: Arc<Mutex<S>>, mut filter: RcFilter) -> (Vec<u8>, Option<i32>)
177 390 where
178 391 R: AsyncRead + Unpin + Send + 'static,
179 392 S: LogSink + Send + 'static,
180 393 {
181 394 let mut total = Vec::new();
182 - let Some(mut s) = stream else { return total };
395 + let Some(mut s) = stream else { return (total, None) };
183 396 let mut buf = [0u8; 4096];
184 397 loop {
185 398 match s.read(&mut buf).await {
186 399 Ok(0) | Err(_) => break,
187 400 Ok(n) => {
188 - push_bounded(&mut total, &buf[..n], OUTPUT_TAIL_CAP);
189 - sink.lock().await.write_chunk(&buf[..n]).await;
401 + let out = filter.feed(&buf[..n]);
402 + if !out.is_empty() {
403 + push_bounded(&mut total, &out, OUTPUT_TAIL_CAP);
404 + sink.lock().await.write_chunk(&out).await;
405 + }
190 406 }
191 407 }
192 408 }
193 - total
409 + let (rest, code) = filter.finish();
410 + if !rest.is_empty() {
411 + push_bounded(&mut total, &rest, OUTPUT_TAIL_CAP);
412 + sink.lock().await.write_chunk(&rest).await;
413 + }
414 + (total, code)
194 415 }
195 416
196 417 /// Single-quote a string for safe inclusion in a `/bin/sh` command. This is
@@ -353,4 +574,140 @@ mod tests {
353 574 assert!(!out.success());
354 575 assert_eq!(out.status.code(), Some(3));
355 576 }
577 +
578 + #[test]
579 + fn local_gets_no_sentinel_and_ssh_does() {
580 + assert!(RemoteHost::new("local").rc_sentinel().is_none());
581 + assert!(RemoteHost::new("mbp").rc_sentinel().is_some());
582 + }
583 +
584 + #[test]
585 + fn each_sentinel_has_its_own_nonce() {
586 + // Build output cannot forge a marker it has never seen.
587 + assert_ne!(RcSentinel::new().body, RcSentinel::new().body);
588 + }
589 +
590 + /// Drive a wrapped script through a real `/bin/sh` (standing in for the
591 + /// remote login shell) and report what the sentinel carried back.
592 + async fn run_wrapped(script: &str) -> (Vec<u8>, Option<i32>) {
593 + let s = RcSentinel::new();
594 + let out = tokio::process::Command::new("sh")
595 + .arg("-c")
596 + .arg(s.wrap(script))
597 + .output()
598 + .await
599 + .unwrap();
600 + let mut filter = RcFilter::new(Some(s));
601 + let mut seen = filter.feed(&out.stdout);
602 + let (rest, code) = filter.finish();
603 + seen.extend_from_slice(&rest);
604 + (seen, code)
605 + }
606 +
607 + #[tokio::test]
608 + async fn sentinel_carries_the_real_code_through_a_shell() {
609 + for code in [0, 1, 42, 255] {
610 + let (_, got) = run_wrapped(&format!("exit {code}")).await;
611 + assert_eq!(got, Some(code), "wrapped `exit {code}` should report {code}");
612 + }
613 + }
614 +
615 + /// The reason for the subshell: our callers send `set -e` scripts, and an
616 + /// errexit at the top level of the remote shell would kill it before the
617 + /// sentinel printed — losing the code on exactly the failures that matter.
618 + #[tokio::test]
619 + async fn set_e_failure_still_reports_its_code() {
620 + let (_, code) = run_wrapped("set -e; false; echo unreachable").await;
621 + assert_eq!(code, Some(1));
622 + }
623 +
624 + /// Same for an explicit `exit`, which the driver's `set -e; git ...` scripts
625 + /// reach via a failing command.
626 + #[tokio::test]
627 + async fn explicit_exit_still_reports_its_code() {
628 + let (out, code) = run_wrapped("echo before; exit 7").await;
629 + assert_eq!(code, Some(7));
630 + assert_eq!(out, b"before\n");
631 + }
632 +
633 + #[tokio::test]
634 + async fn the_sentinel_never_reaches_the_caller_or_the_log() {
635 + // Output with and without a trailing newline: the wrapper adds a leading
636 + // \n so the marker always starts a line, and stripping must put the
637 + // stream back exactly as the script wrote it.
638 + let (out, code) = run_wrapped("printf 'no trailing newline'").await;
639 + assert_eq!(code, Some(0));
640 + assert_eq!(String::from_utf8_lossy(&out), "no trailing newline");
641 +
642 + let (out, code) = run_wrapped("echo 'with trailing newline'").await;
643 + assert_eq!(code, Some(0));
644 + assert_eq!(String::from_utf8_lossy(&out), "with trailing newline\n");
645 + }
646 +
647 + /// A build log that prints something sentinel-shaped must not be able to
648 + /// pass itself off as the exit code: the nonce is unguessable and the real
649 + /// sentinel is always last.
650 + #[tokio::test]
651 + async fn script_output_cannot_forge_the_code() {
652 + let (out, code) = run_wrapped("echo '__ops_exec_rc_deadbeef=0'; exit 9").await;
653 + assert_eq!(code, Some(9), "the forged line must not be read as the code");
654 + assert_eq!(String::from_utf8_lossy(&out), "__ops_exec_rc_deadbeef=0\n");
655 + }
656 +
657 + /// The filter holds back a tail; a sentinel split across read boundaries
658 + /// must still be recognized (and never leak a fragment into the log).
659 + #[test]
660 + fn sentinel_split_across_chunks_is_still_stripped() {
661 + let s = RcSentinel::new();
662 + let stream = format!("hello\n\n{}42\n", s.body).into_bytes();
663 + let mut filter = RcFilter::new(Some(s));
664 + let mut seen = Vec::new();
665 + for chunk in stream.chunks(3) {
666 + seen.extend_from_slice(&filter.feed(chunk));
667 + }
668 + let (rest, code) = filter.finish();
669 + seen.extend_from_slice(&rest);
670 + assert_eq!(code, Some(42));
671 + assert_eq!(String::from_utf8_lossy(&seen), "hello\n");
672 + }
673 +
674 + #[test]
675 + fn a_missing_sentinel_yields_no_code_and_keeps_every_byte() {
676 + let mut filter = RcFilter::new(Some(RcSentinel::new()));
677 + let mut seen = filter.feed(b"build output, no sentinel\n");
678 + let (rest, code) = filter.finish();
679 + seen.extend_from_slice(&rest);
680 + assert_eq!(code, None);
681 + assert_eq!(String::from_utf8_lossy(&seen), "build output, no sentinel\n");
682 + }
683 +
684 + /// The whole point: a transport that claims success without a sentinel is
685 + /// reported as an error, never as a green step.
686 + #[test]
687 + fn success_without_a_sentinel_fails_closed() {
688 + let err = resolve_remote_status("mbp", exit_status_from_code(0), None).unwrap_err();
689 + let msg = err.to_string();
690 + assert!(msg.contains("mbp"), "should name the host: {msg}");
691 + assert!(msg.contains("no exit-status sentinel"), "{msg}");
692 + }
693 +
694 + #[test]
695 + fn the_sentinel_outranks_what_the_transport_claimed() {
696 + // Tailscale SSH's lie: ssh says 0, the remote command really failed.
697 + let got = resolve_remote_status("mbp", exit_status_from_code(0), Some(42)).unwrap();
698 + assert_eq!(got.code(), Some(42));
699 + assert!(!got.success());
700 +
701 + // And a real success is still a success.
702 + let got = resolve_remote_status("mbp", exit_status_from_code(0), Some(0)).unwrap();
703 + assert!(got.success());
704 + }
705 +
706 + /// ssh exits 255 and writes no sentinel when it cannot connect. That is a
707 + /// real failure with a real code, not the ambiguous case — surface it.
708 + #[test]
709 + fn a_transport_failure_with_no_sentinel_is_reported_as_itself() {
710 + let got = resolve_remote_status("mbp", exit_status_from_code(255), None).unwrap();
711 + assert_eq!(got.code(), Some(255));
712 + }
356 713 }
@@ -18,7 +18,6 @@ use anyhow::{Context, Result};
18 18 use async_trait::async_trait;
19 19 use futures_util::StreamExt;
20 20 use std::path::Path;
21 - use std::process::ExitStatus;
22 21
23 22 /// A handle to one `ops-agent`, scoped to a caller-side capability set.
24 23 pub struct AgentRpc {
@@ -54,22 +53,15 @@ impl AgentRpc {
54 53 }
55 54 }
56 55
57 - /// Reconstruct an `ExitStatus` from a raw exit code (Unix wait-status encoding:
58 - /// the code lives in the high byte). The agent reports a process exit code,
59 - /// which is 0..=255; `.success()` (code 0) and the non-zero distinction — all a
60 - /// recipe branches on — are preserved exactly. A negative/out-of-range code
61 - /// (e.g. the agent's `-1` "terminated by signal" sentinel) collapses into the
62 - /// low byte, so it reads back as non-zero but not its original value.
63 - #[cfg(unix)]
64 - fn exit_status(code: i32) -> ExitStatus {
65 - use std::os::unix::process::ExitStatusExt;
66 - ExitStatus::from_raw((code & 0xff) << 8)
67 - }
68 - #[cfg(not(unix))]
69 - fn exit_status(code: i32) -> ExitStatus {
70 - use std::os::windows::process::ExitStatusExt;
71 - ExitStatus::from_raw(code as u32)
72 - }
56 + /// Reconstruct an `ExitStatus` from the raw exit code the agent reports, which
57 + /// is a process exit code (0..=255); `.success()` and the non-zero distinction —
58 + /// all a recipe branches on — are preserved exactly. A negative/out-of-range
59 + /// code (e.g. the agent's `-1` "terminated by signal" sentinel) collapses into
60 + /// the low byte, so it reads back as non-zero but not its original value.
61 + ///
62 + /// Shared with the ssh path, which recovers a code the same way from its
63 + /// [`crate::remote::RcSentinel`].
64 + use crate::remote::exit_status_from_code as exit_status;
73 65
74 66 #[async_trait]
75 67 impl Executor for AgentRpc {
@@ -151,8 +151,8 @@ impl Executor for LocalExec {
151 151 async fn run_streaming(&self, step: &Step, sink: &mut dyn LogSink) -> Result<RunOutput> {
152 152 gate(&self.caps, "local", step)?;
153 153 validate_env_names(step)?;
154 - let cmd = self.host.command(&render_shell_line(step));
155 - run_command_into_sink(cmd, sink).await
154 + let (cmd, sentinel) = self.host.command_for(&render_shell_line(step));
155 + run_command_into_sink(cmd, sink, sentinel, "local").await
156 156 }
157 157
158 158 async fn pull_file(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {
@@ -216,8 +216,8 @@ impl Executor for SshExec {
216 216 async fn run_streaming(&self, step: &Step, sink: &mut dyn LogSink) -> Result<RunOutput> {
217 217 gate(&self.caps, self.host.ssh_target(), step)?;
218 218 validate_env_names(step)?;
219 - let cmd = self.host.command(&render_shell_line(step));
220 - run_command_into_sink(cmd, sink).await
219 + let (cmd, sentinel) = self.host.command_for(&render_shell_line(step));
220 + run_command_into_sink(cmd, sink, sentinel, self.host.ssh_target()).await
221 221 }
222 222
223 223 async fn pull_file(&self, remote: &Path, local: &Path, opts: &SyncOpts) -> Result<()> {