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:
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
Generated
+5948
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
@@ -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.
|
||||
Vendored
+27
@@ -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: {} };
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Cold-start probe: the host installed all Web APIs before this script executes.
|
||||
console.log('V8 ready');
|
||||
@@ -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'} }
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/target/
|
||||
/userland/target/
|
||||
Generated
+2472
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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'}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Generated
+1505
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'} }
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)'
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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)'
|
||||
}
|
||||
Generated
+7
@@ -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"
|
||||
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "builder"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
Generated
+75
@@ -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"
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "namespace-probe"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, ®istry, &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", ®istry))?;
|
||||
|
||||
// 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(®istry)
|
||||
.arg(®istry)
|
||||
.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(®istry)
|
||||
.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]))
|
||||
}
|
||||
Generated
+2746
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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.
|
||||
Generated
+299
@@ -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"
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "s5-curl"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
wstd = "=0.6.8"
|
||||
anyhow = "1"
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"} }
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"} }
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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')!);
|
||||
@@ -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'} }
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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))
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Generated
+210
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "s6-esbuild-fixture",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"preact": "10.27.2",
|
||||
"linkedom": "0.18.12"
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user