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:
+10
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
};
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user