diff --git a/host/js/README.md b/host/js/README.md index 8c5968b..263dd74 100644 --- a/host/js/README.md +++ b/host/js/README.md @@ -9,8 +9,16 @@ WAL preserves atomic transactions after SIGKILL. `synchronous=FULL` flushes each The coding tools access the shared VFS through Rust operations. The shell sends commands to the host's `Exec` implementation. The adapter supplies the filesystem operations that the four coding tools use. -It does not implement the other portable filesystem capabilities or shell spill files. -The console currently uses an explicit unavailable stub. Host integration can call `run_with_exec`. +It does not implement the other portable filesystem capabilities. +The console uses the real process table and passes the conversation id to each command. +Large shell output spills to `/tmp/bash-.log` inside the world. +The tool result names the file and retains pi's limited output window. + +Build the userland with `nix develop -c nu userland/bundle.nu`. +The script discovers binaries through Cargo metadata and reads each package's optional `tools.txt`. +It mounts through `sausage run --userland target/userland/bin`. +The `sh` subcommand accepts the same option. Both commands default to `SAUSAGE_USERLAND`. +The bundle appears read-only at `/bin`. Agents can install tools in `/usr/local/bin`. ## Reproduce the bundles diff --git a/host/js/bundle.js b/host/js/bundle.js index 5b83345..490ae91 100644 --- a/host/js/bundle.js +++ b/host/js/bundle.js @@ -44318,7 +44318,7 @@ var path2 = (cwd, input) => { } return "/" + parts.join("/"); }; -function environment(cwd = "/") { +function environment(cwd = "/", conversation) { const call = (kind, p, bytes2 = []) => ops.op_file(kind, path2(cwd, p), bytes2); const env = { id: "world", @@ -44355,7 +44355,7 @@ function environment(cwd = "/") { const exists = await call("stat", current); if (!exists.ok) { const r = await call("mkdir", current); - if (!r.ok) return r; + if (!r.ok && (await call("stat", current)).value?.kind !== "directory") return r; } } return ok(void 0); @@ -44377,8 +44377,27 @@ function environment(cwd = "/") { }); }, exec: async (command, options, context) => { + let spillPath; + let pending = "", bytes2 = 0, lines2 = 0; + const output = async (text2, stream2) => { + options?.onOutput?.(text2, context, { stream: stream2 }); + if (!options?.spill) return; + bytes2 += new TextEncoder().encode(text2).length; + lines2 += text2.split("\n").length - 1; + if (!spillPath) { + pending += text2; + if (bytes2 <= options.spill.afterBytes && lines2 + (pending.endsWith("\n") ? 0 : 1) <= options.spill.afterLines) return; + spillPath = `/tmp/bash-${crypto.randomUUID()}.log`; + const created = await env.writeFile(spillPath, pending); + if (!created.ok) throw new Error(created.error.message); + pending = ""; + } else { + const appended = await call("append", spillPath, Array.from(new TextEncoder().encode(text2))); + if (!appended.ok) throw new Error(appended.error.message); + } + }; try { - const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout); + const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, conversation); const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() }; let result; while (true) { @@ -44387,16 +44406,16 @@ function environment(cwd = "/") { result = chunk; break; } - options?.onOutput?.(decoders[chunk.stream].decode(new Uint8Array(chunk.bytes), { stream: true }), context, { stream: chunk.stream }); + await output(decoders[chunk.stream].decode(new Uint8Array(chunk.bytes), { stream: true }), chunk.stream); } if (result.error) throw new Error(result.error); for (const stream2 of ["stdout", "stderr"]) { const text2 = decoders[stream2].decode(); - if (text2) options?.onOutput?.(text2, context, { stream: stream2 }); + if (text2) await output(text2, stream2); } - return ok({ exitCode: result.exitCode }); + return ok({ exitCode: result.exitCode, ...spillPath ? { spillPath } : {} }); } catch (error) { - return { ok: false, error }; + return { ok: false, error: Object.assign(new Error(String(error)), { code: "unknown", ...spillPath ? { spillPath } : {} }) }; } }, cleanup: async () => { @@ -44442,7 +44461,7 @@ registry.install(CodingTools); var harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry, - env: (target) => environment(target.cwd ?? "/") + env: (target) => environment(target.cwd ?? "/", target.conversationId) }, BACKGROUND_CONTEXT); var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "openai", modelId: model2.id }, thinkingLevel: "low", cwd: "/", extensions: [CodingTools] } }); var watch = await root.watch(BACKGROUND_CONTEXT); diff --git a/host/js/environment.ts b/host/js/environment.ts index 7ef3900..322eed3 100644 --- a/host/js/environment.ts +++ b/host/js/environment.ts @@ -10,7 +10,7 @@ const path = (cwd: string, input: string) => { } return '/' + parts.join('/'); }; -export function environment(cwd = '/') { +export function environment(cwd = '/', conversation?: string) { const call = (kind: string, p: string, bytes: number[] = []) => ops.op_file(kind, path(cwd, p), bytes); const env: any = { id: 'world', cwd, @@ -44,7 +44,10 @@ export function environment(cwd = '/') { for (const part of full.split('/').filter(Boolean)) { current += '/' + part; const exists = await call('stat', current); - if (!exists.ok) { const r = await call('mkdir', current); if (!r.ok) return r; } + if (!exists.ok) { + const r = await call('mkdir', current); + if (!r.ok && (await call('stat', current)).value?.kind !== 'directory') return r; + } } return ok(undefined); }, @@ -64,22 +67,41 @@ export function environment(cwd = '/') { }); }, exec: async (command: any, options: any, context: any) => { + let spillPath: string | undefined; + let pending = '', bytes = 0, lines = 0; + const output = async (text: string, stream: string) => { + options?.onOutput?.(text, context, { stream }); + if (!options?.spill) return; + bytes += new TextEncoder().encode(text).length; + lines += text.split('\n').length - 1; + if (!spillPath) { + pending += text; + if (bytes <= options.spill.afterBytes && lines + (pending.endsWith('\n') ? 0 : 1) <= options.spill.afterLines) return; + spillPath = `/tmp/bash-${crypto.randomUUID()}.log`; + const created = await env.writeFile(spillPath, pending); + if (!created.ok) throw new Error(created.error.message); + pending = ''; + } else { + const appended = await call('append', spillPath, Array.from(new TextEncoder().encode(text))); + if (!appended.ok) throw new Error(appended.error.message); + } + }; try { - const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout); + const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, conversation); const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() }; let result; while (true) { const chunk = await ops.op_exec_next(rid); if (!chunk.stream) { result = chunk; break; } - options?.onOutput?.(decoders[chunk.stream].decode(new Uint8Array(chunk.bytes), { stream: true }), context, { stream: chunk.stream }); + await output(decoders[chunk.stream].decode(new Uint8Array(chunk.bytes), { stream: true }), chunk.stream); } if (result.error) throw new Error(result.error); for (const stream of ['stdout', 'stderr']) { const text = decoders[stream].decode(); - if (text) options?.onOutput?.(text, context, { stream }); + if (text) await output(text, stream); } - return ok({ exitCode: result.exitCode }); - } catch (error) { return { ok: false, error }; } + return ok({ exitCode: result.exitCode, ...(spillPath ? { spillPath } : {}) }); + } catch (error) { return { ok: false, error: Object.assign(new Error(String(error)), { code: 'unknown', ...(spillPath ? { spillPath } : {}) }) }; } }, cleanup: async () => {}, }; diff --git a/host/js/main.ts b/host/js/main.ts index 00dfb71..ddb7fa9 100644 --- a/host/js/main.ts +++ b/host/js/main.ts @@ -27,7 +27,7 @@ models.setProvider({ ...provider, baseUrl: model.baseUrl, getModels: () => [mode const registry = createRegistry(); registry.install(CodingTools); const harness = await Harness.open(await openStorage(globalThis.storagePath), { - models, registry, env: (target: any) => environment(target.cwd ?? '/'), + models, registry, env: (target: any) => environment(target.cwd ?? '/', target.conversationId), }, context); const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/', extensions: [CodingTools] } }); const watch = await root.watch(context); diff --git a/host/js/test-bundle.js b/host/js/test-bundle.js index 03a2bc0..0de4781 100644 --- a/host/js/test-bundle.js +++ b/host/js/test-bundle.js @@ -21212,7 +21212,7 @@ var path = (cwd, input) => { } return "/" + parts.join("/"); }; -function environment(cwd = "/") { +function environment(cwd = "/", conversation) { const call = (kind, p, bytes2 = []) => ops.op_file(kind, path(cwd, p), bytes2); const env = { id: "world", @@ -21249,7 +21249,7 @@ function environment(cwd = "/") { const exists = await call("stat", current); if (!exists.ok) { const r = await call("mkdir", current); - if (!r.ok) return r; + if (!r.ok && (await call("stat", current)).value?.kind !== "directory") return r; } } return ok(void 0); @@ -21271,8 +21271,27 @@ function environment(cwd = "/") { }); }, exec: async (command, options, context) => { + let spillPath; + let pending = "", bytes2 = 0, lines2 = 0; + const output = async (text, stream) => { + options?.onOutput?.(text, context, { stream }); + if (!options?.spill) return; + bytes2 += new TextEncoder().encode(text).length; + lines2 += text.split("\n").length - 1; + if (!spillPath) { + pending += text; + if (bytes2 <= options.spill.afterBytes && lines2 + (pending.endsWith("\n") ? 0 : 1) <= options.spill.afterLines) return; + spillPath = `/tmp/bash-${crypto.randomUUID()}.log`; + const created = await env.writeFile(spillPath, pending); + if (!created.ok) throw new Error(created.error.message); + pending = ""; + } else { + const appended = await call("append", spillPath, Array.from(new TextEncoder().encode(text))); + if (!appended.ok) throw new Error(appended.error.message); + } + }; try { - const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout); + const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout, conversation); const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() }; let result2; while (true) { @@ -21281,16 +21300,16 @@ function environment(cwd = "/") { result2 = chunk; break; } - options?.onOutput?.(decoders[chunk.stream].decode(new Uint8Array(chunk.bytes), { stream: true }), context, { stream: chunk.stream }); + await output(decoders[chunk.stream].decode(new Uint8Array(chunk.bytes), { stream: true }), chunk.stream); } if (result2.error) throw new Error(result2.error); for (const stream of ["stdout", "stderr"]) { const text = decoders[stream].decode(); - if (text) options?.onOutput?.(text, context, { stream }); + if (text) await output(text, stream); } - return ok({ exitCode: result2.exitCode }); + return ok({ exitCode: result2.exitCode, ...spillPath ? { spillPath } : {} }); } catch (error) { - return { ok: false, error }; + return { ok: false, error: Object.assign(new Error(String(error)), { code: "unknown", ...spillPath ? { spillPath } : {} }) }; } }, cleanup: async () => { @@ -22245,10 +22264,17 @@ var crash = globalThis.testCrash; var faux = fauxProvider({ tokensPerSecond: crash === "model" ? 1 : 1e5 }); models.setProvider(faux.provider); faux.setResponses([ - fauxAssistantMessage([fauxToolCall("write", { path: "/test.txt", content: "before\n" }, { id: "write" })], { stopReason: "toolUse" }), + fauxAssistantMessage([ + fauxToolCall("write", { path: "/test.txt", content: "before\n" }, { id: "write" }), + ...globalThis.testP1 ? [ + fauxToolCall("write", { path: "/parallel/a", content: "a" }, { id: "parallel-a" }), + fauxToolCall("write", { path: "/parallel/b", content: "b" }, { id: "parallel-b" }) + ] : [] + ], { stopReason: "toolUse" }), fauxAssistantMessage([fauxToolCall("read", { path: "/test.txt" }, { id: "read" })], { stopReason: "toolUse" }), fauxAssistantMessage([fauxToolCall("edit", { path: "/test.txt", edits: [{ oldText: "before", newText: "after" }] }, { id: "edit" })], { stopReason: "toolUse" }), - fauxAssistantMessage([fauxToolCall("bash", { command: "echo test" }, { id: "bash" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxToolCall("bash", { command: globalThis.testP1 ? "echo streamed stdout; echo streamed stderr >&2" : "echo test" }, { id: "bash" })], { stopReason: "toolUse" }), + ...globalThis.testP1 ? [fauxAssistantMessage([fauxToolCall("bash", { command: "seq 1 10000" }, { id: "large" })], { stopReason: "toolUse" })] : [], fauxAssistantMessage([fauxText("done")]) ]); if (crash) { @@ -22258,9 +22284,15 @@ if (crash) { fauxAssistantMessage([fauxText("A long response that the test interrupts before it completes.")]) ] : [fauxAssistantMessage([fauxText("resumed done")])]); } +if (globalThis.testP1) { + const failed = await environment("/").exec([], {}, BACKGROUND_CONTEXT); + if (failed.ok || !(failed.error instanceof Error) || !failed.error.message) { + throw new Error("Execution failures must preserve their error message"); + } +} var registry = createRegistry(); registry.install(CodingTools); -var harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry, env: () => environment("/") }, BACKGROUND_CONTEXT); +var harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry, env: (target) => environment("/", target.conversationId) }, BACKGROUND_CONTEXT); var root = await harness.root(BACKGROUND_CONTEXT, { agent: { model: { provider: "faux", modelId: "faux-1" }, extensions: [CodingTools], cwd: "/" } }); if (crash === "model") { const progress = await root.watch(BACKGROUND_CONTEXT); @@ -22284,5 +22316,8 @@ if (!crash && transcript.includes('"isError":true')) throw new Error(transcript) for (const expected of crash ? ["resumed done"] : ["before", "after", "streamed stdout", "streamed stderr", "done"]) { if (!transcript.includes(expected)) throw new Error(`Missing ${expected}: ${transcript}`); } +if (globalThis.testP1) { + if (!transcript.includes("Full output: /tmp/bash-")) throw new Error("Missing spill diagnostic"); +} await watch.stop(); await harness.close(BACKGROUND_CONTEXT); diff --git a/host/js/test.ts b/host/js/test.ts index 323e70d..a9e9b2c 100644 --- a/host/js/test.ts +++ b/host/js/test.ts @@ -11,10 +11,17 @@ const crash = globalThis.testCrash; const faux = fauxProvider({ tokensPerSecond: crash === 'model' ? 1 : 100000 }); models.setProvider(faux.provider); faux.setResponses([ - fauxAssistantMessage([fauxToolCall('write', { path: '/test.txt', content: 'before\n' }, { id: 'write' })], { stopReason: 'toolUse' }), + fauxAssistantMessage([ + fauxToolCall('write', { path: '/test.txt', content: 'before\n' }, { id: 'write' }), + ...(globalThis.testP1 ? [ + fauxToolCall('write', { path: '/parallel/a', content: 'a' }, { id: 'parallel-a' }), + fauxToolCall('write', { path: '/parallel/b', content: 'b' }, { id: 'parallel-b' }), + ] : []), + ], { stopReason: 'toolUse' }), fauxAssistantMessage([fauxToolCall('read', { path: '/test.txt' }, { id: 'read' })], { stopReason: 'toolUse' }), fauxAssistantMessage([fauxToolCall('edit', { path: '/test.txt', edits: [{ oldText: 'before', newText: 'after' }] }, { id: 'edit' })], { stopReason: 'toolUse' }), - fauxAssistantMessage([fauxToolCall('bash', { command: 'echo test' }, { id: 'bash' })], { stopReason: 'toolUse' }), + fauxAssistantMessage([fauxToolCall('bash', { command: globalThis.testP1 ? 'echo streamed stdout; echo streamed stderr >&2' : 'echo test' }, { id: 'bash' })], { stopReason: 'toolUse' }), + ...(globalThis.testP1 ? [fauxAssistantMessage([fauxToolCall('bash', { command: 'seq 1 10000' }, { id: 'large' })], { stopReason: 'toolUse' })] : []), fauxAssistantMessage([fauxText('done')]), ]); if (crash) { @@ -24,9 +31,15 @@ if (crash) { fauxAssistantMessage([fauxText('A long response that the test interrupts before it completes.')]), ] : [fauxAssistantMessage([fauxText('resumed done')])]); } +if (globalThis.testP1) { + const failed = await environment('/').exec([], {}, context); + if (failed.ok || !(failed.error instanceof Error) || !failed.error.message) { + throw new Error('Execution failures must preserve their error message'); + } +} const registry = createRegistry(); registry.install(CodingTools); -const harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry, env: () => environment('/') }, context); +const harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry, env: (target: any) => environment('/', target.conversationId) }, context); const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, extensions: [CodingTools], cwd: '/' } }); if (crash === 'model') { const progress = await root.watch(context); @@ -50,5 +63,8 @@ if (!crash && transcript.includes('"isError":true')) throw new Error(transcript) for (const expected of crash ? ['resumed done'] : ['before', 'after', 'streamed stdout', 'streamed stderr', 'done']) { if (!transcript.includes(expected)) throw new Error(`Missing ${expected}: ${transcript}`); } +if (globalThis.testP1) { + if (!transcript.includes('Full output: /tmp/bash-')) throw new Error('Missing spill diagnostic'); +} await watch.stop(); await harness.close(context); diff --git a/host/src/harness/environment.rs b/host/src/harness/environment.rs index 4fbdef0..8ed9ef0 100644 --- a/host/src/harness/environment.rs +++ b/host/src/harness/environment.rs @@ -27,11 +27,12 @@ pub async fn op_file( let size = file.stat().await?.size; Ok(json!(file.read(0, size as usize).await?)) } - "write" => { - let file = root.open(&path, Open { follow: true, write: true, create: true, truncate: true, ..Default::default() }).await?; + "write" | "append" => { + let file = root.open(&path, Open { follow: true, write: true, create: true, truncate: kind == "write", ..Default::default() }).await?; let mut offset = 0; + let start = if kind == "append" { file.stat().await?.size } else { 0 }; while offset < bytes.len() { - let count = file.write(offset as u64, bytes[offset..].to_vec()).await?; + let count = file.write(start + offset as u64, bytes[offset..].to_vec()).await?; if count == 0 { return Err(std::io::ErrorKind::WriteZero.into()); } offset += count; } @@ -75,6 +76,7 @@ pub fn op_exec( #[serde] command: serde_json::Value, #[string] cwd: String, timeout: Option, + #[string] conversation: Option, ) -> Result { let exec = state.borrow::().exec.clone(); let command = if let Some(s) = command.as_str() { @@ -86,7 +88,7 @@ pub fn op_exec( }; let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); let task = tokio::spawn(async move { - let result = exec.exec(command, Options { cwd, timeout: timeout.map(std::time::Duration::from_secs_f64), ..Default::default() }, &mut |stream, bytes| { let _ = sender.send(json!({"stream": if stream == Stream::Stdout {"stdout"} else {"stderr"}, "bytes": bytes})); }).await; + let result = exec.exec(command, Options { cwd, conversation, timeout: timeout.map(std::time::Duration::from_secs_f64), ..Default::default() }, &mut |stream, bytes| { let _ = sender.send(json!({"stream": if stream == Stream::Stdout {"stdout"} else {"stderr"}, "bytes": bytes})); }).await; let _ = sender.send(match result { Ok(exit) => json!({"exitCode": exit}), Err(e) => json!({"error": e.to_string()}), diff --git a/host/src/harness/mod.rs b/host/src/harness/mod.rs index 324d880..b95d1cf 100644 --- a/host/src/harness/mod.rs +++ b/host/src/harness/mod.rs @@ -99,10 +99,10 @@ pub async fn run_with_exec(world: World, exec: Arc) -> anyhow::Result< .map_err(|error: anyhow::Error| { let mut text = error.to_string(); for key in ["PI_PROXY_BASE_URL", "PI_PROXY_API_KEY"] { - if let Ok(value) = std::env::var(key) { - if !value.is_empty() { - text = text.replace(&value, "[redacted]"); - } + if let Ok(value) = std::env::var(key) + && !value.is_empty() + { + text = text.replace(&value, "[redacted]"); } } anyhow::anyhow!(text) @@ -193,20 +193,22 @@ pub async fn run_module(world: World, exec: Arc, source: &str) -> anyh Ok(()) } -struct Unavailable; -#[async_trait::async_trait] -impl Exec for Unavailable { - async fn exec( - &self, - _: crate::exec::Command, - _: crate::exec::Options, - _: &mut (dyn for<'chunk> FnMut(crate::exec::Stream, &'chunk [u8]) + Send), - ) -> anyhow::Result { - anyhow::bail!("process execution is not wired yet") - } -} - /// Open a world and resume its durable root conversation. pub async fn run(path: &Path) -> anyhow::Result<()> { - run_with_exec(World::open(path)?, Arc::new(Unavailable)).await + run_with_userland(path, None).await +} + +/// Start the console with the shared process table and an optional userland bundle. +pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow::Result<()> { + let world = World::open_with_userland(path, userland)?; + let runtime = crate::process::Runtime::new(World { + root: world.root.clone(), + host_dir: world.host_dir.clone(), + processes: world.processes.clone(), + })?; + let services = crate::process::supervise(runtime.clone()); + let result = run_with_exec(world, Arc::new(runtime)).await; + services.abort(); + let _ = services.await; + result } diff --git a/host/src/main.rs b/host/src/main.rs index 5c15a42..44866a0 100644 --- a/host/src/main.rs +++ b/host/src/main.rs @@ -12,21 +12,61 @@ struct Cli { #[derive(Subcommand)] enum Command { /// Start a world: the harness, its services and a console. - Run { world: PathBuf }, + Run { + world: PathBuf, + #[arg(long)] + userland: Option, + }, /// Run a shell in a world. With `-c`, run one command and exit with its status. Sh { world: PathBuf, #[arg(short = 'c')] command: Option, + #[arg(long)] + userland: Option, }, } #[tokio::main] async fn main() -> anyhow::Result<()> { match Cli::parse().command { - Command::Run { world } => sausage::harness::run(&world).await, - Command::Sh { world, command } => { - let status = sausage::process::sh(&world, command).await?; + Command::Run { world, userland } => { + let bundle = + userland.or_else(|| std::env::var_os("SAUSAGE_USERLAND").map(PathBuf::from)); + sausage::harness::run_with_userland(&world, bundle.as_deref()).await + } + Command::Sh { + world, + command, + userland, + } => { + use sausage::exec::{Command, Exec, Options, Stream}; + let bundle = + userland.or_else(|| std::env::var_os("SAUSAGE_USERLAND").map(PathBuf::from)); + let world = sausage::world::World::open_with_userland(&world, bundle.as_deref())?; + let runtime = sausage::process::Runtime::new(world)?; + let script = + command.ok_or_else(|| anyhow::anyhow!("interactive shell is not implemented"))?; + let status = runtime + .exec( + Command::Shell(script), + Options { + cwd: "/".into(), + ..Default::default() + }, + &mut |stream, bytes| { + use std::io::Write; + match stream { + Stream::Stdout => { + let _ = std::io::stdout().write_all(bytes); + } + Stream::Stderr => { + let _ = std::io::stderr().write_all(bytes); + } + } + }, + ) + .await?; std::process::exit(status); } } diff --git a/host/tests/harness.rs b/host/tests/harness.rs index c22051c..2c68791 100644 --- a/host/tests/harness.rs +++ b/host/tests/harness.rs @@ -16,6 +16,7 @@ impl Exec for FakeExec { options: Options, output: &mut (dyn for<'chunk> FnMut(Stream, &'chunk [u8]) + Send), ) -> anyhow::Result { + assert!(options.conversation.is_some(), "missing conversation id"); let Command::Shell(command) = command else { anyhow::bail!("expected shell command") }; diff --git a/host/tests/p1.rs b/host/tests/p1.rs new file mode 100644 index 0000000..ade9671 --- /dev/null +++ b/host/tests/p1.rs @@ -0,0 +1,67 @@ +//! P1 integration: a read-only bundle and real shell calls from the durable harness. +use sausage::{harness::run_module, world::World}; +use sausage_vfs::Open; +use std::path::PathBuf; + +#[tokio::test(flavor = "current_thread")] +async fn bundle_mount_and_real_shell() -> anyhow::Result<()> { + let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".."); + let bundle = repo.join("target/userland/bin"); + if !bundle.join("sh").exists() { + assert!( + std::process::Command::new("nu") + .env_remove("CARGO_TARGET_DIR") + .arg(repo.join("userland/bundle.nu")) + .status()? + .success() + ); + } + let dir = tempfile::tempdir()?; + let world = World::open_with_userland(dir.path(), Some(&bundle))?; + assert!(world.root.stat("/bin/sh", true).await?.size > 0); + assert!(world.root.stat("/usr/local/bin", true).await?.directory); + let denied = world + .root + .open( + "/bin/forbidden", + Open { + write: true, + create: true, + ..Default::default() + }, + ) + .await; + assert!(matches!(denied, Err(e) if e.kind() == std::io::ErrorKind::ReadOnlyFilesystem)); + let exec = sausage::process::exec(World { + root: world.root.clone(), + host_dir: world.host_dir.clone(), + processes: world.processes.clone(), + })?; + let source = format!( + "globalThis.testP1 = true;\n{}", + include_str!("../js/test-bundle.js") + ); + run_module(world, exec, &source).await?; + assert_eq!( + std::fs::read_to_string(dir.path().join("world/test.txt"))?, + "after\n" + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("world/parallel/a"))?, + "a" + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("world/parallel/b"))?, + "b" + ); + let spills = std::fs::read_dir(dir.path().join("world/tmp"))?.collect::, _>>()?; + assert_eq!(spills.len(), 1); + let actual = std::fs::read_to_string(spills[0].path())?; + let expected: String = (1..=10000).map(|n| format!("{n}\n")).collect(); + assert_eq!(actual, expected); + // An unbundled world remains usable by process tests and file-only callers. + let unbundled = tempfile::tempdir()?; + let world = World::open(unbundled.path())?; + assert!(world.root.stat("/bin/sh", true).await.is_err()); + Ok(()) +} diff --git a/userland/bundle.nu b/userland/bundle.nu new file mode 100644 index 0000000..fa8e28b --- /dev/null +++ b/userland/bundle.nu @@ -0,0 +1,36 @@ +#!/usr/bin/env nu +# Build every workspace CLI and assemble the read-only /bin bundle. +def main [destination?: path] { + let here = $env.FILE_PWD + let destination = ($destination | default ($here | path join .. target userland bin) | path expand) + let object = ($here | path join cwd-init init.o) + let sources = [($here | path join cwd-init init.rs) ($here | path join cwd-init exit.wit) ($here | path join cwd-init build.nu)] + if not ($object | path exists) or ($sources | any {|source| (ls $source | get modified.0) > (ls $object | get modified.0) }) { + ^nu ($here | path join cwd-init build.nu) + if $env.LAST_EXIT_CODE != 0 { error make {msg: 'cwd-init build failed'} } + } + let metadata = (do { cd $here; ^cargo metadata --no-deps --format-version 1 } | from json) + do { cd $here; ^cargo build --workspace --release --locked } + if $env.LAST_EXIT_CODE != 0 { error make {msg: 'userland build failed'} } + ^nu ($here | path join brush build.nu) + if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush build failed'} } + mkdir $destination + for package in ($metadata.packages | where {|p| $p.id in $metadata.workspace_members }) { + let tools = ($package.manifest_path | path dirname | path join tools.txt) + for binary in ($package.targets | where {|t| 'bin' in $t.kind }) { + let source = ($metadata.target_directory | path join wasm32-wasip2 release $'($binary.name).wasm') + let names = if ($tools | path exists) { open --raw $tools | lines | where {|s| ($s | str trim) != '' } } else { [$binary.name] } + for name in $names { + ^ln -f $source ($destination | path join $name) + if $env.LAST_EXIT_CODE != 0 { error make {msg: $'bundle link failed: ($name)'} } + } + } + } + let brush_metadata = (^cargo metadata --manifest-path ($here | path join brush source Cargo.toml) --no-deps --format-version 1 | from json) + let brush = ($brush_metadata.target_directory | path join wasm32-wasip2 release brush.wasm) + for name in [sh bash] { + ^ln -f $brush ($destination | path join $name) + if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush link failed'} } + } + print $destination +}