spikes: six v3 spikes (V8 harness, WASI shell, git, builder, http hook, esbuild)

Throwaway prototypes for Sausage v3. Plan and decisions:
~/research/sausage-v3/PLAN.md. Results and evidence: the spike READMEs.
This commit is contained in:
2026-11-06 14:03:20 +00:00
parent ee88adf6c6
commit 9e9cde5241
66 changed files with 15748 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
import { openStorage } from "./sqlite.ts";
// A foreground subagent tool: the parent's tool call creates a child conversation it owns, runs one task there, and
// returns the child's answer. Aborting the tool call aborts the child. A UI finds the child through the tool's running
// details and shows its view under the call.
// Uses OpenAI when OPENAI_API_KEY is set, and a scripted faux model otherwise.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/21-subagent-foreground.ts
import type { Context } from "@earendil-works/chord/context";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { AssistantMessage, FauxResponseStep } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { obj, str } from "@earendil-works/pi-schema";
import {
AssistantEntry,
type ConversationId,
configure,
createRegistry,
defineExtension,
defineTool,
type EntryId,
type EntryRecord,
type Extension,
Harness,
type LiveState,
type ToolExecutionApi,
} from "@earendil-works/pi-durable";
const context = BACKGROUND_CONTEXT;
// ─── Product code: the subagent extension ───────────────────────────────────
async function answerText(api: ToolExecutionApi, answer: EntryId, callContext: Context): Promise<string> {
const entry = await api.commit((tx) => tx.entry(AssistantEntry, answer), callContext);
const message = entry?.model?.[0] as AssistantMessage;
return message.content.flatMap((c) => (c.type === "text" ? [c.text] : [])).join("");
}
const Subagent: Extension = defineExtension({
name: "subagent",
tools: [
defineTool({
name: "subagent",
description: "Delegate a self-contained task to a subagent and get its answer back.",
parameters: obj({ task: str({ description: "What the subagent should do" }) }),
// Safe to rerun after a crash: a rerun finds the child it already created and the submission it already made.
replay: "safe",
execute: async (args, api, callContext) => {
const { task } = args;
// The child is owned by this tool call's task, so aborting the call aborts the child, and the call
// finishes only once the child's work is done.
const child = await api.commit(async (tx) => {
// Ownership records the child: a rerun of this call finds it instead of creating another.
const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
if (existing !== undefined) return existing.id;
// Starts as a copy of this conversation's agent: model, thinking level, cwd, extensions, tools.
const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
// Without this extension, the child is not offered this tool.
await configure(tx, created.id, { extensions: { remove: [Subagent] } });
return created.id;
}, callContext);
// A UI watching the parent sees this and can attach to the child.
await api.details({ conversationId: child }, callContext);
const handle = (await api.conversation(child, callContext))!;
// The request ID makes a rerun get back the submission it made before the crash.
const request = { type: "input", content: task, requestId: `subagent:${api.taskId}` } as const;
const settled = await (await handle.submit(request, callContext)).wait(callContext);
if (settled.status !== "done" || settled.type !== "input") {
throw new Error(`Subagent failed: ${settled.status}`);
}
const text = await answerText(api, settled.answer, callContext);
return { output: [{ type: "text", text }], details: { conversationId: child } };
},
}),
],
});
// ─── Host setup ─────────────────────────────────────────────────────────────
const models = createModels();
let fauxRun: ReturnType<typeof fauxProvider>;
let childStarted = false;
let model = { provider: "openai", modelId: "gpt-6-sol" };
if (process.env.OPENAI_API_KEY !== undefined) {
models.setProvider(openaiProvider());
} else {
// The parent delegates, the child answers, and the parent reports.
const faux = fauxProvider({ tokensPerSecond: 100, tokenSize: { min: 1, max: 1 } });
fauxRun = faux;
models.setProvider(faux.provider);
model = { provider: "faux", modelId: "faux-1" };
const delegate = fauxToolCall("subagent", { task: "Name three prime numbers." }, { id: "call-1" });
faux.setResponses([
fauxAssistantMessage([delegate], { stopReason: "toolUse" }),
() => {
childStarted = true;
return fauxAssistantMessage([fauxText("2, 3, and 5.")]);
},
fauxAssistantMessage([fauxText("The subagent says: 2, 3, and 5.")]),
] satisfies FauxResponseStep[]);
}
const registry = createRegistry();
registry.install(Subagent);
let harness = await Harness.open(await openStorage("/tmp/s1-v8-harness/21.sqlite"), { models, registry }, context);
let root = await harness.root(context, { agent: { model } });
const requestId = crypto.randomUUID();
// ─── UI: the parent's transcript, with each subagent's indented under its call ───
/** Print an assistant entry's text and tool calls. */
const print = (indent: string, entry: EntryRecord): void => {
const message = entry.model?.[0];
if (entry.kind !== "pi.assistant" || message?.role !== "assistant") return;
for (const block of (message as AssistantMessage).content) {
if (block.type === "text" && block.text !== "") console.log(`${indent}assistant: ${block.text}`);
if (block.type === "toolCall") console.log(`${indent}tool ${block.name}(${JSON.stringify(block.arguments)})`);
}
};
const attached = new Set<ConversationId>();
const attach = async (id: ConversationId, indent: string): Promise<void> => {
attached.add(id);
const watch = await (await harness.conversation(id, context))!.watch(context);
let newest = watch.value.entries.at(-1)?.id ?? 0;
watch.start(async (call) => {
if (call.kind !== "value") return;
const { value } = call;
for (const entry of value.entries) if (entry.id > newest) print(indent, entry);
newest = value.entries.at(-1)?.id ?? newest;
// A running subagent call names its child conversation in its details.
for (const slot of (value.docs["pi.live"] as LiveState).tools ?? []) {
const child = (slot.details as { conversationId?: ConversationId } | undefined)?.conversationId;
if (child !== undefined && !attached.has(child)) await attach(child, `${indent} `);
}
});
};
await attach(root.id, "");
await root.submit(
{ type: "input", content: "Use the subagent tool to find three prime numbers, then tell me what it said.", requestId },
context,
);
harness.resume();
const deadline = Date.now() + 10000;
while (!childStarted) {
if (Date.now() > deadline) throw new Error('Child did not start');
await new Promise(resolve => setTimeout(resolve, 1));
}
console.log('Stop with a live subagent, then reopen SQLite');
await harness.close(context);
fauxRun!.setResponses([
fauxAssistantMessage([fauxText('2, 3, and 5.')]),
fauxAssistantMessage([fauxText('The subagent says: 2, 3, and 5.')]),
]);
harness = await Harness.open(await openStorage('/tmp/s1-v8-harness/21.sqlite'), { models, registry }, context);
root = await harness.root(context);
harness.resume();
const resumed = await root.submit({ type: 'input', content: 'Use the subagent tool to find three prime numbers, then tell me what it said.', requestId }, context);
const result = await resumed.wait(context);
if (result.status !== 'done') throw new Error(`Resume failed: ${result.status}`);
const view = await root.watch(context);
const answer = view.value.entries.filter(entry => entry.kind === 'pi.assistant').at(-1);
if (JSON.stringify(answer?.model).includes('The subagent says: 2, 3, and 5.') === false) throw new Error('Wrong resumed answer');
print('', answer!);
await view.stop();
console.log('21 resumed: done');
await harness.waitForIdle(context);
// Watch callbacks run after their commit; let the last ones print.
await new Promise((resolve) => setTimeout(resolve, 0));
await harness.close(context);
+156
View File
@@ -0,0 +1,156 @@
// A task that owns child tasks: a checkout charges four payments at once and waits for them.
// The task graph view prints the checkout's tree while its payments run.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/23-child-tasks.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxProvider } from "@earendil-works/pi-ai/providers/faux";
import {
type Conversation,
createRegistry,
defineExtension,
defineTask,
Harness,
type TaskGraph,
type TaskGraphNode,
type TaskId,
} from "@earendil-works/pi-durable";
import { openStorage as openNodeSqliteStorage } from "./sqlite.ts";
const context = BACKGROUND_CONTEXT;
// A fake bank: it declines an expired card at once, and confirms other charges after a moment.
const charged = new Set<string>();
type PaymentInput = { card: string };
type PaymentResult = { card: string };
type PaymentState = { phase: "charge" } | { phase: "confirm" };
const Payment = defineTask<PaymentInput, PaymentState, PaymentResult>({
name: "example.payment",
version: 1,
initial: () => ({ phase: "charge" }),
phases: {
charge: async (task, runtime, taskContext) => {
const card = task.input.card;
if (card.startsWith("expired")) {
const error = { message: `${card} declined` };
await runtime.commit(() => ({ status: "terminal", outcome: { status: "failed", error } }), taskContext);
return;
}
charged.add(card);
// The bank confirms after a moment: wait for it without holding an invocation.
await runtime.sleepUntil(runtime.now() + 100, { phase: "confirm" }, taskContext);
},
confirm: async (task, runtime, taskContext) => {
const card = task.input.card;
await runtime.commit(
() => ({ status: "terminal", outcome: { status: "completed", result: { card } } }),
taskContext,
);
},
},
// Each payment undoes its own effect when aborted.
abort: async (task, runtime, taskContext) => {
const refunded = charged.delete(task.input.card);
console.log(` payment ${task.input.card} aborted${refunded ? ", refunded" : ""}`);
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);
},
});
// The checkout creates its payments as child tasks and waits for all of them.
// With `failFast`, the first payment that fails aborts the others; the checkout
// itself is not aborted and decides its outcome once every payment is done.
type CheckoutState = { phase: "pay" } | { phase: "decide"; payments: TaskId<PaymentResult>[] };
const Checkout = defineTask<{ cards: string[] }, CheckoutState, string>({
name: "example.checkout",
version: 1,
initial: () => ({ phase: "pay" }),
phases: {
pay: async (task, runtime, taskContext) => {
await runtime.commit(async (tx) => {
const payments: TaskId<PaymentResult>[] = [];
for (const card of task.input.cards) {
payments.push(await tx.createTask(Payment, { card }, { ownership: { kind: "task", taskId: task.id } }));
}
return { status: "waiting", checkpoint: { phase: "decide", payments }, on: payments, policy: "failFast" };
}, taskContext);
},
decide: async (task, runtime, taskContext) => {
const outcomes = await runtime.outcomes(task.state.checkpoint.payments, taskContext);
console.log(` payments: ${outcomes.map((outcome) => outcome.status).join(", ")}`);
const paid = outcomes.every((outcome) => outcome.status === "completed");
await runtime.commit(
() =>
paid
? { status: "terminal", outcome: { status: "completed", result: "order placed" } }
: { status: "terminal", outcome: { status: "failed", error: { message: "payment failed" } } },
taskContext,
);
},
},
// Runs only after every payment is done, so the refunds have already happened.
abort: async (_task, runtime, taskContext) => {
console.log(" checkout aborted");
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);
},
});
const registry = createRegistry();
registry.install(defineExtension({ name: "checkout", tasks: [Payment, Checkout] }));
const databasePath = "/tmp/s1-v8-harness/23.sqlite";
const models = createModels();
models.setProvider(fauxProvider().provider);
const open = async () =>
Harness.open(await openNodeSqliteStorage(databasePath), { models, registry }, context);
/** Print the live tasks as a tree along their owner edges. */
function printGraph(graph: TaskGraph): void {
const nodes = Object.values(graph.tasks);
const print = (node: TaskGraphNode, depth: number): void => {
const state = node.state;
const status = state.status === "waiting" ? `waiting on ${state.on.join(", ")}` : state.status;
console.log(` ${" ".repeat(depth)}${node.kind} ${node.id}: ${status}`);
for (const child of nodes.filter((candidate) => candidate.owner === node.id)) print(child, depth + 1);
};
for (const node of nodes.filter((candidate) => candidate.owner === undefined)) print(node, 0);
}
const checkout = (root: Conversation, cards: string[]) =>
root.commit((tx) => tx.createTask(Checkout, { cards }, { ownership: { kind: "conversation" } }), context);
let harness = await open();
let root = await harness.root(context);
console.log("One card is declined:");
let id = await checkout(root, ["visa-1", "expired-2", "visa-3", "visa-4"]);
console.log(` checkout: ${(await harness.waitForTask(id, context)).state.outcome.status}`);
console.log("The customer cancels:");
id = await checkout(root, ["visa-5", "visa-6", "visa-7", "visa-8"]);
harness.resume();
await new Promise((resolve) => setTimeout(resolve, 20));
const graph = await harness.watchTaskGraph(context);
printGraph(graph.value);
await graph.stop();
await harness.abortTask(id, context);
console.log(` checkout: ${(await harness.waitForTask(id, context)).state.outcome.status}`);
console.log("The process stops while the payments run, and a new one continues:");
id = await checkout(root, ["visa-9", "visa-10", "visa-11", "visa-12"]);
await new Promise((resolve) => setTimeout(resolve, 20));
const beforeStop = await harness.watchTaskGraph(context);
if (beforeStop.value.tasks[id].state.status === 'terminal') throw new Error('Checkout finished before the stop');
printGraph(beforeStop.value);
await beforeStop.stop();
await harness.close(context);
harness = await open();
root = await harness.root(context);
harness.resume();
const resumed = (await harness.waitForTask(id, context)).state.outcome;
if (resumed.status !== 'completed') throw new Error(`Resume failed: ${resumed.status}`);
console.log(` checkout: ${resumed.status}`);
await harness.close(context);
+5948
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "s1-v8-harness"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
deno_core = "=0.413.0"
deno_web = "=0.291.0"
deno_webidl = "=0.260.0"
deno_crypto = "=0.274.0"
deno_fetch = "=0.284.0"
deno_net = "=0.252.0"
deno_telemetry = "=0.82.0"
deno_ast = { version = "=0.53.3", features = ["transpiling"] }
deno_permissions = "=0.119.0"
deno_error = "=0.7.1"
rusqlite = { version = "0.38", features = ["bundled"] }
serde_json = "1"
sys_traits = { version = "0.1", features = ["real", "libc"] }
tokio = { version = "1", features = ["full"] }
+29
View File
@@ -0,0 +1,29 @@
# S1: V8 harness
This spike runs pi-durable examples 21 and 23 in Rust through deno_core.
Rust owns the SQLite file. The portable adapter queues transactions and calls rusqlite through a Rust op.
The JavaScript uses Deno's Web API crates, not Node.
The sources adapt the upstream examples at:
https://github.com/earendil-works/pi/tree/main/packages/durable/test/examples
Install and bundle:
```sh
npm install --prefix /tmp/s1-v8-harness @earendil-works/pi-durable@1.5.0 @earendil-works/pi-ai@1.5.0 @earendil-works/chord@1.5.0 esbuild@0.28.2
NODE_PATH=/tmp/s1-v8-harness/node_modules /tmp/s1-v8-harness/node_modules/.bin/esbuild spikes/s1-v8-harness/21.ts spikes/s1-v8-harness/23.ts spikes/s1-v8-harness/real.ts --bundle --format=esm --platform=browser --outdir=/tmp/s1-v8-harness/bundles
nix develop -c cargo build --manifest-path spikes/s1-v8-harness/Cargo.toml -j 2
```
Run all three cases with `nu spikes/s1-v8-harness/verify.nu`.
The real call requires `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY`.
The examples retain their SQLite files in `/tmp/s1-v8-harness/`.
Example 21 uses a new request ID for each run and preserves it across reopen.
The host loads bundles as ES modules, so `import.meta` and top-level await work.
Deno fetch also requires the net and telemetry extensions.
The host uses deno_ast to transpile Deno's internal telemetry TypeScript.
The host does not enable telemetry export.
Both restart examples and the real proxy call passed.
The report at `.board/T1.report.md` records the commands, measurements, and remaining limits.
+27
View File
@@ -0,0 +1,27 @@
// Install Deno's native Web APIs. No Node runtime is present.
const core = Deno.core;
const expose = (path, names) => {
const exports = core.loadExtScript(path);
for (const name of names) globalThis[name] = exports[name];
};
expose('ext:deno_web/01_dom_exception.js', ['DOMException']);
expose('ext:deno_web/02_event.js', ['Event', 'EventTarget', 'MessageEvent']);
expose('ext:deno_web/13_message_port.js', ['MessageChannel', 'MessagePort']);
expose('ext:deno_web/00_url.js', ['URL', 'URLSearchParams']);
expose('ext:deno_web/02_timers.js', ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval']);
expose('ext:deno_web/03_abort_signal.js', ['AbortController', 'AbortSignal']);
expose('ext:deno_web/06_streams.js', ['ReadableStream', 'WritableStream', 'TransformStream']);
expose('ext:deno_web/08_text_encoding.js', ['TextEncoder', 'TextDecoder']);
expose('ext:deno_web/09_file.js', ['Blob', 'File']);
expose('ext:deno_web/15_performance.js', ['performance']);
expose('ext:deno_web/02_structured_clone.js', ['structuredClone']);
expose('ext:deno_web/05_base64.js', ['atob', 'btoa']);
expose('ext:deno_crypto/00_crypto.js', ['crypto']);
expose('ext:deno_fetch/20_headers.js', ['Headers']);
expose('ext:deno_fetch/21_formdata.js', ['FormData']);
expose('ext:deno_fetch/23_request.js', ['Request']);
expose('ext:deno_fetch/23_response.js', ['Response']);
expose('ext:deno_fetch/26_fetch.js', ['fetch']);
const { Console } = core.loadExtScript('ext:deno_web/01_console.js');
globalThis.console = new Console((message, level) => core.print(message, level > 1));
globalThis.process = { env: {} };
+24
View File
@@ -0,0 +1,24 @@
// Make one streamed call through Surma's proxy with pi-ai's OpenAI provider.
import { createModels } from '@earendil-works/pi-ai/models';
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
const models = createModels();
models.setProvider(openaiProvider());
const model = {
id: 'gpt-6.1-sol', name: 'gpt-6.1-sol', provider: 'openai', api: 'openai-responses',
baseUrl: `${process.env.PI_PROXY_BASE_URL}/v1`, reasoning: true, input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 1024,
};
const answer = await models.completeSimple(model as any, {
messages: [{ role: 'user', content: 'Reply with one short sentence about prime numbers.', timestamp: Date.now() }],
}, { apiKey: process.env.PI_PROXY_API_KEY, maxTokens: 1024, reasoning: 'low', headers: { 'Shopify-Usage-Tag': JSON.stringify(['pi', 's1-v8-harness']) } });
if (answer.stopReason === 'error') {
let message = answer.errorMessage ?? 'Real model call failed';
for (const key of ['PI_PROXY_BASE_URL', 'PI_PROXY_API_KEY']) {
const value = process.env[key];
if (value) message = message.split(value).join('[redacted]');
}
throw new Error(message);
}
const text = answer.content.filter(c => c.type === 'text').map(c => c.text).join('');
if (!text) throw new Error('Real model returned no answer text');
console.log(text);
+33
View File
@@ -0,0 +1,33 @@
// Portable SQLite facade. Rust owns the connection and executes every statement.
import { SqliteStorage } from '@earendil-works/pi-durable/storage/sqlite';
const op = (kind: string, sql = '', params: any[] = []) => Deno.core.ops.op_sql(kind, sql, params.map(p => p instanceof Uint8Array ? Array.from(p) : typeof p === 'bigint' ? Number(p) : p));
export async function openStorage(path: string) {
op('open', path);
let tail = Promise.resolve();
const queue = <T>(call: () => Promise<T>): Promise<T> => {
const result = tail.then(call);
tail = result.then(() => {}, () => {});
return result;
};
const executor = {
exec: async (sql: string) => { op('exec', sql); },
run: async (sql: string, ...params: any[]) => { op('run', sql, params); },
runCounted: async (sql: string, ...params: any[]) => op('run', sql, params).changes,
get: async (sql: string, ...params: any[]) => (await executor.all(sql, ...params))[0],
all: async (sql: string, ...params: any[]) => op('all', sql, params).map((row: any) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, Array.isArray(value) ? new Uint8Array(value) : value]))),
};
const database = Object.fromEntries(Object.entries(executor).map(([name, method]) => [name, (...args: any[]) => queue(() => method(...args))]));
database.transaction = (callback: any) => queue(async () => {
op('exec', 'BEGIN IMMEDIATE');
try {
const result = await callback(executor);
op('exec', 'COMMIT');
return result;
} catch (error) {
op('exec', 'ROLLBACK');
throw error;
}
});
database.close = () => queue(async () => { op('close'); });
return SqliteStorage.open(database as any);
}
+145
View File
@@ -0,0 +1,145 @@
//! Runs bundled pi examples in V8 with a rusqlite connection owned by Rust.
use deno_core::{JsRuntime, OpState, RuntimeOptions, op2};
use deno_error::JsErrorBox;
use rusqlite::{Connection, types::Value as SqlValue};
use serde_json::{Value, json};
use std::sync::Arc;
#[op2]
#[serde]
fn op_sql(
state: &mut OpState,
#[string] kind: String,
#[string] sql: String,
#[serde] params: Vec<serde_json::Value>,
) -> Result<serde_json::Value, JsErrorBox> {
let work = || -> Result<Value, Box<dyn std::error::Error>> {
if kind == "open" {
state.put(Connection::open(sql)?);
return Ok(Value::Null);
}
if kind == "close" {
state.take::<Connection>();
return Ok(Value::Null);
}
let db = state.borrow::<Connection>();
if kind == "exec" {
db.execute_batch(&sql)?;
return Ok(Value::Null);
}
let params: Vec<SqlValue> = params
.into_iter()
.map(|v| match v {
Value::Null => SqlValue::Null,
Value::Number(n) => n
.as_i64()
.map(SqlValue::Integer)
.unwrap_or_else(|| SqlValue::Real(n.as_f64().unwrap())),
Value::String(s) => SqlValue::Text(s),
Value::Array(a) => {
SqlValue::Blob(a.into_iter().map(|n| n.as_u64().unwrap() as u8).collect())
}
_ => panic!("unsupported SQL parameter"),
})
.collect();
let mut stmt = db.prepare_cached(&sql)?;
if kind == "run" {
let changes = stmt.execute(rusqlite::params_from_iter(params))?;
return Ok(json!({"changes": changes, "lastInsertRowid": db.last_insert_rowid()}));
}
let names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
let mut rows = stmt.query(rusqlite::params_from_iter(params))?;
let mut result = vec![];
while let Some(row) = rows.next()? {
let mut obj = serde_json::Map::new();
for (i, name) in names.iter().enumerate() {
let v = match row.get::<_, SqlValue>(i)? {
SqlValue::Null => Value::Null,
SqlValue::Integer(n) => json!(n),
SqlValue::Real(n) => json!(n),
SqlValue::Text(s) => json!(s),
SqlValue::Blob(b) => json!(b),
};
obj.insert(name.clone(), v);
}
result.push(Value::Object(obj));
}
Ok(json!(result))
};
work().map_err(|e| JsErrorBox::generic(e.to_string()))
}
deno_core::extension!(storage, ops = [op_sql]);
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
deno_telemetry::deno_telemetry::init(),
deno_webidl::deno_webidl::init(),
deno_web::deno_web::init(
Arc::new(deno_web::BlobStore::default()),
None,
false,
Default::default(),
),
deno_crypto::deno_crypto::init(None),
deno_net::deno_net::init(None, None),
deno_fetch::deno_fetch::init(Default::default()),
storage::init(),
],
extension_transpiler: Some(std::rc::Rc::new(|name, source| {
if !name.ends_with(".ts") {
return Ok((source, None));
}
let parsed = deno_ast::parse_module(deno_ast::ParseParams {
specifier: deno_core::url::Url::parse(&name).unwrap(),
text: source.into(),
media_type: deno_ast::MediaType::TypeScript,
capture_tokens: false,
scope_analysis: false,
maybe_syntax: None,
})
.map_err(|e| JsErrorBox::generic(e.to_string()))?;
let emitted = parsed
.transpile(
&Default::default(),
&Default::default(),
&deno_ast::EmitOptions {
source_map: deno_ast::SourceMapOption::None,
..Default::default()
},
)
.map_err(|e| JsErrorBox::generic(e.to_string()))?
.into_source();
Ok((emitted.text.into(), None))
})),
..Default::default()
});
runtime
.op_state()
.borrow_mut()
.put(deno_permissions::PermissionsContainer::allow_all(Arc::new(
deno_permissions::RuntimePermissionDescriptorParser::new(sys_traits::impls::RealSys),
)));
runtime.execute_script("bootstrap.js", include_str!("../bootstrap.js"))?;
let env: serde_json::Map<String, Value> = ["PI_PROXY_BASE_URL", "PI_PROXY_API_KEY"]
.into_iter()
.filter_map(|key| std::env::var(key).ok().map(|v| (key.to_owned(), json!(v))))
.collect();
runtime.execute_script("env.js", format!("process.env = {};", Value::Object(env)))?;
let input = std::env::args()
.nth(1)
.ok_or("usage: s1-v8-harness BUNDLE.js")?;
let input = std::fs::canonicalize(input)?;
let specifier = deno_core::ModuleSpecifier::from_file_path(&input).unwrap();
let module = runtime
.load_main_es_module_from_code(&specifier, std::fs::read_to_string(input)?)
.await?;
let result = runtime.mod_evaluate(module);
runtime
.with_event_loop_promise(result, Default::default())
.await?;
runtime.run_event_loop(Default::default()).await?;
Ok(())
}
+2
View File
@@ -0,0 +1,2 @@
// Cold-start probe: the host installed all Web APIs before this script executes.
console.log('V8 ready');
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env nu
# Build the three bundles and execute them with the same Rust binary.
$env.NODE_PATH = '/tmp/s1-v8-harness/node_modules'
^/tmp/s1-v8-harness/node_modules/.bin/esbuild spikes/s1-v8-harness/21.ts spikes/s1-v8-harness/23.ts spikes/s1-v8-harness/real.ts --bundle --format=esm --platform=browser --outdir=/tmp/s1-v8-harness/bundles
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'Bundle failed'} }
^nix develop -c cargo build --manifest-path spikes/s1-v8-harness/Cargo.toml -j 2
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'Build failed'} }
for example in ['21' '23' 'real'] {
print $'Example ($example):'
^spikes/s1-v8-harness/target/debug/s1-v8-harness $'/tmp/s1-v8-harness/bundles/($example).js'
if $env.LAST_EXIT_CODE != 0 { error make {msg: $'Example ($example) failed'} }
}
+2
View File
@@ -0,0 +1,2 @@
/target/
/userland/target/
+2472
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "s2-shell"
version = "0.1.0"
edition = "2024"
[workspace]
[profile.dev.package."*"]
opt-level = 2
[dependencies]
anyhow = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sausage-vfs = { path = "../../vfs" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "io-util"] }
wasmtime = "49.0.2"
wasmtime-wasi = "49.0.2"
+71
View File
@@ -0,0 +1,71 @@
# S2 shell experiment
The target is a brush shell inside WASI with external uutils commands in separate Wasmtime stores.
All instances share one world root through `sausage-vfs`.
The process import must preserve streaming, EOF, broken pipes, and exit codes.
## Reproduction
The scratch checkout must use brush revision `943553433ae04f5b3bd55d2cf207e39eb9ef2af7`.
The toolchain comes from the repository devshell.
```sh
git clone --depth 1 https://github.com/reubeno/brush /tmp/s2-shell/brush
git -C /tmp/s2-shell/brush fetch --depth 1 origin 943553433ae04f5b3bd55d2cf207e39eb9ef2af7
git -C /tmp/s2-shell/brush checkout --detach 943553433ae04f5b3bd55d2cf207e39eb9ef2af7
nu --no-config-file /home/surma/src/gitea.surma.technology/surma/sausage/spikes/s2-shell/patch-brush.nu
nix develop -c cargo build --manifest-path /tmp/s2-shell/brush/Cargo.toml -p brush-shell --release --no-default-features --features minimal --target wasm32-wasip2 -j 2
nix develop -c wasm-tools strip /tmp/s2-shell/brush/target/wasm32-wasip2/release/brush.wasm -o /tmp/s2-shell/brush-release.wasm
nix develop -c cargo build --manifest-path spikes/s2-shell/userland/Cargo.toml --release --target wasm32-wasip2 -j 2
nix develop -c cargo build --manifest-path spikes/s2-shell/Cargo.toml -j 2
nu --no-config-file /home/surma/src/gitea.surma.technology/surma/sausage/spikes/s2-shell/compare.nu /home/surma/src/gitea.surma.technology/surma/sausage/spikes/s2-shell/target/debug/s2-shell /tmp/s2-shell/brush-release.wasm
# Run the same comparison again after the cache is warm.
nu --no-config-file /home/surma/src/gitea.surma.technology/surma/sausage/spikes/s2-shell/compare.nu /home/surma/src/gitea.surma.technology/surma/sausage/spikes/s2-shell/target/debug/s2-shell /tmp/s2-shell/brush-release.wasm
nu --no-config-file /home/surma/src/gitea.surma.technology/surma/sausage/spikes/s2-shell/timings.nu /home/surma/src/gitea.surma.technology/surma/sausage/spikes/s2-shell/target/debug/s2-shell /tmp/s2-shell/brush-release.wasm
```
`userland/` builds the six commands from the published uutils 0.12.0 crates.
Its entry point calls libc's `chdir` with the host-supplied `S2_CWD` before it dispatches the command.
The packaged uutils component does not import `initial-cwd` and otherwise starts every command at `/`.
`compare.nu` copies the rebuilt multicall component into each bin alias in the test worlds.
The host checks the bin alias and reuses one compiled uutils component for these identical files.
It does not implement arbitrary executable resolution.
`corpus.sh` covers the requested syntax and traversal into another top-level folder.
`compare.nu` creates separate native and guest fixtures under `/tmp/s2-shell/comparison`.
It compares stdout and exit codes, and preserves stderr separately.
It removes the native fixture prefix from stdout because the guest sees the fixture as `/`.
## Bridge
`patch-brush.nu` copies `guest-process.rs` into the wasm launcher seam of the scratch checkout.
It adds the WIT import and its dependencies to that checkout only.
The guest launcher transfers bounded chunks between brush's open files and host-backed pipes.
It starts an independent Tokio task for each child's IO pump.
Brush waits for child processes sequentially, so a pump cannot run only inside the child's wait future.
Each pump yields between exchanges so the downstream pipeline stage can execute.
It closes the host output pipe when brush reports a broken pipe.
The host starts each child component in a separate store.
Unix socket pairs provide bounded, streamed stdin, stdout, and stderr.
The host uses the default Cranelift optimization level.
The dev profile optimizes dependencies at level 2, so Cranelift itself does not run unoptimized.
Wasmtime stores compiled modules in `/tmp/s2-shell/cache` and reuses them across host invocations.
The first comparison warms this cache. Cold compilation can take minutes.
`timings.nu` compares and times each corpus construct after the cache is warm.
Every child receives the same VFS root, its environment, and its initial working directory.
The guest process exchanges do not buffer a whole command's output.
Brush's existing in-memory pipes still buffer between cooperative shell tasks.
## Scope
The experiment supports only the acceptance corpus and its six uutils aliases.
It does not implement exec, signals, process groups, or interactive stdin inheritance.
It treats the shell's original stdin as EOF for external commands.
It does forward redirected stdin and pipeline stdin.
The fixture uses an ordinary file at `/dev/null`, not a device implementation.
The process import is spike-specific, not a proposed stable ABI.
AgentOS supplies the prior art, not code in this implementation.
Its standard library patches target `host_process` imports in its V8 runtime.
This experiment does not patch Rust's standard library.
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env nu
# Compare one shell script with bash. Keep both streams and exact exit codes.
def main [host: path, shell: path] {
let base = '/tmp/s2-shell/comparison'
mkdir $base
for name in [native guest] {
let root = ($base | path join $name)
mkdir ($root | path join work sub) ($root | path join other) ($root | path join bin) ($root | path join dev)
"alpha\n" | save -f ($root | path join work f)
"one\ntwo\n" | save -f ($root | path join work file)
'' | save -f ($root | path join work a.txt)
'' | save -f ($root | path join work b.txt)
"before\n" | save -f ($root | path join work log)
'' | save -f ($root | path join dev 'null')
for tool in [cat tr ls wc yes head] {
cp --force ($env.FILE_PWD | path join userland target wasm32-wasip2 release s2-userland.wasm) ($root | path join bin $tool)
}
}
let script = (open --raw ($env.FILE_PWD | path join corpus.sh))
let native_root = ($base | path join native)
let native = (do { cd $native_root; ^bash --noprofile --norc -c $script } | complete)
let stdout = ($native.stdout | str replace --all $native_root '')
$stdout | save -f ($base | path join native.stdout)
$native.stderr | save -f ($base | path join native.stderr)
print $'native exit=($native.exit_code)'
let start = (date now)
let guest = (^$host ($base | path join guest) $shell -c $script | complete)
print $'guest elapsed=((date now) - $start)'
$guest.stdout | save -f ($base | path join guest.stdout)
$guest.stderr | save -f ($base | path join guest.stderr)
print $'guest exit=($guest.exit_code)'
print $'equal stdout=($stdout == $guest.stdout)'
print '--- native stdout ---'
print $stdout
print '--- guest stdout ---'
print $guest.stdout
print '--- guest stderr ---'
print $guest.stderr
if $native.exit_code != $guest.exit_code or $stdout != $guest.stdout {
error make {msg: 'shell comparison failed'}
}
}
+18
View File
@@ -0,0 +1,18 @@
cd work
printf 'pwd=%s\n' "$(pwd)"
printf 'upper=%s\n' "$(cat f | tr a-z A-Z)"
ls *.txt | wc -l
( cd sub && pwd )
cat <<'EOF'
$HOME stays literal
EOF
cat missing 2>/dev/null >> log
cat f 2>/dev/null >> log
cat log
wc -l < file
for f in *; do printf 'entry=%s\n' "$f"; done
false || echo ok
false
echo $?
( cd sub && cd ../../other && pwd && cd ../work && pwd )
yes | head -n 2
+76
View File
@@ -0,0 +1,76 @@
//! Launches external components through the S2 host import and pumps their streams.
#![allow(clippy::all, missing_docs)]
use crate::{
ExecutionResult,
openfiles::OpenFile,
processes::{ChildProcess, LaunchRequest},
};
use std::io::{Read, Write};
mod bindings {
wit_bindgen::generate!({ path: "s2-process.wit", world: "shell" });
}
pub(crate) type ProcessId = i32;
pub(crate) fn spawn(mut request: LaunchRequest) -> std::io::Result<ChildProcess> {
let json = serde_json::json!({
"argv": std::iter::once(request.argv0).chain(request.args).collect::<Vec<_>>(),
"env": request.env,
"cwd": request.working_dir,
})
.to_string();
let pid = bindings::s2::process::host::spawn(&json);
let mut input = request.open_files.remove_fd(0);
if matches!(input, Some(OpenFile::Stdin(_))) {
input = None;
}
let mut output = request.open_files.remove_fd(1);
let mut error = request.open_files.remove_fd(2);
// Brush waits for pipeline stages sequentially. Pump every child's IO independently.
let pump = tokio::spawn(async move {
let mut pending = Vec::new();
let mut eof = input.is_none();
let mut closed = output.is_none();
loop {
if pending.is_empty() && !eof {
let mut buf = [0; 8192];
match input
.as_mut()
.expect("input exists until EOF")
.read(&mut buf)
{
Ok(0) => {
eof = true;
input = None;
}
Ok(n) => pending.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(e) => return Err(e),
}
}
let (consumed, done, code, out, err) =
bindings::s2::process::host::exchange(pid, &pending, eof, closed);
pending.drain(..consumed as usize);
if let Some(writer) = output.as_mut() {
if let Err(e) = writer.write_all(&out) {
if e.kind() != std::io::ErrorKind::BrokenPipe {
return Err(e);
}
closed = true;
output = None;
}
}
if let Some(writer) = error.as_mut() {
writer.write_all(&err)?;
}
if done {
return Ok(ExecutionResult::new(code as u8));
}
tokio::task::yield_now().await;
}
});
Ok(ChildProcess::from_future(async move {
pump.await.map_err(std::io::Error::other)?
}))
}
pub(crate) fn exec(_request: LaunchRequest) -> std::io::Error {
std::io::ErrorKind::Unsupported.into()
}
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env nu
# Apply the experiment only to the pinned scratch checkout.
def main [] {
let root = '/tmp/s2-shell/brush'
let revision = (^git -C $root rev-parse HEAD | str trim)
if $revision != '943553433ae04f5b3bd55d2cf207e39eb9ef2af7' { error make {msg: 'unexpected brush revision'} }
let core = ($root | path join brush-core)
cp ($env.FILE_PWD | path join guest-process.rs) ($core | path join src sys wasm process.rs)
cp ($env.FILE_PWD | path join process.wit) ($core | path join s2-process.wit)
let manifest = ($core | path join Cargo.toml)
let text = (open --raw $manifest)
let marker = "[target.'cfg(target_family = \"wasm\")'.dependencies]"
if not ($text | str contains 'wit-bindgen =') {
$text | str replace $marker ($marker + "\nwit-bindgen = \"0.46\"\nserde_json = \"1\"") | save -f $manifest
}
}
+6
View File
@@ -0,0 +1,6 @@
package s2:process;
interface host {
spawn: func(request: string) -> u32;
exchange: func(pid: u32, input: list<u8>, eof: bool, close-output: bool) -> tuple<u32, bool, u32, list<u8>, list<u8>>;
}
world shell { import host; }
+90
View File
@@ -0,0 +1,90 @@
//! Runs the shell component against one virtual world root for the S2 experiment.
mod process;
use sausage_vfs::{
Host, Mounts, Process,
adapter::{self, Adapter, View},
};
use std::sync::Arc;
use wasmtime::{
Engine, Store,
component::{Component, Linker, ResourceTable},
};
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
struct State {
wasi: WasiCtx,
table: ResourceTable,
fs: Adapter,
runtime: Arc<process::Runtime>,
children: Vec<process::Child>,
}
impl WasiView for State {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView {
ctx: &mut self.wasi,
table: &mut self.table,
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect();
anyhow::ensure!(
args.len() == 5 && args[3] == "-c",
"usage: s2-shell WORLD SHELL.wasm -c SCRIPT"
);
let mut config = wasmtime::Config::new();
let mut cache = wasmtime::CacheConfig::new();
cache.with_directory("/tmp/s2-shell/cache");
config.cache(Some(wasmtime::Cache::new(cache)?));
let engine = Engine::new(&config)?;
let component = Component::from_file(&engine, &args[2])?;
let runtime = Arc::new(process::Runtime {
engine: engine.clone(),
utility: Component::from_file(&engine, std::path::Path::new(&args[1]).join("bin/cat"))?,
world: args[1].clone().into(),
});
let mut mounts = Mounts::default();
mounts.mount(
"/",
Arc::new(Host::new(&args[1], Process::default())?),
false,
);
let mut linker = Linker::new(&engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
adapter::add_to_linker(&mut linker, |s: &mut State| View {
adapter: &mut s.fs,
table: &mut s.table,
})?;
process::add(&mut linker)?;
let mut wasi = WasiCtxBuilder::new();
wasi.inherit_stdio()
.args(&["sh", "--noprofile", "--norc", "-c", &args[4]])
.env("PATH", "/bin")
.env("HOME", "/")
.env("PWD", "/")
.initial_cwd("/");
let mut store = Store::new(
&engine,
State {
wasi: wasi.build(),
table: ResourceTable::new(),
fs: Adapter::new(mounts),
runtime,
children: Vec::new(),
},
);
let command =
wasmtime_wasi::p2::bindings::Command::instantiate_async(&mut store, &component, &linker)
.await?;
let code = match command.wasi_cli_run().call_run(&mut store).await {
Ok(Ok(())) => 0,
Ok(Err(())) => 1,
Err(error) => match error.downcast_ref::<wasmtime_wasi::I32Exit>() {
Some(exit) => exit.0,
None => return Err(error.into()),
},
};
std::process::exit(code);
}
+191
View File
@@ -0,0 +1,191 @@
//! Starts a uutils component in its own store and exchanges bounded pipe chunks.
use super::State;
use sausage_vfs::{
Host, Mounts, Process,
adapter::{self, Adapter, View},
};
use serde::Deserialize;
use std::{
path::PathBuf,
sync::{
Arc,
atomic::{AtomicI32, Ordering},
},
};
use tokio::net::UnixStream;
use wasmtime::{
Engine, Store, StoreContextMut,
component::{Component, Linker, ResourceTable},
};
use wasmtime_wasi::{
WasiCtxBuilder,
cli::{AsyncStdinStream, AsyncStdoutStream},
};
pub struct Runtime {
pub engine: Engine,
pub utility: Component,
pub world: PathBuf,
}
pub struct Child {
input: Option<UnixStream>,
output: Option<UnixStream>,
error: Option<UnixStream>,
code: Arc<AtomicI32>,
}
#[derive(Deserialize)]
struct Request {
argv: Vec<String>,
env: Vec<(String, String)>,
cwd: String,
}
fn pair() -> wasmtime::Result<(UnixStream, UnixStream)> {
let (a, b) = std::os::unix::net::UnixStream::pair()?;
a.set_nonblocking(true)?;
b.set_nonblocking(true)?;
Ok((UnixStream::from_std(a)?, UnixStream::from_std(b)?))
}
fn drain(pipe: &mut Option<UnixStream>) -> wasmtime::Result<Vec<u8>> {
let Some(stream) = pipe else {
return Ok(Vec::new());
};
let mut bytes = vec![0; 8192];
match stream.try_read(&mut bytes) {
Ok(0) => {
*pipe = None;
bytes.clear();
}
Ok(n) => bytes.truncate(n),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => bytes.clear(),
Err(e) => return Err(e.into()),
}
Ok(bytes)
}
pub fn add(linker: &mut Linker<State>) -> wasmtime::Result<()> {
let mut host = linker.instance("s2:process/host")?;
host.func_wrap(
"spawn",
|mut store: StoreContextMut<'_, State>, (json,): (String,)| {
let request: Request = serde_json::from_str(&json)?;
let runtime = store.data().runtime.clone();
let name = std::path::Path::new(&request.argv[0])
.file_name()
.ok_or_else(|| wasmtime::format_err!("missing command name"))?;
wasmtime::ensure!(
runtime.world.join("bin").join(name).is_file(),
"command is absent from /bin"
);
let (input, child_input) = pair()?;
let (output, child_output) = pair()?;
let (error, child_error) = pair()?;
let code = Arc::new(AtomicI32::new(-1));
let result_code = code.clone();
tokio::spawn(async move {
let result = run(runtime, request, child_input, child_output, child_error).await;
let status = match result {
Ok(status) => status,
Err(error) => {
eprintln!("child component: {error:#}");
125
}
};
result_code.store(status, Ordering::Release);
});
let state = store.data_mut();
let pid = state.children.len() as u32;
state.children.push(Child {
input: Some(input),
output: Some(output),
error: Some(error),
code,
});
Ok((pid,))
},
)?;
host.func_wrap(
"exchange",
|mut store: StoreContextMut<'_, State>,
(pid, bytes, eof, closed): (u32, Vec<u8>, bool, bool)| {
let child = &mut store.data_mut().children[pid as usize];
let consumed = match child.input.as_mut() {
Some(input) if !bytes.is_empty() => match input.try_write(&bytes) {
Ok(n) => n,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => 0,
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
child.input = None;
bytes.len()
}
Err(e) => return Err(e.into()),
},
_ => bytes.len(),
};
if eof && consumed == bytes.len() {
child.input = None;
}
if closed {
child.output = None;
}
let out = drain(&mut child.output)?;
let err = drain(&mut child.error)?;
let code = child.code.load(Ordering::Acquire);
let done = code >= 0 && child.output.is_none() && child.error.is_none();
Ok(((consumed as u32, done, code.max(0) as u32, out, err),))
},
)?;
Ok(())
}
async fn run(
runtime: Arc<Runtime>,
request: Request,
input: UnixStream,
output: UnixStream,
error: UnixStream,
) -> wasmtime::Result<i32> {
let mut mounts = Mounts::default();
mounts.mount(
"/",
Arc::new(Host::new(&runtime.world, Process::default())?),
false,
);
let mut linker = Linker::new(&runtime.engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
adapter::add_to_linker(&mut linker, |s: &mut State| View {
adapter: &mut s.fs,
table: &mut s.table,
})?;
let mut wasi = WasiCtxBuilder::new();
let (reader, _) = input.into_split();
let (_, writer) = output.into_split();
let (_, err_writer) = error.into_split();
wasi.args(&request.argv)
.envs(&request.env)
.env("S2_CWD", &request.cwd)
.initial_cwd(&request.cwd)
.stdin(AsyncStdinStream::new(reader))
.stdout(AsyncStdoutStream::new(8192, writer))
.stderr(AsyncStdoutStream::new(8192, err_writer));
let mut store = Store::new(
&runtime.engine,
State {
wasi: wasi.build(),
table: ResourceTable::new(),
fs: Adapter::new(mounts),
runtime: runtime.clone(),
children: Vec::new(),
},
);
let command = wasmtime_wasi::p2::bindings::Command::instantiate_async(
&mut store,
&runtime.utility,
&linker,
)
.await?;
match command.wasi_cli_run().call_run(&mut store).await {
Ok(Ok(())) => Ok(0),
Ok(Err(())) => Ok(1),
Err(error) => match error.downcast_ref::<wasmtime_wasi::I32Exit>() {
Some(exit) => Ok(exit.0),
None => Err(error),
},
}
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env nu
# Time each corpus construct after compare.nu creates the worlds and warms the cache.
def main [host: path, shell: path] {
let root = '/tmp/s2-shell/comparison'
let cases = [
{name: 'command substitution: pwd', script: 'printf "%s\n" "$(pwd)"'}
{name: 'command substitution: cat | tr', script: 'printf "%s\n" "$(cat f | tr a-z A-Z)"'}
{name: 'glob: ls | wc', script: 'ls *.txt | wc -l'}
{name: 'subshell: cd', script: '( cd sub && pwd )'}
{name: 'quoted heredoc', script: "cat <<'EOF'\n$HOME stays literal\nEOF\n"}
{name: 'stderr and append redirects', script: 'cat missing 2>/dev/null >> log; cat f 2>/dev/null >> log; cat log'}
{name: 'stdin redirect', script: 'wc -l < file'}
{name: 'glob loop', script: 'for f in *; do printf "entry=%s\n" "$f"; done'}
{name: 'false || echo', script: 'false || echo ok'}
{name: 'exit status', script: 'false; echo $?'}
{name: 'stderr and child exit', script: 'cat missing'}
{name: 'whole-root traversal', script: '( cd sub && cd ../../other && pwd && cd ../work && pwd )'}
{name: 'external paths across root', script: 'cd ../other; pwd; cat ../work/f; cat @ROOT@/work/f'}
{name: 'yes | head', script: 'yes | head -n 2'}
]
let results = ($cases | each {|case|
let script = ('cd work; ' + $case.script)
let native_root = ($root | path join native)
let start = (date now)
let native = (do { cd $native_root; ^bash --noprofile --norc -c ($script | str replace --all '@ROOT@' $native_root) } | complete)
let native_ms = (((date now) - $start) / 1ms)
let start = (date now)
let guest = (^$host ($root | path join guest) $shell -c ($script | str replace --all '@ROOT@' '') | complete)
let guest_ms = (((date now) - $start) / 1ms)
let equal = (($native.stdout | str replace --all $native_root '') == $guest.stdout and $native.exit_code == $guest.exit_code)
if not $equal { error make {msg: $'case failed: ($case.name)', help: $guest.stderr} }
if $native.stderr != '' and $guest.stderr == '' { error make {msg: 'child stderr was lost'} }
{command: $case.name, native_ms: $native_ms, guest_ms: $guest_ms, exit: $guest.exit_code, equal: $equal}
})
$results | to json
}
+1505
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "s2-userland"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
# uucore enables rustix without its std adapters. WASI needs those adapters.
rustix = { version = "1.1.6", features = ["std"] }
uucore = "=0.12.0"
uu_cat = "=0.12.0"
uu_tr = "=0.12.0"
uu_ls = "=0.12.0"
uu_wc = "=0.12.0"
uu_yes = "=0.12.0"
uu_head = "=0.12.0"
+24
View File
@@ -0,0 +1,24 @@
//! Dispatches uutils 0.12 commands after libc adopts the process working directory.
fn main() {
// wasi-libc starts at / and does not consume wasi:cli/environment.initial-cwd.
std::env::set_current_dir(std::env::var("S2_CWD").expect("host supplies cwd"))
.expect("process working directory exists");
let argv0 = std::env::args().next().expect("host supplies argv0");
let name = std::path::Path::new(&argv0)
.file_name()
.unwrap()
.to_str()
.unwrap();
uucore::locale::setup_localization(name).expect("utility supplies localization");
let args = uucore::args_os();
let code = match name {
"cat" => uu_cat::uumain(args),
"tr" => uu_tr::uumain(args),
"ls" => uu_ls::uumain(args),
"wc" => uu_wc::uumain(args),
"yes" => uu_yes::uumain(args),
"head" => uu_head::uumain(args),
_ => unreachable!("fixture supplies the six uutils aliases"),
};
std::process::exit(code);
}
@@ -0,0 +1,6 @@
--- a/git-compat-util.h
+++ b/git-compat-util.h
@@ -282,1 +282,3 @@
+#ifndef __wasi__
#include <syslog.h>
+#endif
+85
View File
@@ -0,0 +1,85 @@
# Upstream Git WASI spike
Status: the agentOS baseline builds Git 2.55.0 for WASI. Diagnostic preload tests fail during startup.
The binary is `/tmp/s3-git/agentos/toolchain/c/build/git` (3,987,814 bytes).
See `.board/T3.report.md` for all 87 imports and the measured operation results.
## Rework 1: unchanged agentOS baseline
`baseline.nu` invokes the pinned agentOS sysroot script through `bash`, then invokes its Git Makefile target.
The clone lives at `/tmp/s3-git/agentos`, commit `605f76de28366118c84e92ca6fd4938c1c501025`.
The launcher expects `/tmp/s3-git/tools/nproc` to print `2` to limit compiler jobs.
Rework 2 added Python3 as an upstream build dependency. The unchanged build then succeeded.
```sh
nix shell nixpkgs#gnumake nixpkgs#cmake nixpkgs#ninja nixpkgs#gcc nixpkgs#patch nixpkgs#perl nixpkgs#python3 -c nu spikes/s3-git/baseline.nu
nix develop -c nu spikes/s3-git/stubs.nu /tmp/s3-git/agentos/toolchain/c/build/git /tmp/s3-git/stubs
mkdir -p /tmp/s3-git/dev
touch /tmp/s3-git/dev/null
nix develop -c nu spikes/s3-git/test.nu /tmp/s3-git/agentos/toolchain/c/build/git --stubs /tmp/s3-git/stubs
nix develop -c nu spikes/s3-git/probe.nu /tmp/s3-git/agentos/toolchain/c/build/git /tmp/s3-git/stubs
```
`stubs.nu` extracts imports and generates the five WAT preload modules.
The repository also contains those concrete WAT files.
`test.nu` fails at the first unsuccessful operation.
`probe.nu` records every operation independently. Native Git creates its fixtures.
All WASI probes fail at Git's startup open of `/dev/null` with the diagnostic stubs.
An exit code of zero from `probe.nu` means that it recorded results, not that Git passed.
## Original stock-SDK attempt
The brief defines the intended local operations. `test.nu` exercises those operations through Wasmtime.
The script needs a successful build before it can verify behavior.
## Reproduce the build failure
The experiment uses Git 2.49.0, zlib 1.3.1, and WASI SDK 34.0 for Linux ARM64.
Run these commands from the repository root. Keep the extracted sources under `/tmp/s3-git`.
```sh
mkdir -p /tmp/s3-git
curl -fL https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-34/wasi-sdk-34.0-arm64-linux.tar.gz -o /tmp/s3-git/sdk.tar.gz
tar -xf /tmp/s3-git/sdk.tar.gz -C /tmp/s3-git
curl -fL https://www.kernel.org/pub/software/scm/git/git-2.49.0.tar.xz -o /tmp/s3-git/git.tar.xz
tar -xf /tmp/s3-git/git.tar.xz -C /tmp/s3-git
curl -fL https://zlib.net/fossils/zlib-1.3.1.tar.gz -o /tmp/s3-git/zlib.tar.gz
tar -xf /tmp/s3-git/zlib.tar.gz -C /tmp/s3-git
nix build nixpkgs#ncurses --no-link
```
Set `LD_LIBRARY_PATH` to the SDK's `lib` directory and `$(nix eval --raw nixpkgs#ncurses.outPath)/lib`.
In `/tmp/s3-git/zlib-1.3.1`, run:
```sh
CC=/tmp/s3-git/wasi-sdk-34.0-arm64-linux/bin/clang AR=/tmp/s3-git/wasi-sdk-34.0-arm64-linux/bin/llvm-ar RANLIB=/tmp/s3-git/wasi-sdk-34.0-arm64-linux/bin/llvm-ranlib ./configure --static
nix shell nixpkgs#gnumake -c make -j2 libz.a
```
Apply `0001-wasi-no-syslog-header.patch` with `patch -p1` in the Git source directory.
From the repository root, run:
```sh
nix shell nixpkgs#gnumake -c nu spikes/s3-git/build.nu
```
This fails because the stock sysroot lacks `sys/wait.h`.
The patch only suppresses an unavailable header. It does not implement syslog or process support.
## Required verification after a successful build
```sh
nix develop -c nu spikes/s3-git/test.nu /tmp/s3-git/git-2.49.0/git
```
Run the same script with the adapted component path after `wasm-tools component new --adapt`.
Neither execution passed in this spike.
## Source checksums
```text
f7e243dff54d60bcc576e94d6166b69f410f2500ae4a9ceef34315be10e77971 sdk.tar.gz
618190cf590b7e9f6c11f91f23b1d267cd98c3ab33b850416d8758f8b5a85628 git.tar.xz
9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23 zlib.tar.gz
```
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env nu
# Invoke agentOS's unchanged recipe and sysroot build with two compiler jobs.
def main [] {
$env.PATH = ($env.PATH | prepend /tmp/s3-git/tools)
$env.LD_LIBRARY_PATH = $'((nix eval --raw nixpkgs#ncurses.outPath))/lib'
$env.TMPDIR = '/tmp/s3-git'
$env.MAKE_JOBS = '2'
cd /tmp/s3-git/agentos
^bash toolchain/scripts/patch-wasi-libc.sh
^make -C toolchain/c build/git
}
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env nu
# Build upstream Git's local CLI with the stock WASI SDK. Sources live in /tmp/s3-git.
def main [] {
let sdk = '/tmp/s3-git/wasi-sdk-34.0-arm64-linux'
let ncurses = (nix eval --raw nixpkgs#ncurses.outPath)
$env.LD_LIBRARY_PATH = $'($sdk)/lib:($ncurses)/lib'
cd /tmp/s3-git/git-2.49.0
^make -j2 uname_S=WASI $'CC=($sdk)/bin/clang' $'AR=($sdk)/bin/llvm-ar' $'RANLIB=($sdk)/bin/llvm-ranlib' 'CFLAGS=-O2 -I/tmp/s3-git/zlib-1.3.1 -D_WASI_EMULATED_PROCESS_CLOCKS -D_WASI_EMULATED_MMAN -D_WASI_EMULATED_SIGNAL' 'LDFLAGS=-L/tmp/s3-git/zlib-1.3.1 -lwasi-emulated-process-clocks -lwasi-emulated-mman -lwasi-emulated-signal -Wl,-z,stack-size=8388608 -Wl,--stack-first' CSPRNG_METHOD=getentropy HAVE_CLOCK_GETTIME=YesPlease HAVE_GETDELIM=YesPlease NO_OPENSSL=1 NO_CURL=1 NO_EXPAT=1 NO_GETTEXT=1 NO_TCLTK=1 NO_PERL=1 NO_PYTHON=1 NO_REGEX=NeedsStartEnd NO_ICONV=1 NO_PTHREADS=1 NO_MMAP=1 NO_SYS_POLL_H=1 NO_NSEC=1 git
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'Git build failed'} }
}
+16
View File
@@ -0,0 +1,16 @@
(module
(func (export "fd_mode") (param i32) (result i32) i32.const 420)
(func (export "fd_size") (param i32) (result i64) i64.const -1)
(func (export "fd_blocks") (param i32) (result i64) i64.const -1)
(func (export "fd_owner") (param i32 i32 i32) (result i32) i32.const 52)
(func (export "ftruncate") (param i32 i64) (result i32) i32.const 52)
(func (export "open_tmpfile") (param i32 i32 i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "set_open_direct") (param i32) (result i32) i32.const 52)
(func (export "set_open_mode") (param i32) (result i32) i32.const 0)
(func (export "path_access") (param i32 i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "path_mode") (param i32 i32 i32 i32) (result i32) i32.const 420)
(func (export "path_size") (param i32 i32 i32 i32) (result i64) i64.const -1)
(func (export "path_blocks") (param i32 i32 i32 i32) (result i64) i64.const -1)
(func (export "path_owner") (param i32 i32 i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "chmod") (param i32 i32 i32 i32) (result i32) i32.const 52)
)
+15
View File
@@ -0,0 +1,15 @@
(module
(func (export "net_recv") (param i32 i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "net_send") (param i32 i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "net_socket") (param i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "net_validate_socket") (param i32) (result i32) i32.const 52)
(func (export "net_connect") (param i32 i32 i32) (result i32) i32.const 52)
(func (export "net_bind") (param i32 i32 i32) (result i32) i32.const 52)
(func (export "net_listen") (param i32 i32) (result i32) i32.const 52)
(func (export "net_validate_accept") (param i32) (result i32) i32.const 52)
(func (export "net_accept") (param i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "net_setsockopt") (param i32 i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "net_getaddrinfo") (param i32 i32 i32 i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "net_poll") (param i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "net_dns_query_rr_v1") (param i32 i32 i32 i32 i32 i32 i32 i32) (result i32) i32.const 52)
)
+21
View File
@@ -0,0 +1,21 @@
(module
(func (export "proc_getpid") (param i32) (result i32) i32.const 52)
(func (export "proc_getppid") (param i32) (result i32) i32.const 52)
(func (export "proc_kill") (param i32 i32) (result i32) i32.const 52)
(func (export "fd_pipe") (param i32 i32) (result i32) i32.const 52)
(func (export "fd_dup") (param i32 i32) (result i32) i32.const 52)
(func (export "fd_dup2") (param i32 i32) (result i32) i32.const 52)
(func (export "proc_getrlimit") (param i32 i32 i32) (result i32) i32.const 52)
(func (export "proc_sigaction") (param i32 i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "proc_signal_mask_v2") (param i32 i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "proc_spawn_v4") (param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "proc_waitpid_v3") (param i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "fd_setfd") (param i32 i32) (result i32) i32.const 0)
(func (export "fd_dup_min") (param i32 i32 i32) (result i32) i32.const 52)
(func (export "fd_getfd") (param i32 i32) (result i32) i32.const 0)
(func (export "fd_record_lock") (param i32 i32 i32 i64 i64 i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "proc_itimer_real") (param i32 i64 i64 i32 i32) (result i32) i32.const 52)
(func (export "proc_getpgid") (param i32 i32) (result i32) i32.const 52)
(func (export "proc_exec") (param i32 i32 i32 i32 i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "proc_umask") (param i32 i32) (result i32) i32.const 52)
)
+4
View File
@@ -0,0 +1,4 @@
(module
(func (export "isatty") (param i32) (result i32) i32.const 52)
(func (export "set_raw_mode") (param i32) (result i32) i32.const 52)
)
+7
View File
@@ -0,0 +1,7 @@
(module
(func (export "getpwnam") (param i32 i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "getpwuid") (param i32 i32 i32 i32) (result i32) i32.const 52)
(func (export "getuid") (param i32) (result i32) i32.const 52)
(func (export "geteuid") (param i32) (result i32) i32.const 52)
(func (export "isatty") (param i32 i32) (result i32) i32.const 52)
)
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env nu
# Probe each WASI operation separately. Native Git creates only the test fixtures.
def main [binary: path, stubs: path] {
let binary = ($binary | path expand)
let preloads = (glob ($stubs | path join '*.wasm') | each {|p| ['--preload' $'($p | path parse | get stem)=($p)'] } | flatten)
let root = (^mktemp -d /tmp/s3-git/probe.XXXXXX | str trim)
let repo = ($root | path join repo)
mkdir $repo
$env.GIT_CONFIG_NOSYSTEM = '1'
$env.GIT_AUTHOR_NAME = 'Spike'
$env.GIT_AUTHOR_EMAIL = 'spike@example.invalid'
$env.GIT_COMMITTER_NAME = 'Spike'
$env.GIT_COMMITTER_EMAIL = 'spike@example.invalid'
def --wrapped native [...args: string] { ^git -C $repo -c commit.gpgsign=false ...$args }
def probe [label: string, args: list<string>] {
let r = (^wasmtime run ...$preloads --dir /tmp/s3-git/dev::/dev --dir $'($root)::/repo' --env GIT_PAGER=cat --env GIT_EDITOR=true --env GIT_AUTHOR_NAME=Spike --env GIT_AUTHOR_EMAIL=spike@example.invalid --env GIT_COMMITTER_NAME=Spike --env GIT_COMMITTER_EMAIL=spike@example.invalid --env GIT_CONFIG_NOSYSTEM=1 --env HOME=/repo -- $binary -C /repo/repo -c core.pager=cat -c maintenance.auto=false ...$args | complete)
print $'=== ($label): exit ($r.exit_code) ==='
print $r.stdout
print --stderr $r.stderr
}
probe version ['--version']
probe init ['init' '-b' 'main']
native init -b main
'base' | save ($repo | path join base)
probe add ['add' 'base']
native add base
probe commit ['commit' '-m' 'base']
native commit -m base
probe status ['status' '--short']
probe log ['log' '--oneline']
probe branch ['branch' 'wasi-branch']
native branch feature
probe switch ['switch' 'feature']
'changed' | save --force ($repo | path join base)
probe diff ['diff']
native reset --hard
native switch feature
'feature' | save ($repo | path join feature)
native add feature
native commit -m feature
native switch main
probe fast-forward ['merge' '--ff-only' 'feature']
native merge --ff-only feature
native branch side
'main' | save ($repo | path join main)
native add main
native commit -m main
native switch side
'side' | save ($repo | path join side)
native add side
native commit -m side
native switch main
probe three-way ['merge' '--no-edit' 'side']
probe worktree ['worktree' 'add' '-b' 'linked' '/repo/linked']
print $'Fixtures: ($root)'
}
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env nu
# Extract the complete import table and generate explicit unsupported host modules.
def main [binary: path, output: path] {
mkdir $output
let wat = (^wasm-tools print $binary)
$wat | save --force ($output | path join git.wat)
let types = ($wat | lines | parse -r '^ \(type \(;(?P<id>\d+);\) \(func(?P<sig>.*)\)\)$')
let imports = ($wat | lines | parse -r '^ \(import "(?P<module>[^"]+)" "(?P<name>[^"]+)" \(func .*\(type (?P<id>\d+)\).*\)\)$' | each {|i|
let sig = ($types | where id == $i.id | first | get sig)
{module: $i.module, name: $i.name, signature: ($sig | str trim)}
})
$imports | to json | save --force ($output | path join imports.json)
for module in ($imports | get module | uniq | where {|m| $m | str starts-with 'host_' }) {
let functions = ($imports | where module == $module | each {|i|
let result = ($i.signature | parse -r '\(result (?P<ty>\w+)\)' | get ty | first)
let value = if $i.name in ['fd_mode' 'path_mode'] { '420' } else if $i.name =~ '(size|blocks)$' { '-1' } else if $i.name in ['fd_getfd' 'fd_setfd' 'set_open_mode'] { '0' } else { '52' }
' (func (export "' + $i.name + '") ' + $i.signature + ' ' + $result + '.const ' + $value + ')'
} | str join (char nl))
let file = ($output | path join $'($module).wat')
('(module' + (char nl) + $functions + (char nl) + ')') | save --force $file
^wasm-tools parse $file -o ($output | path join $'($module).wasm')
}
}
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env nu
# Exercise local Git operations in a fresh directory through Wasmtime.
def main [binary: path, --stubs: path] {
let binary = ($binary | path expand)
let preloads = if $stubs == null { [] } else {
glob ($stubs | path join '*.wasm') | each {|p| ['--preload' $'($p | path parse | get stem)=($p)'] } | flatten
}
let root = (^mktemp -d /tmp/s3-git/local.XXXXXX | str trim)
let repo = ($root | path join repo)
mkdir $repo
def --wrapped g [...args: string] {
print $'git ($args | str join " ")'
^wasmtime run ...$preloads --dir /tmp/s3-git/dev::/dev --dir $'($root)::/repo' --env GIT_PAGER=cat --env GIT_EDITOR=true --env GIT_AUTHOR_NAME=Spike --env GIT_AUTHOR_EMAIL=spike@example.invalid --env GIT_COMMITTER_NAME=Spike --env GIT_COMMITTER_EMAIL=spike@example.invalid --env GIT_CONFIG_NOSYSTEM=1 --env HOME=/repo -- $binary -C /repo/repo -c core.pager=cat -c maintenance.auto=false -c commit.gpgsign=false ...$args
if $env.LAST_EXIT_CODE != 0 { error make {msg: $'Git failed: ($args | str join " ")'} }
}
g init -b main
'base' | save ($repo | path join base)
g add base
g commit -m base
g status --short
g log --oneline
g branch feature
g switch feature
'feature' | save ($repo | path join feature)
g add feature
g commit -m feature
g switch main
g merge --ff-only feature
g branch side
'main' | save ($repo | path join main)
g add main
g commit -m main
g switch side
'side' | save ($repo | path join side)
g add side
g commit -m side
g switch main
g merge --no-edit side
'changed' | save --force ($repo | path join base)
g diff
g worktree add -b linked /repo/linked
if not ($root | path join linked side | path exists) { error make {msg: 'Missing worktree file'} }
print $'PASS: local operations in ($root)'
}
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "builder"
version = "0.1.0"
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "builder"
version = "0.1.0"
edition = "2024"
[workspace]
+75
View File
@@ -0,0 +1,75 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "namespace-probe"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "namespace-probe"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
serde = { version = "1", features = ["derive"] }
+17
View File
@@ -0,0 +1,17 @@
//! Asserts that native build scripts cannot access host files or the network.
use std::{
env, fs,
net::{SocketAddr, TcpStream},
time::Duration,
};
fn main() {
let home = env::var("HOME").unwrap();
assert!(fs::read_dir(format!("{home}/.ssh")).is_err());
assert!(fs::read_dir("/home/surma/.ssh").is_err());
assert!(fs::write("/outside-target-probe", "escape").is_err());
let address: SocketAddr = "1.1.1.1:443".parse().unwrap();
assert!(TcpStream::connect_timeout(&address, Duration::from_secs(1)).is_err());
println!("cargo:warning=PASS: HOME/.ssh hidden, outside write denied, network denied");
println!("cargo:rerun-if-changed=build.rs");
}
+16
View File
@@ -0,0 +1,16 @@
//! Exercises a native serde proc macro and a WASI test executable.
#[derive(serde::Serialize, serde::Deserialize)]
struct Message {
value: u32,
}
fn main() {
println!("serde derive works: {}", Message { value: 42 }.value);
}
#[test]
fn serde_derive_works() {
fn assert_serde<T: serde::Serialize + for<'a> serde::Deserialize<'a>>() {}
assert_serde::<Message>();
assert_eq!(Message { value: 42 }.value, 42);
}
+133
View File
@@ -0,0 +1,133 @@
//! Copies sources, fetches dependencies, and builds WASI artifacts in Linux namespaces.
use std::{env, error::Error, fs, path::Path, process::Command};
type Result<T> = std::result::Result<T, Box<dyn Error>>;
const BASE: &str = "/tmp/s4-builder";
const KACHE: &str = "/nix/store/s4zxxn1y3kzvi33yl0gny5y30ri14h4a-kache-1.2.2/bin/kache";
fn run(command: &mut Command) -> Result<()> {
let status = command.status()?;
if !status.success() {
return Err(format!("{command:?}: {status}").into());
}
Ok(())
}
fn copy_tree(source: &Path, dest: &Path) -> Result<()> {
fs::create_dir_all(dest)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
if ["target", ".git"].contains(&entry.file_name().to_str().unwrap_or("")) {
continue;
}
let path = dest.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &path)?;
} else if entry.file_type()?.is_file() {
fs::copy(entry.path(), path)?;
} else {
return Err("source symlinks are not supported by this spike".into());
}
}
Ok(())
}
fn artifacts(source: &Path, out: &Path) -> Result<()> {
for entry in fs::read_dir(source)? {
let path = entry?.path();
if path.is_dir() {
artifacts(&path, out)?;
} else if path.extension().is_some_and(|ext| ext == "wasm") {
fs::copy(&path, out.join(path.file_name().unwrap()))?;
println!("artifact: {}", path.display());
}
}
Ok(())
}
fn main() -> Result<()> {
let args: Vec<_> = env::args().collect();
if args.len() != 4 || args[1] != "build" {
return Err("usage: builder build <source dir> <out dir>".into());
}
let base = Path::new(BASE);
let source = base.join("source");
let target = base.join("target");
let registry = base.join("cargo-home");
let cache = base.join("kache");
for dir in [&target, &registry, &cache, &target.join("tmp")] {
fs::create_dir_all(dir)?;
}
if source.exists() {
fs::remove_dir_all(&source)?;
}
copy_tree(Path::new(&args[2]), &source)?;
run(Command::new("cargo")
.arg("fetch")
.current_dir(&source)
.env("CARGO_HOME", &registry))?;
// A private PID namespace also removes the on-demand kache daemon after cargo exits.
// It must never reuse a daemon outside the mount/network namespaces.
for action in ["build", "test"] {
let mut command = Command::new("bwrap");
command
.args([
"--unshare-user",
"--unshare-pid",
"--unshare-net",
"--die-with-parent",
])
.args(["--ro-bind", "/nix/store", "/nix/store"])
.arg("--bind")
.arg(target.join("tmp"))
.arg("/tmp")
.arg("--bind")
.arg(&source)
.arg(&source)
.arg("--bind")
.arg(&target)
.arg(&target)
.arg("--bind")
.arg(&cache)
.arg(&cache)
.arg("--ro-bind")
.arg(&registry)
.arg(&registry)
.args([
"--proc",
"/proc",
"--dev",
"/dev",
"--remount-ro",
"/",
"--chdir",
])
.arg(&source)
.args(["--clearenv", "--setenv", "PATH"])
.arg(env::var("PATH")?)
.args(["--setenv", "HOME", "/nonexistent"])
.args(["--setenv", "CARGO_HOME"])
.arg(&registry)
.args(["--setenv", "CARGO_TARGET_DIR"])
.arg(&target)
.args(["--setenv", "RUSTC_WRAPPER", KACHE])
.args(["--setenv", "KACHE_CACHE_DIR"])
.arg(&cache)
.args(["--setenv", "CARGO_BUILD_JOBS", "2"])
.args([
"cargo",
action,
"--release",
"--offline",
"--target",
"wasm32-wasip2",
]);
if action == "test" {
command.arg("--no-run");
}
run(&mut command)?;
}
fs::create_dir_all(&args[3])?;
artifacts(&target.join("wasm32-wasip2/release"), Path::new(&args[3]))
}
+2746
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "s5-http"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
anyhow = "1"
bytes = "1"
http = "1"
http-body-util = "0.1"
hyper = { version = "1.12.0", features = ["server", "http1"] }
hyper-util = { version = "0.1.21", features = ["tokio"] }
tokio = { version = "1", features = ["full"] }
wasmtime = "=49.0.2"
wasmtime-wasi = "=49.0.2"
wasmtime-wasi-http = "=49.0.2"
+38
View File
@@ -0,0 +1,38 @@
# S5: HTTP hook proof
The brief in `.board/T5.brief.md` defines this spike's contract.
The host assigns an identity to each WASI store. Its HTTP hook answers
`http://host.sausage/whoami` directly. This path never calls the network transport.
Items match a scheme, exact hosts or `*.suffix` hosts, path prefixes, and optional
methods. One match requires a grant or free use. The host then injects an
Authorization header through a Rust closure. No match preserves the request.
No grant returns a 403 that names the item. Multiple matches return an HTTP error.
The hook consumes the outgoing body before it returns an in-process response or error.
It also collects forwarded bodies before transport, with their data and trailers intact.
This prevents Hyper from closing an unknown-length GET body before the guest finishes its WASI stream.
The spike buffers forwarded bodies. It does not prove streaming forwarding.
The test server consumes the complete HTTP request body before it answers and closes the connection.
It validates the token but returns only its presence and correctness.
The guest prints its environment, files, request headers, and response.
The host checks that none contain its fresh token.
Run all cases from the repository root:
```sh
nu spikes/s5-http/verify.nu
```
Repeat the direct host run 20 times:
```sh
nu spikes/s5-http/repeat.nu after-body
```
The loop preserves each run's stdout and stderr separately under `/tmp/s5-http/`.
The spike does not implement durable approval requests, conversation ancestry,
JavaScript transforms, or a real credentials vault. Grants are a per-store boolean.
The filesystem contains one public fixture. The token remains in host memory.
+299
View File
@@ -0,0 +1,299 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "fastrand"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be"
dependencies = [
"instant",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-io"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-lite"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"memchr",
"parking",
"pin-project-lite",
"waker-fn",
]
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "instant"
version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
dependencies = [
"cfg-if",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "s5-curl"
version = "0.1.0"
dependencies = [
"anyhow",
"wstd",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "waker-fn"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
dependencies = [
"bitflags",
]
[[package]]
name = "wstd"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29b52936db10a79bb724dadd1c2c3aac958e8229dcb1f1c7f2b7044ca9fc6a3a"
dependencies = [
"anyhow",
"async-task",
"bytes",
"futures-lite",
"http",
"http-body",
"http-body-util",
"itoa",
"pin-project-lite",
"serde",
"serde_json",
"slab",
"wasip2",
"wstd-macro",
]
[[package]]
name = "wstd-macro"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "153db9b65508bf6c2efe26617169840c03671ba5719a35868db7682a5261f7d4"
dependencies = [
"quote",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "s5-curl"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
wstd = "=0.6.8"
anyhow = "1"
+31
View File
@@ -0,0 +1,31 @@
//! A curl-like WASI guest that prints its visible inputs and HTTP result.
use wstd::http::{Body, BodyExt, Client, Request};
#[wstd::main]
async fn main() -> anyhow::Result<()> {
let args: Vec<_> = std::env::args().collect();
println!("environment={:?}", std::env::vars().collect::<Vec<_>>());
println!(
"files={:?}",
std::fs::read_dir("/")?
.map(|e| e.unwrap().file_name())
.collect::<Vec<_>>()
);
println!("file={}", std::fs::read_to_string("/public.txt")?);
let request = Request::builder()
.uri(&args[1])
.method(args[2].as_str())
.header("x-guest", "unchanged")
.body(Body::empty())?;
println!("request-headers={:?}", request.headers());
match Client::new().send(request).await {
Ok(response) => {
println!("status={}", response.status().as_u16());
println!("response-headers={:?}", response.headers());
let body = response.into_body().into_boxed_body().collect().await?;
println!("body={}", String::from_utf8_lossy(&body.to_bytes()));
}
Err(error) => println!("error={error}"),
}
Ok(())
}
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env nu
# Preserve each direct run so intermittent failures retain their full evidence.
def main [label: string] {
mkdir /tmp/s5-http
mut passed = 0
for n in 1..20 {
let result = (do {
^./spikes/s5-http/target/debug/s5-http spikes/s5-http/guest/target/wasm32-wasip2/debug/s5-curl.wasm
} | complete)
$result.stdout | save -f $"/tmp/s5-http/($label)-($n).out"
$result.stderr | save -f $"/tmp/s5-http/($label)-($n).err"
if $result.exit_code == 0 {
$passed = $passed + 1
print $"RUN ($n): PASS"
} else {
print $"RUN ($n): FAIL exit=($result.exit_code)"
print -e $result.stderr
}
}
print $"RESULT ($label): ($passed)/20 passed"
if $passed != 20 { error make {msg: "direct runs failed"} }
}
+387
View File
@@ -0,0 +1,387 @@
//! Runs the HTTP hook and verifies real WASI guests against a local test server.
use anyhow::Result;
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::{server::conn::http1, service::service_fn};
use hyper_util::rt::TokioIo;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use wasmtime::component::{Component, Linker, ResourceTable};
use wasmtime::{Engine, Store};
use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
use wasmtime_wasi_http::{WasiBody, WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
type Io = Box<dyn Future<Output = Result<(), wasmtime_wasi_http::Error>> + Send>;
struct Item {
name: &'static str,
scheme: &'static str,
hosts: Vec<&'static str>,
prefixes: Vec<&'static str>,
methods: Option<Vec<http::Method>>,
free: bool,
}
impl Item {
fn matches(&self, request: &http::Request<WasiBody>) -> bool {
let uri = request.uri();
uri.scheme_str() == Some(self.scheme)
&& self
.hosts
.iter()
.any(|pattern| host_matches(pattern, uri.host().unwrap_or("")))
&& self.prefixes.iter().any(|p| uri.path().starts_with(p))
&& self
.methods
.as_ref()
.is_none_or(|m| m.contains(request.method()))
}
}
fn host_matches(pattern: &str, host: &str) -> bool {
match pattern.strip_prefix("*.") {
Some(suffix) => host
.strip_suffix(suffix)
.is_some_and(|p| p.ends_with('.') && p.len() > 1),
None => pattern == host,
}
}
struct Hooks {
id: String,
grant: bool,
token: Arc<String>,
items: Arc<Vec<Item>>,
outgoing: Arc<AtomicUsize>,
}
fn response(
status: u16,
text: String,
) -> wasmtime_wasi_http::Result<(http::Response<WasiBody>, Io)> {
let body = Full::new(Bytes::from(text))
.map_err(|never| match never {})
.boxed_unsync();
Ok((
http::Response::builder().status(status).body(body).unwrap(),
Box::new(async { Ok(()) }),
))
}
impl WasiHttpHooks for Hooks {
fn send_request(
&mut self,
mut request: http::Request<WasiBody>,
options: Option<wasmtime_wasi_http::RequestOptions>,
fut: Io,
) -> Box<dyn Future<Output = wasmtime_wasi_http::Result<(http::Response<WasiBody>, Io)>> + Send>
{
drop(fut);
if request.uri().host() == Some("host.sausage") {
let result = response(200, format!("caller={}", self.id));
return Box::new(async move {
request.into_body().collect().await?;
result
});
}
let matches: Vec<_> = self.items.iter().filter(|i| i.matches(&request)).collect();
if matches.len() > 1 {
return Box::new(async move {
request.into_body().collect().await?;
Err(wasmtime_wasi_http::Error::InternalError(Some(
"two credential items match".into(),
)))
});
}
if let Some(item) = matches.first() {
if !self.grant && !item.free {
let result = response(403, format!("grant required: {}", item.name));
return Box::new(async move {
request.into_body().collect().await?;
result
});
}
// Stand-in for the isolated JavaScript transform. Only the host owns the token.
let transform = |r: &mut http::Request<WasiBody>| {
r.headers_mut().insert(
http::header::AUTHORIZATION,
format!("Bearer {}", self.token).parse().unwrap(),
);
};
transform(&mut request);
}
self.outgoing.fetch_add(1, Ordering::SeqCst);
Box::new(async move {
// Hyper drops unknown-length GET bodies before the guest can finish its WASI stream.
// Complete the body first. Collected preserves its data and trailers.
let (parts, body) = request.into_parts();
let body = body.collect().await?.map_err(|never| match never {});
let request = http::Request::from_parts(parts, body);
let (res, io) = wasmtime_wasi_http::default_send_request(request, options).await?;
Ok((res.map(BodyExt::boxed_unsync), Box::new(io) as Io))
})
}
}
struct State {
ctx: WasiCtx,
table: ResourceTable,
http: WasiHttpCtx,
hooks: Hooks,
}
impl WasiView for State {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView {
ctx: &mut self.ctx,
table: &mut self.table,
}
}
}
impl WasiHttpView for State {
fn http(&mut self) -> WasiHttpCtxView<'_> {
WasiHttpCtxView {
ctx: &mut self.http,
table: &mut self.table,
hooks: &mut self.hooks,
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
let component_path = std::env::args().nth(1).expect("guest component path");
std::fs::create_dir_all("/tmp/s5-http/world")?;
std::fs::write("/tmp/s5-http/world/public.txt", "public fixture")?;
// A fresh host-only token is absent from guest source, argv, env, and preopened files.
let token = Arc::new(format!(
"s5-secret-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_nanos()
));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
let received = Arc::new(AtomicUsize::new(0));
let count = received.clone();
let server_token = token.clone();
let server = tokio::spawn(async move {
loop {
let (socket, _) = listener.accept().await.unwrap();
let count = count.clone();
let token = server_token.clone();
let service = service_fn(move |request: hyper::Request<hyper::body::Incoming>| {
let count = count.clone();
let token = token.clone();
async move {
let (parts, body) = request.into_parts();
// Read through the terminal chunk before answering and closing the connection.
body.collect().await.unwrap();
assert_eq!(parts.headers["x-guest"], "unchanged");
let text = match parts.headers.get(http::header::AUTHORIZATION) {
Some(value) => {
assert_eq!(value.to_str().unwrap(), format!("Bearer {}", token));
"authorization=present; token=correct; x-guest=unchanged"
}
None => "authorization=absent; x-guest=unchanged",
};
count.fetch_add(1, Ordering::SeqCst);
Ok::<_, std::convert::Infallible>(http::Response::new(Full::new(
Bytes::from_static(text.as_bytes()),
)))
}
});
http1::Builder::new()
.keep_alive(false)
.serve_connection(TokioIo::new(socket), service)
.await
.unwrap();
}
});
let items = Arc::new(vec![
Item {
name: "test-token",
scheme: "http",
hosts: vec!["127.0.0.1"],
prefixes: vec!["/private"],
methods: Some(vec![http::Method::GET]),
free: false,
},
Item {
name: "collision",
scheme: "http",
hosts: vec!["127.0.0.1"],
prefixes: vec!["/private/overlap"],
methods: None,
free: false,
},
Item {
name: "free-token",
scheme: "http",
hosts: vec!["127.0.0.1"],
prefixes: vec!["/free"],
methods: None,
free: true,
},
]);
let engine = Engine::default();
let component = Component::from_file(&engine, component_path)?;
let mut linker = Linker::<State>::new(&engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
let outgoing = Arc::new(AtomicUsize::new(0));
let cases = [
(
"identity-a",
"http://host.sausage/whoami".to_string(),
"GET",
false,
"caller=instance-1",
0,
),
(
"identity-b",
"http://host.sausage/whoami".to_string(),
"GET",
false,
"caller=instance-2",
0,
),
(
"granted",
format!("http://127.0.0.1:{port}/private"),
"GET",
true,
"authorization=present; token=correct",
1,
),
(
"unmatched",
format!("http://127.0.0.1:{port}/public"),
"GET",
false,
"authorization=absent",
1,
),
(
"denied",
format!("http://127.0.0.1:{port}/private"),
"GET",
false,
"status=403\nresponse-headers={}\nbody=grant required: test-token",
0,
),
(
"ambiguous",
format!("http://127.0.0.1:{port}/private/overlap"),
"GET",
true,
"two credential items match",
0,
),
(
"method-mismatch",
format!("http://127.0.0.1:{port}/private"),
"POST",
false,
"authorization=absent",
1,
),
(
"free-use",
format!("http://127.0.0.1:{port}/free"),
"POST",
false,
"authorization=present; token=correct",
1,
),
];
for (index, (name, url, method, grant, expected, sockets)) in cases.into_iter().enumerate() {
let before = outgoing.load(Ordering::SeqCst);
let stdout = wasmtime_wasi::p2::pipe::MemoryOutputPipe::new(16384);
let stderr = wasmtime_wasi::p2::pipe::MemoryOutputPipe::new(16384);
let mut ctx = WasiCtx::builder();
ctx.args(&["s5-curl", &url, method])
.env("PUBLIC", "visible")
.stdout(stdout.clone())
.stderr(stderr.clone());
ctx.preopened_dir("/tmp/s5-http/world", "/", wasmtime_wasi::FsPerms::ReadOnly)?;
let state = State {
ctx: ctx.build(),
table: ResourceTable::new(),
http: WasiHttpCtx::new(),
hooks: Hooks {
id: format!("instance-{}", index + 1),
grant,
token: token.clone(),
items: items.clone(),
outgoing: outgoing.clone(),
},
};
let mut store = Store::new(&engine, state);
let command = wasmtime_wasi::p2::bindings::Command::instantiate_async(
&mut store, &component, &linker,
)
.await?;
assert!(command.wasi_cli_run().call_run(&mut store).await?.is_ok());
let out = String::from_utf8(stdout.contents().to_vec())?;
let err = String::from_utf8(stderr.contents().to_vec())?;
assert!(
!out.contains(token.as_str()) && !err.contains(token.as_str()),
"secret leaked"
);
assert!(
out.contains("environment=[(\"PUBLIC\", \"visible\")]"),
"{out}"
);
assert!(out.contains("files=[\"public.txt\"]"), "{out}");
assert!(out.contains("file=public fixture"), "{out}");
assert!(
out.contains("request-headers={\"x-guest\": \"unchanged\"}"),
"{out}"
);
assert!(out.contains(expected), "{name}: {out}\n{err}");
assert_eq!(outgoing.load(Ordering::SeqCst) - before, sockets, "{name}");
println!("PASS {name}: {expected}; transport calls={sockets}; guest secret absent");
}
assert_eq!(received.load(Ordering::SeqCst), 4);
server.abort();
println!("PASS all 8 guest cases; local server received exactly 4 requests");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rule_dimensions() {
let item = Item {
name: "test",
scheme: "http",
hosts: vec!["*.example.test"],
prefixes: vec!["/api"],
methods: Some(vec![http::Method::GET]),
free: false,
};
for (url, method, expected) in [
("http://api.example.test/api/v1", "GET", true),
("https://api.example.test/api/v1", "GET", false),
("http://example.test/api/v1", "GET", false),
("http://api.example.test/public", "GET", false),
("http://api.example.test/api/v1", "POST", false),
] {
let body = Full::new(Bytes::new())
.map_err(|n| match n {})
.boxed_unsync();
let request = http::Request::builder()
.uri(url)
.method(method)
.body(body)
.unwrap();
assert_eq!(item.matches(&request), expected, "{url} {method}");
}
}
#[test]
fn host_patterns() {
assert!(host_matches("*.example.test", "api.example.test"));
assert!(host_matches("*.example.test", "a.b.example.test"));
assert!(!host_matches("*.example.test", "example.test"));
assert!(!host_matches("*.example.test", "badexample.test"));
assert!(host_matches("example.test", "example.test"));
assert!(!host_matches("example.test", "api.example.test"));
}
}
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env nu
# Verify the guest, rule dimensions, and all end-to-end HTTP cases.
def main [] {
^nix develop -c cargo build --manifest-path spikes/s5-http/guest/Cargo.toml --target wasm32-wasip2 -j 2
if $env.LAST_EXIT_CODE != 0 { error make {msg: "guest build failed"} }
^nix develop -c cargo test --manifest-path spikes/s5-http/Cargo.toml -j 2
if $env.LAST_EXIT_CODE != 0 { error make {msg: "rule tests failed"} }
^nix develop -c cargo run --manifest-path spikes/s5-http/Cargo.toml -j 2 -- spikes/s5-http/guest/target/wasm32-wasip2/debug/s5-curl.wasm
if $env.LAST_EXIT_CODE != 0 { error make {msg: "guest cases failed"} }
}
+61
View File
@@ -0,0 +1,61 @@
# S6: esbuild in WASI
This spike builds unpatched esbuild v0.27.7 for `wasip1/wasm`.
Status: BLOCKED. The module bundles and renders, but both tested adapters trap.
The test requires a bundle through both the module and the adapted component.
Node executes each minified bundle with LinkeDOM and checks the rendered elements.
The test also requires byte-identical output from native esbuild.
From the repository root:
```sh
nix develop -c nu spikes/s6-esbuild/build.nu
nix develop -c nu spikes/s6-esbuild/test.nu
```
Requirements: Go, Node, npm, Nushell, Git, curl, wasm-tools, and Wasmtime 49.0.2.
The repository devshell supplies wasm-tools and Wasmtime.
The recipe downloads the command adapter from the Wasmtime v49.0.2 release.
Both the shared adapter and the v49.0.2 adapter fail with this Go build.
The recipe stores sources, dependencies, binaries, and bundles in `/tmp/s6-esbuild/`.
It fetches npm dependencies natively, not inside WASI.
The benchmark reports process wall times, not internal compiler times.
It measures a first invocation and five repetitions with precompiled artifacts.
The first invocation can use Wasmtime's existing compilation cache.
The precompiled measurements exclude Cranelift compilation but include process startup and guest initialization.
Only use the locally generated `.cwasm` files with `--allow-precompiled`.
The WASI preopen maps the fixture to `/` with `--dir .::/`.
A relative guest preopen (`--dir .`) fails with Go's path resolution.
After the component blocker is resolved, probe optional CLI modes after the test:
```sh
nix develop -c nu spikes/s6-esbuild/limitations.nu
```
This fixture verifies a single entry point and no custom plugins.
It does not verify a browser, the Sausage VFS, dependency installation in WASI, or TypeScript type checks.
See `.board/T6.report.md` for measured limitations and results.
## Rework 1
The clock probe succeeds with clock IDs 0 and 1 at precision 0.
The Go probe also succeeds, even with additional initialization and allocation.
Run both probes with:
```sh
nix develop -c nu spikes/s6-esbuild/diagnose.nu
```
The adapter assertion checks a state-memory sentinel, not clock support.
An unstripped esbuild build also fails that assertion through `fd_write`.
This evidence suggests memory corruption rather than an unsupported clock ID.
It does not identify the writer.
`eager-state.experimental.patch` records an unverified adapter experiment.
The patch reserves adapter state before the guest starts.
Two attempts to build the adapter failed because of the local Rust target setup.
The recipe does not apply this patch.
The component bundle test and the precompiled benchmark remain blocked.
+8
View File
@@ -0,0 +1,8 @@
// This fixture checks TSX, package resolution, and browser rendering.
import { render } from 'preact';
const message: string = 'Hello from WASI';
function App() {
return <main><h1>{message}</h1><p>Preact + TypeScript</p></main>;
}
render(<App />, document.getElementById('app')!);
+29
View File
@@ -0,0 +1,29 @@
# Build unpatched esbuild v0.27.7 and adapt it into a WASI component.
def main [] {
let root = '/tmp/s6-esbuild'
let fixture = $env.FILE_PWD
mkdir $root
if not ($root | path join esbuild | path exists) {
^git clone --depth 1 --branch v0.27.7 https://github.com/evanw/esbuild.git ($root | path join esbuild)
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'clone failed'} }
}
cd ($root | path join esbuild)
with-env {GOOS: wasip1, GOARCH: wasm} {
^go build -p 2 -trimpath '-ldflags=-s -w' -o ($root | path join esbuild.wasm) ./cmd/esbuild
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'WASI build failed'} }
}
^go build -p 2 -trimpath '-ldflags=-s -w' -o ($root | path join esbuild-native) ./cmd/esbuild
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'native build failed'} }
let adapter = ($root | path join adapter-v49.wasm)
if not ($adapter | path exists) {
^curl -fL https://github.com/bytecodealliance/wasmtime/releases/download/v49.0.2/wasi_snapshot_preview1.command.wasm -o $adapter
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'adapter download failed'} }
}
^wasm-tools component new ($root | path join esbuild.wasm) --adapt $'wasi_snapshot_preview1=($adapter)' -o ($root | path join esbuild.component.wasm)
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'component adaptation failed'} }
mkdir ($root | path join app)
cp ($fixture | path join app.tsx) ($fixture | path join check.mjs) ($fixture | path join package.json) ($fixture | path join package-lock.json) ($root | path join app)
cd ($root | path join app)
^npm ci --ignore-scripts --no-audit --no-fund
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'npm ci failed'} }
}
+12
View File
@@ -0,0 +1,12 @@
// Execute the actual browser bundle against a small DOM implementation.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { parseHTML } from 'linkedom';
const { window, document } = parseHTML('<html><body><div id="app"></div></body></html>');
vm.runInNewContext(readFileSync(process.argv[2], 'utf8'), { window, document, console, setTimeout });
assert.equal(document.querySelector('h1')?.textContent, 'Hello from WASI');
assert.equal(document.querySelector('p')?.textContent, 'Preact + TypeScript');
assert.equal(document.querySelectorAll('main').length, 1);
console.log(`PASS render ${process.argv[2]}: ${document.getElementById('app').innerHTML}`);
+10
View File
@@ -0,0 +1,10 @@
;; Test the Go runtime's exact clock IDs and precision without a Go allocator.
(module
(import "wasi_snapshot_preview1" "clock_time_get"
(func $clock (param i32 i64 i32) (result i32)))
(memory (export "memory") 1)
(func (export "_start")
i32.const 0 i64.const 0 i32.const 0 call $clock
if unreachable end
i32.const 1 i64.const 0 i32.const 8 call $clock
if unreachable end))
+19
View File
@@ -0,0 +1,19 @@
# Compare Go initialization and exact runtime clock calls with the stock adapter.
def main [] {
let fixture = $env.FILE_PWD
let root = '/tmp/s6-esbuild'
with-env {GOOS: wasip1, GOARCH: wasm} {
^go build -o $'($root)/hello-http.wasm' ($fixture | path join hello.go)
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'Go probe build failed'} }
}
for probe in [
{input: $'($root)/hello-http.wasm', output: hello-http},
{input: ($fixture | path join clocks.wat), output: clocks}
] {
^wasm-tools component new $probe.input --adapt $'wasi_snapshot_preview1=($root)/adapter-v49.wasm' -o $'($root)/($probe.output).component.wasm'
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'probe adaptation failed'} }
^wasmtime run $'($root)/($probe.output).component.wasm'
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'probe execution failed'} }
print $'PASS stock adapter: ($probe.output)'
}
}
@@ -0,0 +1,14 @@
diff --git a/crates/wasi-preview1-component-adapter/src/lib.rs b/crates/wasi-preview1-component-adapter/src/lib.rs
index 7ce52c6..55a8480 100644
--- a/crates/wasi-preview1-component-adapter/src/lib.rs
+++ b/crates/wasi-preview1-component-adapter/src/lib.rs
@@ -129,6 +129,9 @@ pub extern "C" fn run() -> u32 {
unsafe extern "C" {
safe fn _start();
}
+ // Reserve adapter state before Go snapshots the linear-memory heap extent.
+ // Lazy memory.grow allocations otherwise overlap Go's sbrk allocator.
+ State::ptr();
_start();
0
}
+27
View File
@@ -0,0 +1,27 @@
// Minimal Go runtime initialization and clock probe for the adapter.
package main
import (
"fmt"
_ "net/http"
"time"
)
var blob [20 << 20]byte
var allocations [][]byte
func init() {
for i := 0; i < 20000; i++ {
allocations = append(allocations, make([]byte, 1024))
}
}
func main() {
blob[0] = 1
fmt.Println("Go WASI clock before allocation:", time.Now().UnixNano(), blob[0])
data := make([]byte, 32<<20)
for i := range data {
data[i] = 1
}
fmt.Println("Go WASI clock after allocation:", time.Now().UnixNano(), data[len(data)-1])
}
+15
View File
@@ -0,0 +1,15 @@
# Probe optional CLI modes after test.nu creates the precompiled component.
def main [] {
cd /tmp/s6-esbuild/app
for flags in [
['--serve=127.0.0.1:8000'],
['--watch=forever'],
['--plugins=example']
] {
print $'PROBE ($flags | str join " ")'
let result = (^wasmtime run -W timeout=2s --allow-precompiled --dir .::/ /tmp/s6-esbuild/component.cwasm app.tsx --bundle --jsx=automatic --jsx-import-source=preact --outfile=probe.js ...$flags | complete)
print $'exit=($result.exit_code)'
print $result.stdout
print $result.stderr
}
}
+210
View File
@@ -0,0 +1,210 @@
{
"name": "s6-esbuild-fixture",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "s6-esbuild-fixture",
"dependencies": {
"linkedom": "0.18.12",
"preact": "10.27.2"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/cssom": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz",
"integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==",
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
"license": "MIT"
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/linkedom": {
"version": "0.18.12",
"resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz",
"integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==",
"license": "ISC",
"dependencies": {
"css-select": "^5.1.0",
"cssom": "^0.5.0",
"html-escaper": "^3.0.3",
"htmlparser2": "^10.0.0",
"uhyphen": "^0.2.0"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"canvas": ">= 2"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/preact": {
"version": "10.27.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/uhyphen": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
"integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
"license": "ISC"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "s6-esbuild-fixture",
"private": true,
"type": "module",
"dependencies": {
"preact": "10.27.2",
"linkedom": "0.18.12"
}
}
+58
View File
@@ -0,0 +1,58 @@
# Verify both WASI forms, render their bundles, and compare process wall times.
use std/assert
def checked [program: string, args: list<string>] {
let start = date now
let result = (run-external $program ...$args | complete)
let ms = ((date now) - $start) / 1ms
if $result.exit_code != 0 {
print $result.stdout
print --stderr $result.stderr
error make {msg: $'Failed: ($program) ($args | str join " ")'}
}
{ms: $ms, stdout: ($result.stdout | str trim)}
}
def main [] {
let root = '/tmp/s6-esbuild'
cd ($root | path join app)
^go version
^wasmtime --version
let flags = ['app.tsx' '--bundle' '--minify' '--format=iife' '--platform=browser' '--jsx=automatic' '--jsx-import-source=preact' '--legal-comments=none' '--log-level=error']
let variants = [
{name: native, program: $'($root)/esbuild-native', prefix: []},
{name: module, program: wasmtime, prefix: [run --dir .::/ $'($root)/esbuild.wasm']},
{name: component, program: wasmtime, prefix: [run --dir .::/ $'($root)/esbuild.component.wasm']}
]
for variant in $variants {
let version = checked $variant.program ($variant.prefix | append '--version')
assert equal $version.stdout '0.27.7'
print $'PASS ($variant.name) version=($version.stdout) first_startup_ms=($version.ms)'
let result = checked $variant.program ($variant.prefix | append $flags | append $'--outfile=($variant.name).js')
print $'PASS ($variant.name) first_bundle_ms=($result.ms)'
let render = checked node [check.mjs $'($variant.name).js']
print $render.stdout
}
assert equal (open --raw native.js) (open --raw module.js)
assert equal (open --raw native.js) (open --raw component.js)
print $'PASS byte-identical native/module/component bundles: ((ls component.js).size.0)'
for name in [esbuild-native esbuild.wasm esbuild.component.wasm] {
let size = (ls ($root | path join $name)).size.0
print $'SIZE ($name): ($size | into int) bytes'
}
# Precompiled artifacts model the host cache without including Cranelift compilation.
for name in [module component] {
let input = if $name == module { 'esbuild.wasm' } else { 'esbuild.component.wasm' }
let compile = checked wasmtime [compile $'($root)/($input)' -o $'($root)/($name).cwasm']
print $'COMPILE ($name): ($compile.ms) ms'
}
for variant in [
{name: native, program: $'($root)/esbuild-native', prefix: []},
{name: module-precompiled, program: wasmtime, prefix: [run --allow-precompiled --dir .::/ $'($root)/module.cwasm']},
{name: component-precompiled, program: wasmtime, prefix: [run --allow-precompiled --dir .::/ $'($root)/component.cwasm']}
] {
let startup = (1..5 | each { (checked $variant.program ($variant.prefix | append '--version')).ms })
let bundle = (1..5 | each { (checked $variant.program ($variant.prefix | append $flags | append '--outfile=bench.js')).ms })
print $'BENCH ($variant.name) median_ms n=5: startup=($startup | math median) bundle=($bundle | math median)'
}
print 'PASS all acceptance checks'
}