1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
//! End-to-end test of the "agent bash tool" adoption shape.
//!
//! This is the integration an embedder writes when it wants bashkit to *be*
//! its shell — a real workspace mounted read-write, and commands bashkit does
//! not implement bridged to host executables so the same code path works on
//! Windows without a real `bash`.
//!
//! Decision: this exists because the shape was only ever validated downstream.
//! The first adopter to write it (crabot) shipped two defects bashkit's own
//! tests could not see: a harness that emptied `PATH`, and a stdin pipe to a
//! host process that never reached EOF and hung the suite. Both are behaviors
//! of *this* composition — `CommandResolver` + `HostMounts` + `realfs` — so
//! they belong here, and this file runs on Windows in CI too.
//!
//! The bridge below is deliberately the whole thing: if bridging a host
//! command needs more code than this, that is a gap in bashkit's API.
#![cfg(feature = "realfs")]
use async_trait::async_trait;
use bashkit::{Bash, Builtin, BuiltinContext, CommandResolver, ExecResult, HostMount, HostMounts};
use std::io::Read;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Bound on a bridged host command.
///
/// A real bridge needs one regardless; here it also keeps a defect from
/// turning into a hung CI job. A leaked stdin pipe means the child waits for
/// EOF forever, and a test that hangs reports nothing — this turns it into a
/// named failure.
const HOST_TIMEOUT: Duration = Duration::from_secs(20);
/// Run a script through the host shell.
///
/// `sh`/`cmd` rather than a bare binary because the set of standalone
/// executables shared by Linux, macOS, and Windows is effectively empty.
fn shell_out(script: &str) -> (String, Vec<String>) {
if cfg!(windows) {
("cmd".into(), vec!["/C".into(), script.into()])
} else {
("sh".into(), vec!["-c".into(), script.into()])
}
}
/// Map a bridged name to the host program it runs.
///
/// The `host-` prefix matters: bashkit implements `cat`, `echo`, `pwd` and
/// `false` itself, so a test using those names would never reach the bridge —
/// it would silently assert on builtins instead. Returning `None` for
/// unprefixed names also keeps the normal 127 path observable.
fn host_program(name: &str) -> Option<&'static str> {
Some(match name.strip_prefix("host-")? {
// Reads stdin to EOF on every platform.
"cat" => {
if cfg!(windows) {
"sort"
} else {
"cat"
}
}
"pwd" => {
if cfg!(windows) {
"cd"
} else {
"pwd"
}
}
"false" => {
if cfg!(windows) {
"exit 1"
} else {
"false"
}
}
"echo" => "echo",
_ => return None,
})
}
/// Bridges one command name to a host process.
///
/// The interesting parts, all of which the first adopter got wrong at least
/// once: the cwd comes from the mount table (never a default), stdin is fed
/// through a pipe that must reach EOF, and the exit code is passed through.
struct HostCommand {
name: String,
program: &'static str,
mounts: Arc<HostMounts>,
}
#[async_trait]
impl Builtin for HostCommand {
async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
// A cwd on no mount is an error. Falling back to some default would
// run the command in the wrong directory.
let Some(dir) = self.mounts.resolve(ctx.cwd) else {
return Ok(ExecResult::err(
format!("{}: cwd is not on a host mount", self.name),
1,
));
};
let script = std::iter::once(self.program.to_string())
.chain(ctx.args.iter().cloned())
.collect::<Vec<_>>()
.join(" ");
let (program, args) = shell_out(&script);
// Bytes, not text: a host command's stdin may not be valid UTF-8.
let stdin_data = ctx.stdin.map(|s| s.as_bytes().to_vec());
let output = tokio::task::spawn_blocking(move || {
let mut cmd = std::process::Command::new(program);
cmd.args(args)
.current_dir(dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(if stdin_data.is_some() {
Stdio::piped()
} else {
Stdio::null()
});
let mut child = cmd.spawn()?;
if let Some(data) = stdin_data {
use std::io::Write;
// Dropping the handle closes the pipe. Without that the child
// waits for EOF forever — the exact hang this file exists for.
let mut pipe = child.stdin.take().expect("stdin piped");
pipe.write_all(&data)?;
drop(pipe);
}
let deadline = Instant::now() + HOST_TIMEOUT;
let status = loop {
if let Some(status) = child.try_wait()? {
break Some(status);
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
break None;
}
std::thread::sleep(Duration::from_millis(20));
};
// Only drain on a clean exit. After a timeout kill, a grandchild
// the shell spawned can still hold the write end, and reading
// would block forever — turning the bounded wait back into a hang.
//
// Reading after exit is safe here because the outputs are small.
// A production bridge must drain concurrently with the wait, or a
// child that fills the pipe buffer blocks before it can exit.
let mut stdout = Vec::new();
let mut stderr = Vec::new();
if status.is_some() {
if let Some(mut pipe) = child.stdout.take() {
let _ = pipe.read_to_end(&mut stdout);
}
if let Some(mut pipe) = child.stderr.take() {
let _ = pipe.read_to_end(&mut stderr);
}
}
Ok::<_, std::io::Error>((status, stdout, stderr))
})
.await
.expect("spawn_blocking");
Ok(match output {
Ok((Some(status), stdout, stderr)) => ExecResult {
stdout: stdout.into(),
stderr: stderr.into(),
exit_code: status.code().unwrap_or(1),
..Default::default()
},
Ok((None, _, _)) => ExecResult::err(
format!(
"{}: host command exceeded {}s — did stdin reach EOF?",
self.name,
HOST_TIMEOUT.as_secs()
),
124,
),
// Bash's convention for a command that could not be executed.
Err(e) => ExecResult::err(format!("{}: {e}", self.name), 127),
})
}
}
/// Bridges any name bashkit did not resolve, exactly as an agent tool would.
struct HostBridge {
mounts: Arc<HostMounts>,
}
impl CommandResolver for HostBridge {
fn resolve(&self, name: &str) -> Option<Arc<dyn Builtin>> {
let program = host_program(name)?;
Some(Arc::new(HostCommand {
name: name.to_owned(),
program,
mounts: Arc::clone(&self.mounts),
}))
}
}
struct Fixture {
bash: Bash,
workspace: PathBuf,
_dir: tempfile::TempDir,
}
fn fixture() -> Fixture {
let dir = tempfile::tempdir().unwrap();
let workspace = std::fs::canonicalize(dir.path()).unwrap();
std::fs::create_dir(workspace.join("sub")).unwrap();
// Built up front and shared: the resolver is passed *into* the builder, so
// it cannot ask the not-yet-existing instance where things are mounted.
let mounts = Arc::new(HostMounts::new([HostMount {
host_path: workspace.clone(),
vfs_path: PathBuf::from("/workspace"),
}]));
let bash = Bash::builder()
.allowed_mount_paths([workspace.clone()])
.mount_real_readwrite_at(workspace.clone(), "/workspace")
.cwd("/workspace")
.command_resolver(Arc::new(HostBridge {
mounts: Arc::clone(&mounts),
}))
.build();
// The bridge and the real mount must agree, or every cwd mapping is wrong.
assert_eq!(bash.host_path_for("/workspace"), Some(workspace.clone()));
Fixture {
bash,
workspace,
_dir: dir,
}
}
#[tokio::test]
async fn bridged_host_command_runs_and_returns_output() {
let mut f = fixture();
let result = f.bash.exec("host-echo hello from host").await.unwrap();
assert_eq!(result.exit_code, 0, "unexpected: {result:?}");
assert!(
result.stdout.contains("hello from host"),
"unexpected: {result:?}"
);
}
/// A builtin bashkit implements must win over the bridge — the resolver runs
/// last, so it must never be consulted for `echo`.
#[tokio::test]
async fn builtins_win_over_the_bridge() {
let mut f = fixture();
let result = f.bash.exec("echo bridged").await.unwrap();
assert_eq!(result.stdout, "bridged\n");
}
/// A name the bridge declines still gets the normal `command not found`.
#[tokio::test]
async fn undeclined_names_keep_command_not_found() {
let mut f = fixture();
let result = f.bash.exec("definitely-not-a-command-xyz").await.unwrap();
assert_eq!(result.exit_code, 127, "unexpected: {result:?}");
}
/// Stdin must reach the host process *and* hit EOF. A bridge that leaks the
/// write end hangs here instead of failing, so the timeout is the assertion.
#[tokio::test]
async fn pipeline_stdin_reaches_the_host_process_and_reaches_eof() {
let mut f = fixture();
let result = f.bash.exec("echo piped-payload | host-cat").await.unwrap();
assert_eq!(
result.exit_code, 0,
"host command did not complete: {result:?}"
);
assert!(
result.stdout.contains("piped-payload"),
"unexpected: {result:?}"
);
}
/// `cd` moves the VFS cwd; the bridge must map the *new* cwd to the host, not
/// silently keep spawning in the workspace root.
#[tokio::test]
async fn cd_changes_the_host_spawn_directory() {
let mut f = fixture();
let result = f.bash.exec("cd sub && host-pwd").await.unwrap();
assert_eq!(result.exit_code, 0, "unexpected: {result:?}");
let printed = result.stdout.trim().replace('\\', "/");
assert!(
printed.ends_with("/sub"),
"host command ran in the wrong directory: {printed}"
);
}
/// Writes through bashkit's own builtins must land on the real filesystem —
/// the reason the workspace is mounted read-write rather than overlaid.
#[tokio::test]
async fn builtin_writes_reach_the_real_filesystem() {
let mut f = fixture();
let result = f
.bash
.exec("echo written > out.txt && mkdir -p sub2 && cp out.txt sub2/copy.txt")
.await
.unwrap();
assert_eq!(result.exit_code, 0, "unexpected: {result:?}");
assert_eq!(
std::fs::read_to_string(f.workspace.join("out.txt")).unwrap(),
"written\n"
);
assert_eq!(
std::fs::read_to_string(f.workspace.join("sub2/copy.txt")).unwrap(),
"written\n"
);
}
/// A non-zero host exit code behaves like any other command: it does not abort
/// the script, and it drives `&&` / `||`.
#[tokio::test]
async fn host_exit_codes_drive_control_flow() {
let mut f = fixture();
let result = f.bash.exec("host-false || echo recovered").await.unwrap();
assert!(
result.stdout.contains("recovered"),
"unexpected: {result:?}"
);
let result = f.bash.exec("host-false; echo after").await.unwrap();
assert!(
result.stdout.contains("after"),
"script aborted: {result:?}"
);
let result = f.bash.exec("host-false").await.unwrap();
assert_ne!(result.exit_code, 0);
}
/// The whole point of the composition: syntax bashkit implements (heredocs,
/// command substitution, pipelines) works with bridged commands mixed in,
/// with no real `bash` process anywhere.
#[tokio::test]
async fn bashkit_syntax_composes_with_bridged_commands() {
let mut f = fixture();
let result = f
.bash
.exec("cat <<'EOF'\nheredoc line\nEOF\necho \"host says: $(host-echo ok)\"")
.await
.unwrap();
assert!(
result.stdout.contains("heredoc line"),
"unexpected: {result:?}"
);
assert!(
result.stdout.contains("host says: ok"),
"unexpected: {result:?}"
);
}