host: sausage run with the userland bundle, real shell and services (T13)

- userland/bundle.nu builds the userland and assembles target/userland/bin
  (bin names, tools.txt aliases, brush as sh and bash).
- run and sh take --userland (default SAUSAGE_USERLAND); /bin is read-only.
- The console's bash tool runs through the process table with the
  conversation id; large output spills to the world's /tmp.
- sausage run starts the services supervisor.

P1 exit session (openai/gpt-6.1-sol, fresh world): write, read, edit, git
init/add/commit/diff/log, grep -rn, rg, find and a sed|sort|uniq|wc pipeline.
This commit is contained in:
2026-11-06 17:34:11 +00:00
parent b9e3e3677d
commit ec6e20a9d7
12 changed files with 305 additions and 57 deletions
+10 -2
View File
@@ -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-<uuid>.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 <world> --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
+27 -8
View File
@@ -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);
+29 -7
View File
@@ -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 () => {},
};
+1 -1
View File
@@ -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);
+45 -10
View File
@@ -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);
+19 -3
View File
@@ -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);
+6 -4
View File
@@ -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<f64>,
#[string] conversation: Option<String>,
) -> Result<u32, JsErrorBox> {
let exec = state.borrow::<Environment>().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()}),
+20 -18
View File
@@ -99,10 +99,10 @@ pub async fn run_with_exec(world: World, exec: Arc<dyn Exec>) -> 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<dyn Exec>, 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<i32> {
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
}
+44 -4
View File
@@ -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<PathBuf>,
},
/// Run a shell in a world. With `-c`, run one command and exit with its status.
Sh {
world: PathBuf,
#[arg(short = 'c')]
command: Option<String>,
#[arg(long)]
userland: Option<PathBuf>,
},
}
#[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);
}
}
+1
View File
@@ -16,6 +16,7 @@ impl Exec for FakeExec {
options: Options,
output: &mut (dyn for<'chunk> FnMut(Stream, &'chunk [u8]) + Send),
) -> anyhow::Result<i32> {
assert!(options.conversation.is_some(), "missing conversation id");
let Command::Shell(command) = command else {
anyhow::bail!("expected shell command")
};
+67
View File
@@ -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::<Result<Vec<_>, _>>()?;
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(())
}
+36
View File
@@ -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
}