@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
275 lines • 10.6 kB
JavaScript
import { resolvePath } from "../../../../utils/path.js";
import { classify } from "../../../../errors/index.js";
import { PipeClosed } from "../../../../shell/errors.js";
import { PathSpec } from "../../../../types.js";
import { decodeBase64, encodeBase64 } from "../../../../utils/base64.js";
async function within(done, seconds) {
if (seconds === undefined || seconds === null) {
await done;
return true;
}
let timer;
try {
return await Promise.race([
done.then(() => true),
new Promise((resolve) => {
timer = setTimeout(() => {
resolve(false);
}, Math.max(0, seconds * 1000));
}),
]);
}
finally {
if (timer !== undefined)
clearTimeout(timer);
}
}
function encoded(chunks) {
const bytes = new Uint8Array(chunks.reduce((n, chunk) => n + chunk.length, 0));
let at = 0;
for (const chunk of chunks) {
bytes.set(chunk, at);
at += chunk.length;
}
return encodeBase64(bytes);
}
class GuestChild {
child;
request;
readers;
chunks = { stdout: [], stderr: [] };
inherited = { stdout: [], stderr: [] };
background = [];
communication = null;
killed = 0;
constructor(child, request) {
this.child = child;
this.request = request;
this.readers = {
stdout: child.stdout[Symbol.asyncIterator](),
stderr: child.stderr[Symbol.asyncIterator](),
};
for (const stream of ['stdout', 'stderr']) {
if (request[stream] !== -1)
this.background.push(this.drain(stream, request[stream] === 0 ? this.inherited[stream] : null));
}
if (request.stdin !== -1)
this.background.push(this.feed(decodeBase64(request.data ?? '')));
}
async feed(data) {
try {
await this.child.stdin.write(data);
}
catch (error) {
if (!(error instanceof PipeClosed))
throw error;
}
finally {
this.child.stdin.close();
}
}
async drain(stream, target) {
for (;;) {
const item = await this.readers[stream].next();
if (item.done)
return;
target?.push(item.value);
}
}
code() {
const code = this.child.poll();
return code === null ? null : this.killed ? -this.killed : code;
}
communicate(data) {
if (this.communication === null) {
const work = [...this.background, this.child.wait()];
if (this.request.stdin === -1)
work.push(this.feed(decodeBase64(data ?? '')));
for (const stream of ['stdout', 'stderr'])
if (this.request[stream] === -1)
work.push(this.drain(stream, this.chunks[stream]));
this.communication = Promise.all(work);
}
else if (data !== undefined)
throw new Error('cannot send input after communication started');
return this.communication;
}
}
/** Invocation-owned capabilities. A guest cannot attach to another profile's PID. */
export class GuestProcessTable {
ctx;
files;
children = new Map();
closed = false;
owned = new Set();
/**
* @param ctx the run's context, whose processes the guest's children join.
* @param files the run's file adapter, which finds a program to spawn.
*/
constructor(ctx, files) {
this.ctx = ctx;
this.files = files;
}
async call(payload) {
try {
const request = JSON.parse(payload);
const value = await this.operation(request);
const inherited = { stdout: [], stderr: [] };
for (const entry of this.children.values()) {
for (const stream of ['stdout', 'stderr'])
for (const item of entry.inherited[stream].splice(0))
inherited[stream].push(item);
}
const consumed = Object.fromEntries([...this.children].map(([pid, entry]) => [pid, entry.child.stdin.bytesRead]));
return JSON.stringify({
...value,
consumed,
inherited_stdout: encoded(inherited.stdout),
inherited_stderr: encoded(inherited.stderr),
});
}
catch (error) {
const code = error instanceof PipeClosed
? 'EPIPE'
: error.code === 'EAGAIN'
? 'EAGAIN'
: classify(error);
return JSON.stringify({
error: error instanceof Error ? error.message : String(error),
code: code ?? 'EIO',
});
}
}
async operation(request) {
if (request.op === 'finish') {
await this.close();
return {};
}
if (request.op === 'resolve' || request.op === 'spawn') {
if (request.op === 'spawn')
this.ctx.processes?.checkSpawn();
const files = this.files;
const head = request.argv?.[0];
if (typeof head !== 'string' || !head)
throw Object.assign(new Error('empty executable'), { code: 'ENOENT' });
const cwd = request.cwd ?? '/';
const paths = head.includes('/')
? [resolvePath(head, cwd)]
: (request.env?.PATH ?? '/usr/bin')
.split(':')
.map((dir) => resolvePath(`${dir || '.'}/${head}`, cwd));
let found = null;
let denied = false;
for (const path of paths) {
try {
const stat = await files.stat(path);
if (!stat.isDir && (stat.mode & 0o111) !== 0) {
found = path;
break;
}
denied = true;
}
catch (error) {
const code = classify(error);
if (code === 'EACCES' || code === 'EPERM')
denied = true;
else if (code !== 'ENOENT' && code !== 'ENOTDIR')
throw error;
}
}
if (request.op === 'resolve')
return { path: found };
if (found === null)
throw Object.assign(new Error(`executable not found: ${head}`), {
code: denied ? 'EACCES' : 'ENOENT',
});
const directory = await files.stat(cwd);
if (!directory.isDir)
throw Object.assign(new Error(`not a directory: ${cwd}`), { code: 'ENOTDIR' });
request.argv = [found, ...(request.argv ?? []).slice(1)];
if (this.ctx.processes?.spawn === undefined)
throw new Error('runtime has no process spawn entry point');
if (!Array.isArray(request.argv) ||
request.argv.length === 0 ||
!request.argv.every((arg) => typeof arg === 'string' && !arg.includes('\0')))
throw new Error('invalid argv');
if (typeof request.cwd !== 'string' || !request.cwd.startsWith('/'))
throw new Error('invalid cwd');
if (this.closed)
throw new Error('guest invocation has ended');
const child = this.ctx.processes.spawn({
argv: request.argv,
cwd: PathSpec.fromStrPath(request.cwd),
env: request.env ?? {},
replaceEnv: true,
mergeStderr: request.stderr === -2,
});
this.owned.add(child);
const entry = new GuestChild(child, request);
this.children.set(child.pid, entry);
return { pid: child.pid, returncode: entry.code() };
}
const entry = this.children.get(request.pid ?? -1);
if (entry === undefined)
throw new Error('unknown subprocess handle');
const { child } = entry;
switch (request.op) {
case 'release':
if (child.poll() === null)
throw new Error('cannot release a live child');
this.children.delete(child.pid);
return {};
case 'poll':
return { returncode: entry.code() };
case 'kill':
if (child.poll() === null) {
entry.killed = request.signal === 15 ? 15 : 9;
child.terminate(entry.killed);
}
return { returncode: entry.code() };
case 'close':
if (request.stream === undefined)
child.stdin.close();
else
child.closeOutput(request.stream);
return {};
case 'write':
await child.stdin.write(decodeBase64(request.data ?? ''));
return {};
case 'read': {
const stream = request.stream;
if (stream !== 'stdout' && stream !== 'stderr')
throw new Error('invalid stream');
if (entry.request[stream] !== -1 || entry.communication !== null)
throw new Error('pipe is not readable');
const chunk = await entry.readers[stream].next();
return { data: chunk.done ? '' : encodeBase64(chunk.value) };
}
case 'wait': {
const complete = await within(Promise.all([child.wait(), ...entry.background]), request.timeout);
return { timeout: !complete, returncode: entry.code() };
}
case 'communicate': {
const complete = await within(entry.communicate(request.data), request.timeout);
return {
timeout: !complete,
returncode: entry.code(),
stdout: encoded(entry.chunks.stdout),
stderr: encoded(entry.chunks.stderr),
};
}
}
}
async close() {
this.closed = true;
for (const child of this.owned)
child.terminate();
await Promise.all([...this.children.values()].map(async (entry) => {
await entry.child.wait();
await Promise.all(entry.background);
if (entry.communication !== null)
await entry.communication;
}));
}
}
//# sourceMappingURL=process.js.map