host: process table, shell, coreutils and harness (T8, T9)

T8: sausage:process WIT (spawn, wait, kill), one store per process over the
shared root, /proc/processes, /run, pipeline and cancellation groups, Exec.
brush and 78 uutils commands; the shared cwd-init object sets the cwd from PWD
and keeps numeric exit codes. The corpus matches native bash.

T9: V8 (deno_core) runs pi-durable, pi-ai and chord; SQLite storage in
<dir>/host/harness.sqlite (WAL, synchronous=FULL); file tools on the VFS, bash
through Exec; resumes after SIGKILL.
This commit is contained in:
2026-11-06 16:34:34 +00:00
parent 5ce2a2f2bd
commit 57a47b069e
41 changed files with 87601 additions and 82 deletions
+1
View File
@@ -0,0 +1 @@
node_modules/
+32
View File
@@ -0,0 +1,32 @@
# Host JavaScript
The console uses pi-durable in V8. Rust owns the SQLite connection.
The console runs V8 on a dedicated current-thread Tokio runtime.
This satisfies Deno's runtime requirement without a change to the CLI runtime.
The database lives at `<dir>/host/harness.sqlite`, outside the world.
WAL preserves atomic transactions after SIGKILL. `synchronous=FULL` flushes each commit before SQLite reports success.
The coding tools access the shared VFS through Rust operations.
The shell sends commands to the host's `Exec` implementation.
The adapter supplies the filesystem operations that the four coding tools use.
It does not implement the other portable filesystem capabilities or shell spill files.
The console currently uses an explicit unavailable stub. Host integration can call `run_with_exec`.
## Reproduce the bundles
```sh
npm ci --prefix host/js
npm run --prefix host/js bundle
CARGO_TARGET_DIR=target/harness nix develop -c cargo test -p sausage --test harness
```
The lock file pins npm dependencies. The checked-in bundles let Cargo build without npm or network access for JavaScript.
Cargo still needs its normal Rust dependencies and the V8 archive.
For Nix, use a separate npm derivation with `importNpmLock` and the checked-in lock file.
Run the bundle script there. Pass its output to the Rust source derivation before Crane builds the host.
The Rust build must not execute npm or download JavaScript packages.
The console reads `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` from the host environment.
It selects `openai/gpt-6.1-sol` with low reasoning effort.
This model rejects disabled reasoning. Tests use the faux provider without credentials.
+27
View File
@@ -0,0 +1,27 @@
// Install Deno's native Web APIs. No Node runtime is present.
const core = Deno.core;
const expose = (path, names) => {
const exports = core.loadExtScript(path);
for (const name of names) globalThis[name] = exports[name];
};
expose('ext:deno_web/01_dom_exception.js', ['DOMException']);
expose('ext:deno_web/02_event.js', ['Event', 'EventTarget', 'MessageEvent']);
expose('ext:deno_web/13_message_port.js', ['MessageChannel', 'MessagePort']);
expose('ext:deno_web/00_url.js', ['URL', 'URLSearchParams']);
expose('ext:deno_web/02_timers.js', ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval']);
expose('ext:deno_web/03_abort_signal.js', ['AbortController', 'AbortSignal']);
expose('ext:deno_web/06_streams.js', ['ReadableStream', 'WritableStream', 'TransformStream']);
expose('ext:deno_web/08_text_encoding.js', ['TextEncoder', 'TextDecoder']);
expose('ext:deno_web/09_file.js', ['Blob', 'File']);
expose('ext:deno_web/15_performance.js', ['performance']);
expose('ext:deno_web/02_structured_clone.js', ['structuredClone']);
expose('ext:deno_web/05_base64.js', ['atob', 'btoa']);
expose('ext:deno_crypto/00_crypto.js', ['crypto']);
expose('ext:deno_fetch/20_headers.js', ['Headers']);
expose('ext:deno_fetch/21_formdata.js', ['FormData']);
expose('ext:deno_fetch/23_request.js', ['Request']);
expose('ext:deno_fetch/23_response.js', ['Response']);
expose('ext:deno_fetch/26_fetch.js', ['fetch']);
const { Console } = core.loadExtScript('ext:deno_web/01_console.js');
globalThis.console = new Console((message, level) => core.print(message, level > 1));
globalThis.process = { env: {} };
+44488
View File
File diff suppressed because one or more lines are too long
+87
View File
@@ -0,0 +1,87 @@
// The coding tools see only world paths. Rust owns the VFS and process execution.
import { LineScanner } from '@earendil-works/pi-durable/env';
const ops = Deno.core.ops;
const ok = (value: any) => ({ ok: true, value });
const path = (cwd: string, input: string) => {
const parts: string[] = [];
for (const p of (input.startsWith('/') ? input : `${cwd}/${input}`).split('/')) {
if (p === '..') parts.pop();
else if (p && p !== '.') parts.push(p);
}
return '/' + parts.join('/');
};
export function environment(cwd = '/') {
const call = (kind: string, p: string, bytes: number[] = []) => ops.op_file(kind, path(cwd, p), bytes);
const env: any = {
id: 'world', cwd,
absolutePath: async (p: string) => ok(path(cwd, p)),
canonicalPath: async () => ({ ok: false, error: { code: 'not_supported', message: 'The VFS does not expose canonical paths' } }),
joinPath: async (parts: string[]) => ok(path(cwd, parts.join('/'))),
fileInfo: (p: string) => call('stat', p),
exists: async (p: string) => {
const result = await call('stat', p);
return result.ok ? ok(true) : result.error.code === 'not_found' ? ok(false) : result;
},
readBinaryFile: async (p: string) => {
const r = await call('read', p);
return r.ok ? ok(new Uint8Array(r.value)) : r;
},
readTextFile: async (p: string) => {
const r = await env.readBinaryFile(p);
return r.ok ? ok(new TextDecoder().decode(r.value)) : r;
},
writeFile: async (p: string, content: string | Uint8Array) => {
const full = path(cwd, p);
const parent = full.slice(0, full.lastIndexOf('/')) || '/';
const created = await env.createDir(parent, { recursive: true });
if (!created.ok) return created;
return call('write', full, Array.from(typeof content === 'string' ? new TextEncoder().encode(content) : content));
},
createDir: async (p: string, options: any) => {
const full = path(cwd, p);
if (!options?.recursive) return call('mkdir', full);
let current = '';
for (const part of full.split('/').filter(Boolean)) {
current += '/' + part;
const exists = await call('stat', current);
if (!exists.ok) { const r = await call('mkdir', current); if (!r.ok) return r; }
}
return ok(undefined);
},
openBinaryReader: async (p: string) => {
const data = await env.readBinaryFile(p);
if (!data.ok) return data;
const info = await env.fileInfo(p);
return ok({
info: async () => info,
read: async (offset: number, length: number) => ok(data.value.slice(offset, offset + length)),
scanLines: async (options: any) => {
const scanner = new LineScanner(options.startLine, options.endLine);
scanner.push(data.value);
return ok(scanner.finish());
},
close: async () => {},
});
},
exec: async (command: any, options: any, context: any) => {
try {
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout);
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
let result;
while (true) {
const chunk = await ops.op_exec_next(rid);
if (!chunk.stream) { result = chunk; break; }
options?.onOutput?.(decoders[chunk.stream].decode(new Uint8Array(chunk.bytes), { stream: true }), context, { stream: chunk.stream });
}
if (result.error) throw new Error(result.error);
for (const stream of ['stdout', 'stderr']) {
const text = decoders[stream].decode();
if (text) options?.onOutput?.(text, context, { stream });
}
return ok({ exitCode: result.exitCode });
} catch (error) { return { ok: false, error }; }
},
cleanup: async () => {},
};
return env;
}
+72
View File
@@ -0,0 +1,72 @@
// Durable root conversation and console. SQLite stays outside the world.
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
import { createModels } from '@earendil-works/pi-ai/models';
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
import { Harness, createRegistry } from '@earendil-works/pi-durable';
import { CodingTools } from '@earendil-works/pi-durable/tools';
import { openStorage } from './sqlite';
import { environment } from './environment';
if (!process.env.PI_PROXY_BASE_URL || !process.env.PI_PROXY_API_KEY) {
throw new Error('PI_PROXY_BASE_URL and PI_PROXY_API_KEY are required');
}
const models = createModels();
const provider = 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'],
thinkingLevelMap: { off: null, minimal: null },
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 4096,
};
models.setProvider({ ...provider, baseUrl: model.baseUrl, getModels: () => [model],
auth: { apiKey: { name: 'Sausage proxy', resolve: async () => ({
auth: { apiKey: process.env.PI_PROXY_API_KEY, baseUrl: model.baseUrl,
headers: { 'Shopify-Usage-Tag': JSON.stringify(['pi', 'sausage']) } },
source: 'PI_PROXY_API_KEY',
}) } },
});
const registry = createRegistry();
registry.install(CodingTools);
const harness = await Harness.open(await openStorage(globalThis.storagePath), {
models, registry, env: (target: any) => environment(target.cwd ?? '/'),
}, context);
const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/', extensions: [CodingTools] } });
const watch = await root.watch(context);
const emitted = new Map<number, string>();
let partial = '';
const text = (message: any) => message?.content?.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('') ?? '';
const render = (view: any) => {
for (const entry of view.entries) {
if (entry.kind !== 'pi.assistant') continue;
const value = text(entry.model?.[0]);
if (emitted.has(entry.id)) continue;
if (value.startsWith(partial)) Deno.core.ops.op_output(value.slice(partial.length));
else Deno.core.ops.op_output(value);
partial = '';
emitted.set(entry.id, value);
}
const live = text(view.docs['pi.live']?.generation?.message);
if (live.startsWith(partial)) Deno.core.ops.op_output(live.slice(partial.length));
partial = live;
};
// Do not replay the historical transcript to the console.
for (const entry of watch.value.entries) {
if (entry.kind === 'pi.assistant') emitted.set(entry.id, text(entry.model?.[0]));
}
watch.start((event: any) => { if (event.kind === 'value') render(event.value); });
harness.resume();
await harness.waitForIdle(context);
while (true) {
const line = await Deno.core.ops.op_input();
if (line === null) break;
if (!line.trim()) continue;
const submission = await root.submit({ type: 'input', content: line, requestId: crypto.randomUUID() }, context);
const result = await submission.wait(context);
await harness.waitForIdle(context);
const final = await root.watch(context);
render(final.value);
await final.stop();
Deno.core.ops.op_output('\n');
if (result.status !== 'done') throw new Error(`Run ended: ${JSON.stringify(result)}`);
}
await watch.stop();
await harness.close(context);
+1687
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
{
"private": true,
"type": "module",
"scripts": { "bundle": "esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js" },
"dependencies": {
"@earendil-works/pi-durable": "1.5.0",
"@earendil-works/pi-ai": "1.5.0",
"@earendil-works/chord": "1.5.0"
},
"devDependencies": { "esbuild": "0.28.2" }
}
+33
View File
@@ -0,0 +1,33 @@
// Portable SQLite facade. Rust owns the connection and executes every statement.
import { SqliteStorage } from '@earendil-works/pi-durable/storage/sqlite';
const op = (kind: string, sql = '', params: any[] = []) => Deno.core.ops.op_sql(kind, sql, params.map(p => p instanceof Uint8Array ? Array.from(p) : typeof p === 'bigint' ? Number(p) : p));
export async function openStorage(path: string) {
op('open', path);
let tail = Promise.resolve();
const queue = <T>(call: () => Promise<T>): Promise<T> => {
const result = tail.then(call);
tail = result.then(() => {}, () => {});
return result;
};
const executor = {
exec: async (sql: string) => { op('exec', sql); },
run: async (sql: string, ...params: any[]) => { op('run', sql, params); },
runCounted: async (sql: string, ...params: any[]) => op('run', sql, params).changes,
get: async (sql: string, ...params: any[]) => (await executor.all(sql, ...params))[0],
all: async (sql: string, ...params: any[]) => op('all', sql, params).map((row: any) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, Array.isArray(value) ? new Uint8Array(value) : value]))),
};
const database = Object.fromEntries(Object.entries(executor).map(([name, method]) => [name, (...args: any[]) => queue(() => method(...args))]));
database.transaction = (callback: any) => queue(async () => {
op('exec', 'BEGIN IMMEDIATE');
try {
const result = await callback(executor);
op('exec', 'COMMIT');
return result;
} catch (error) {
op('exec', 'ROLLBACK');
throw error;
}
});
database.close = () => queue(async () => { op('close'); });
return SqliteStorage.open(database as any);
}
+22288
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
// Faux-provider integration test. The Rust test checks VFS effects and Exec arguments.
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
import { createModels } from '@earendil-works/pi-ai/models';
import { fauxProvider, fauxAssistantMessage, fauxToolCall, fauxText } from '@earendil-works/pi-ai/providers/faux';
import { Harness, createRegistry } from '@earendil-works/pi-durable';
import { CodingTools } from '@earendil-works/pi-durable/tools';
import { environment } from './environment';
import { openStorage } from './sqlite';
const models = createModels();
const crash = globalThis.testCrash;
const faux = fauxProvider({ tokensPerSecond: crash === 'model' ? 1 : 100000 });
models.setProvider(faux.provider);
faux.setResponses([
fauxAssistantMessage([fauxToolCall('write', { path: '/test.txt', content: 'before\n' }, { id: 'write' })], { stopReason: 'toolUse' }),
fauxAssistantMessage([fauxToolCall('read', { path: '/test.txt' }, { id: 'read' })], { stopReason: 'toolUse' }),
fauxAssistantMessage([fauxToolCall('edit', { path: '/test.txt', edits: [{ oldText: 'before', newText: 'after' }] }, { id: 'edit' })], { stopReason: 'toolUse' }),
fauxAssistantMessage([fauxToolCall('bash', { command: 'echo test' }, { id: 'bash' })], { stopReason: 'toolUse' }),
fauxAssistantMessage([fauxText('done')]),
]);
if (crash) {
faux.setResponses(crash === 'tool' ? [
fauxAssistantMessage([fauxToolCall('bash', { command: 'echo test' }, { id: 'crash' })], { stopReason: 'toolUse' }),
] : crash === 'model' ? [
fauxAssistantMessage([fauxText('A long response that the test interrupts before it completes.')]),
] : [fauxAssistantMessage([fauxText('resumed done')])]);
}
const registry = createRegistry();
registry.install(CodingTools);
const harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry, env: () => environment('/') }, context);
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, extensions: [CodingTools], cwd: '/' } });
if (crash === 'model') {
const progress = await root.watch(context);
let ready = false;
progress.start((event: any) => {
if (event.kind !== 'value' || ready) return;
const message = event.value.docs['pi.live']?.generation?.message;
if (message?.content?.some((block: any) => block.type === 'text' && block.text.length > 0)) {
ready = true;
console.log('CRASH_READY');
}
});
}
harness.resume();
const result = await (await root.submit({ type: 'input', content: 'Exercise tools', requestId: 'test' }, context)).wait(context);
if (result.status !== 'done') throw new Error(JSON.stringify(result));
await harness.waitForIdle(context);
const watch = await root.watch(context);
const transcript = JSON.stringify(watch.value.entries);
if (!crash && transcript.includes('"isError":true')) throw new Error(transcript);
for (const expected of crash ? ['resumed done'] : ['before', 'after', 'streamed stdout', 'streamed stderr', 'done']) {
if (!transcript.includes(expected)) throw new Error(`Missing ${expected}: ${transcript}`);
}
await watch.stop();
await harness.close(context);