mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 16:26:36 +00:00
* fix(desktop): release a capture when its start is cancelled after launch Cancelling start_build_capture during the opened app's screenshot left the capture running, so the next start was refused. The capture is now kept for the owner's cleanup, and the next start stops it first. * fix(desktop): keep a published witness error and tag an app data refusal A second failure to close a published witness returns the original error, so its record and cleanup lease stay bound. A reset the app itself refuses is an AppDataRefusal; only a reply that is not that refusal stays unsettled. A missing child of a cut view is undecided with the same description in both evaluators, and the shared cases cover it. * fix(desktop): align a cut view's missing text with the Python check A missing child of a truncated view is undecided for the same sentence in both evaluators, including the pointer the view does not contain. * fix(desktop): keep one published witness cleanup across retries A published record and its bound cleanup error stay together. A later close failure returns that error, and the unused path that built a fresh adapter error is gone. * fix(desktop): keep a bundle task read inside the witness result A cancelled, refused, or failed read of the bundle's task files returns the witness's Result, including a cleanup lease when a handle would not close. * test(desktop): pass the settle timing when reading a witness bundle The witness takes the settle timing before the bundle path, so the bundle-read tests keep the path in its own argument. * fix(desktop): bound a guest file answer and split a long controller argument A data/get whose base64 answer would exceed a quarter of the V8 heap is refused before it is read. Controller JSON that exceeds Linux's single-argument limit travels as several arguments the controller joins. * fix(desktop): keep an underreported guest file from raising its read A data/get answer is admitted only when its base64 body fits in the heap quarter after the copies held during parse. Decoded bytes larger than the computer reported are dropped. * fix(desktop): grade shipped seeds and keep selected settings and sqlite logs Seed checks follow the files the bundle ships, so an omitted lock is absent. An incomplete checkpoint is not reported as no change. A selected link keeps its spelling, and a database's wal and journal stay supporting state. Closing the fake computer stops a detached capture. * fix(desktop): keep added-record checks off ids, dates, and joined timestamps A position-named record's fields keep their paths, so an identifier, a stamped date, and text past the limit are not suggested checks. A whole-text check does not climb into an ancestor whose text joins a timestamp. * fix(desktop): bound home fetches and keep unsettled edits readable Split home-folder reads by the guest answer budget, keep a capacity error from the capture output, and drop the superseded task-argument refusal. Grade kept text per line, leave unretained dconf unread, and reserve by-position notes for XML children. Map a linked database's sidecars through the selected spelling and stop a detached capture only when its start identity still owns the pid. * fix(desktop): describe unretained dconf as unread The key comparison treats a missing copy as unknown. The comment still said that copy counted as an empty key list. * test(desktop): keep a capture capacity error through endedCapture An output past the guest answer ceiling stays the resource constraint, with its heap recovery and non-retryable status, instead of a generic capture-exit message. * fix(desktop): keep nested save times and formatting runs out of checks A field check drops save-time leaves before it joins the field, so a nested timestamp is not part of the suggested text. A text check beside a save time uses the paragraph when the diff names a formatting run. * test(desktop): read paragraph pointers from checks that have them * test(desktop): expect a large task to export intact The 96 KiB refusal is gone. A prompt past that size reaches the computer and comes back unchanged, instead of failing before export. * fix(desktop): keep real edits that save-side heuristics were dropping Repeated unnamed fields stay content checks. A formula that still has text is not renumbered with its ExprID. One gained style attribute is the edit; a repeated fill-in stays upkeep. Phone numbers and plain clock times stay text, and a decimal is the element's whole text. An older verifier matches on the task it states, not its evaluator source. * fix(desktop): cover repeated records and keep timestamps out of checks An added record whose words are already in the seed still gets the list's text when that text fits. Readiness is a start reason, so a final check names seed locks without matching the message. Field text split by a timestamp stays separate, and a timestamp line is not offered as a guard. * fix(desktop): compare a cell's joined decimal within tolerance The whole-element check keeps numeric tolerance, so 437.80 and a long float still match 437.8, including when a run splits the digits. A different number or other text does not. Both evaluators read an element's text for that comparison. * fix(desktop): grade a number from a complete XML element only A tolerance check joins an element's text only in an XML file or an XML archive entry, and leaves that check undecided when the view is cut, so a dropped descendant cannot turn the kept runs into a passing number. A JSON object with the same shape stays the value itself. Equivalent decimals and a split run on a whole cell still match. * fix(desktop): accept an older grader only when its index helper agrees The witness still matches a bundle whose evaluator is this REA's, or the older index expression alone when no path segment is a leading-zero number. A truncated verifier, a changed grader, or that older helper beside a segment such as 01 no longer counts as the same task. * fix(desktop): require the added record and leave save times out of list checks A record a selector names is checked itself, so its words have to be in that record. When an unnamed record only repeats words the seed already has, a list that holds a save time is checked by the text around that time. A later save of the same records still passes, and a missing or rewritten record fails. * fix(desktop): judge the older index helper by pointer segments only A leading zero in a file name or a URL does not change how the older index helper reads a pointer. The witness still rejects that helper when a pointer segment is a number such as 01. * fix(desktop): do not suggest list text the seed already passes A repeated record is shown by text the seed does not hold. The seed's own text is not a check. A pointer inside a check value is not a pointer segment for the older index helper. * fix(desktop): keep a linked settings file in the folder the link points at A selected ~/.config alias no longer moves MuseScore settings to that alias. The seed keeps settings/MuseScore or Documents, which is the path the export opens the note from. * fix(desktop): split home fetches by answer size and keep a size refusal A batch of tiny files stops before each path's envelope exceeds the host answer budget. A capture output past that ceiling stays a resource error instead of becoming a generic exit. * fix(desktop): seed a selected link under the path the app uses The selected spelling wins, so ~/.config/MuseScore is not rewritten to the folder it points at and the note stays the one launched document. A database wal and journal follow that database. A launch argument is respelled the same way. Teardown signals a detached PID only when its start identity still matches. * fix(desktop): keep a witness's actions and an incomplete checkpoint A failed witness save retries the action log it first recorded. A deletion left unconfirmed between the seed and the end is not reported as no change. Windows packs npm through a shell, which can start npm.cmd. * fix(desktop): read verifier pointers from the parsed checks A brace inside a check value is not an object boundary. A leading-zero pointer after that value still rejects the older index helper. * fix(desktop): prefer the most specific home spelling and retarget launch files A selected parent directory no longer hides a settings folder walked through a link. A launch filename under that folder is replaced with the seeded file's absolute path. * fix(desktop): pack the build runtime through npm's JavaScript entry npm pack and npm ci no longer spawn npm.cmd through a shell, so a temporary directory whose path contains spaces stays one argument. * fix(desktop): spell launch settings and keep a database log exact A database log matches only that file, so a neighboring folder is not moved. A launch option and the working directory use the same home spelling as the seed. * fix(desktop): move a state database's logs with the database The write-ahead log and journal follow the spelling the database received, including a walked state link. A neighboring folder stays put. A separate --config argument uses that spelling too. * fix(desktop): keep a selected file ahead of a database-log guess A file selected as itself keeps that spelling even when its name ends in -journal. Relative launch arguments are resolved in the captured working directory before that directory moves. * fix(desktop): keep outside-home database logs and option values A selected database outside the home keeps its write-ahead log. Launch options are rewritten only when they name a captured file or settings path. * fix(desktop): follow a database mapping and own limitation text A selected database keeps its log when a parent folder moves or the file sits outside the home. Trash and seed-lock tests compare the owning notice with the paths, and launch options that are not captured paths stay as written. * fix(desktop): retarget a settings folder that holds captured files A nested profile directory is a captured path when a seeded file is inside it. An option value that only resolves under the home stays as written. * fix(desktop): keep a program word and a directory's neighbor The program after env stays the program even when a captured file sits in a folder of that name. A selected directory is not a database, so a neighboring file keeps the parent folder's spelling. * fix(desktop): record whether a selected path is a directory Capture stores the walked path's kind. A neighboring file of a selected directory keeps the parent folder's spelling even when that directory never changes and its children are not content roots. * fix(desktop): mark containing paths without a pairwise scan A recorded path marks its known ancestors as directories in one walk up the path, instead of comparing every recorded path with every other. * fix(desktop): keep a selected folder that is named like a database log A folder the caller selected stays a selected root when its name ends in -journal or -wal. A generated log beside a database file still does not. * fix(desktop): refuse a build command that exceeds the argument vector Splitting one argument still leaves the whole command under Linux's E2BIG limit. A request past that budget is refused before the process starts. * fix(desktop): remap a captured path in an env assignment The program word stays the program. A NAME=value assignment keeps its name and follows a captured path value when that path's spelling moves. * fix(desktop): keep oversized text out of draft guards A value or line longer than the draft's text bound is not copied into a guard, so one large frame cannot fill the task by itself. * fix(desktop): keep an empty env value empty An empty assignment is not a path. Resolving it named the working directory and rewrote the value when that directory's spelling moved. * fix(desktop): check build runtime export capability * fix(desktop): provide complete runtime repack command * fix(desktop): retain runtime probe failures * test(desktop): identify capture independently of schema preflight * fix(desktop): preserve document URLs and value formats * fix(desktop): bound added checks and omit noisy text guards * fix(desktop): preserve launch URLs and JSON draft values * fix(desktop): recognize captured file URL documents * fix(desktop): render captured file URLs for runtime home * fix(desktop): distinguish phone numbers from save metadata * fix(desktop): preserve launch URLs and user text * fix(desktop): preserve local file URL authority * fix(desktop): preserve local file URL authority * fix(desktop): render captured file URLs at launch * fix(desktop): classify numeric timestamps using field context * fix(desktop): classify numeric timestamps using field context * fix(desktop): retain context for generated draft values * fix(desktop): retain context for generated draft values * test(desktop): cover generated IDs in text guards * test(desktop): cover generated IDs in text guards * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): retain clean text in noisy record fields * style(desktop): format the noisy-record draft files with the pinned oxfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(desktop): move launch execution to boundary suite * fix(desktop): redact build schema failure output * refactor(desktop): normalize schema failure once * fix(desktop): omit unsafe schema parser snippets * fix(desktop): preserve draft change and selected path evidence * test(desktop): group retained dconf note regressions * fix(desktop): respect archive entry format in draft checks * test(desktop): separate unowned-file draft note cases * docs(desktop): clarify task checks before scratch export * fix(desktop): respell selected paths in file URLs * test(desktop): group captured document spelling cases * fix(desktop): bound draft preview serialization * fix(desktop): restore selected state aliases * fix(desktop): respect entry format in draft noise * test(desktop): match exported alias evidence at its JSON boundary * style: fix desktop launch test formatting * style: format native command path schema * fix(desktop): map selected external launch paths * style(desktop): format seed document regressions * test(desktop): group launch URL regressions * fix(desktop): omit redundant nested state aliases * fix(desktop): compare added free text as a whole * fix(desktop): check recompressed seeds as shipped * fix(desktop): map captured launch paths once * refactor: remove unused whole-text guard state * refactor: stop passing unused text children * test: align free-text checks with whole-note policy * fix: validate recompressed desktop archive seeds * style(desktop): format archive seed regression * fix(desktop): redact runtime schema errors * fix(desktop): normalize captured file URLs * fix(desktop): report seeded files by capture path * fix(desktop): retain startup readiness failure stage * fix(desktop): retain JSON date checks * fix(desktop): preserve archive formatting checks * fix(desktop): preserve explicitly selected journal files * fix(desktop): report build command transport limits * style(desktop): format capture review fixes --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
782 lines
27 KiB
TypeScript
782 lines
27 KiB
TypeScript
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
|
|
import {
|
|
observeProcessStartIdentity,
|
|
signalProcessWithStartIdentity,
|
|
} from "../../src/process/ProcessOwnershipObservation.js";
|
|
import { createHash } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import {
|
|
access,
|
|
chmod,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
stat,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import {
|
|
createServer,
|
|
type IncomingMessage,
|
|
type ServerResponse,
|
|
} from "node:http";
|
|
import type { AddressInfo } from "node:net";
|
|
import { join, relative } from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
|
|
import {
|
|
McpServer,
|
|
WebStandardStreamableHTTPServerTransport,
|
|
type ServerContext,
|
|
} from "@modelcontextprotocol/server";
|
|
import { z } from "zod";
|
|
|
|
import { onTestFinished } from "vitest";
|
|
|
|
import { DesktopBuildComputers } from "../../src/application/desktop/DesktopBuildComputers.js";
|
|
import { createTestTempDirectory } from "./temporaryDirectory.js";
|
|
|
|
/** A 2x1 PNG of a black and a white pixel, the fake driver's drawn desktop. */
|
|
export const FAKE_FRAME_PNG =
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAD0lEQVR42mNgYGD4//8/AAYBAv67yYXpAAAAAElFTkSuQmCC";
|
|
/** A 2x1 PNG of two black pixels, a display the desktop has not drawn on yet. */
|
|
const FAKE_BLANK_PNG =
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAC0lEQVR42mNgAAMAAAcAASEi2xMAAAAASUVORK5CYII=";
|
|
/** The fake recording's segments, in order, as the driver keeps them under `rec-000000000001/`. */
|
|
export const FAKE_SEGMENTS = [
|
|
Buffer.from("not really an mp4 segment"),
|
|
Buffer.from("nor is this second one"),
|
|
];
|
|
const segmentPath = (index: number) =>
|
|
`rec-000000000001/${String(index).padStart(4, "0")}.mp4`;
|
|
|
|
/** How the fake driver misbehaves, when a test asks it to. */
|
|
export interface FakeComputerOptions {
|
|
/** Polls answered not ready before readiness passes. */
|
|
readonly notReadyPolls?: number;
|
|
/** Readiness reports a final failed check instead. */
|
|
readonly readinessFails?: boolean;
|
|
/** Base64 characters in each screenshot's image, in place of a drawn PNG. */
|
|
readonly frameCharacters?: number;
|
|
/** Screenshots answered with one black frame before the desktop is drawn. */
|
|
readonly blankFrames?: number;
|
|
/** How long each screenshot, in order, waits before its reply, unless its request is cancelled. */
|
|
readonly screenshotDelaysMs?: readonly number[];
|
|
/** Bytes data/get appends to each segment beyond its manifest size. */
|
|
readonly segmentPadding?: number;
|
|
/** Recording starts, in order, whose recording begins but whose reply is lost. */
|
|
readonly lostRecordingStarts?: number;
|
|
/** Run computer-exec commands on this host in `home`, with `bin` first on PATH and `env` added. */
|
|
readonly exec?: {
|
|
readonly home: string;
|
|
readonly bin: string;
|
|
readonly env?: Readonly<Record<string, string>>;
|
|
/** Controller commands, in order, whose command runs but whose reply is lost. */
|
|
readonly dropReplies?: string[];
|
|
};
|
|
/** The one app the card lists. */
|
|
readonly app?: FakeApp;
|
|
}
|
|
|
|
const COMPUTER_TOOLS = [
|
|
"computer_screenshot",
|
|
"computer_click",
|
|
"computer_drag",
|
|
"computer_scroll",
|
|
"computer_type",
|
|
"computer_key",
|
|
"computer_wait",
|
|
] as const;
|
|
|
|
/** The one app a fake computer lists, serving data/get with its state views and data/reset to its seed. */
|
|
export interface FakeApp {
|
|
readonly name: string;
|
|
/** The views data/get answers now; a test sets them as the app's state changes. */
|
|
state: Record<string, unknown>;
|
|
readonly seed: Record<string, unknown>;
|
|
/** The data methods the app was asked, in order. */
|
|
readonly methods: string[];
|
|
/** data/get calls still to answer with an error, as a busy app would. */
|
|
failGets?: number;
|
|
/**
|
|
* Called as each data/get arrives, before its answer, as an app saving on
|
|
* its own timer changes its files; the answer waits for a promise it returns.
|
|
*/
|
|
onGet?: () => void | Promise<void>;
|
|
/** Readiness polls answered not ready after each data/reset, as a slow relaunch would. */
|
|
readonly unreadyAfterReset?: number;
|
|
/** Readiness polls left before the relaunched app is ready. */
|
|
unready?: number;
|
|
}
|
|
|
|
/** A local stand-in for an agentenv computer's driver: readiness, `computer_*` MCP tools, recording and data/get. */
|
|
export interface FakeComputer {
|
|
readonly url: string;
|
|
readonly token: string;
|
|
/** Every `computer_*` call the driver received, with its arguments as sent. */
|
|
readonly calls: { name: string; arguments: Record<string, unknown> }[];
|
|
/** Paths of framework-only routes called without the control token. */
|
|
readonly unauthorized: string[];
|
|
readonly recordingRequests: { path: string; body: unknown }[];
|
|
/** The paths each data/get asked for, one entry per request. */
|
|
readonly dataRequests: unknown[];
|
|
/** Refuse the next call of a tool as the driver refuses one, with its message. */
|
|
refuseNext(name: string, message: string): void;
|
|
/** Run the next call of a tool but lose its reply, as a dropped connection would. */
|
|
loseNext(name: string): void;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
const readBody = async (request: IncomingMessage): Promise<Buffer> => {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of request) chunks.push(chunk as Buffer);
|
|
return Buffer.concat(chunks);
|
|
};
|
|
|
|
const sendJson = (response: ServerResponse, status: number, value: unknown) => {
|
|
response.writeHead(status, { "content-type": "application/json" });
|
|
response.end(JSON.stringify(value));
|
|
};
|
|
|
|
/** The driver's `computer_*` tools over streamable HTTP, recording each call and refusing on request. */
|
|
const startFakeDriver = async (
|
|
calls: FakeComputer["calls"],
|
|
refusals: Map<string, string>,
|
|
options: FakeComputerOptions,
|
|
) => {
|
|
const frameData =
|
|
options.frameCharacters === undefined
|
|
? FAKE_FRAME_PNG
|
|
: "A".repeat(options.frameCharacters);
|
|
let blankFrames = options.blankFrames ?? 0;
|
|
let screenshots = 0;
|
|
const mcp = new McpServer({ name: "fake-reference-driver", version: "1" });
|
|
for (const name of COMPUTER_TOOLS)
|
|
mcp.registerTool(
|
|
name,
|
|
{ description: name, inputSchema: z.looseObject({}) },
|
|
async (args: Record<string, unknown>, context: ServerContext) => {
|
|
calls.push({ name, arguments: args });
|
|
if (name === "computer_screenshot") {
|
|
screenshots += 1;
|
|
const wait = options.screenshotDelaysMs?.[screenshots - 1] ?? 0;
|
|
if (wait > 0)
|
|
await delay(wait, undefined, {
|
|
signal: context.mcpReq.signal,
|
|
ref: false,
|
|
}).catch(() => undefined);
|
|
}
|
|
const refusal = refusals.get(name);
|
|
if (refusal !== undefined) {
|
|
refusals.delete(name);
|
|
return { content: [{ type: "text", text: refusal }], isError: true };
|
|
}
|
|
const frame = { frame_id: `f${String(calls.length)}`, width: 1280 };
|
|
const blank = name === "computer_screenshot" && blankFrames > 0;
|
|
if (blank) blankFrames -= 1;
|
|
const image =
|
|
name === "computer_screenshot" || args.screenshot === true
|
|
? [
|
|
{
|
|
type: "image" as const,
|
|
data: blank ? FAKE_BLANK_PNG : frameData,
|
|
mimeType: "image/png",
|
|
},
|
|
]
|
|
: [];
|
|
return {
|
|
content: [{ type: "text" as const, text: `${name} done` }, ...image],
|
|
structuredContent:
|
|
name === "computer_screenshot"
|
|
? frame
|
|
: { applied: "yes", ...(image.length === 0 ? {} : { frame }) },
|
|
};
|
|
},
|
|
);
|
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
sessionIdGenerator: () => "fake-session",
|
|
enableJsonResponse: true,
|
|
});
|
|
await mcp.connect(transport);
|
|
return { mcp, transport };
|
|
};
|
|
|
|
/** Every file under home-relative paths, as the driver's data plane answers data/get. */
|
|
const homeFileParts = async (home: string, paths: readonly string[]) => {
|
|
const parts: unknown[] = [];
|
|
const visit = async (path: string): Promise<void> => {
|
|
const entry = await stat(path).catch(() => undefined);
|
|
if (entry?.isDirectory() === true)
|
|
for (const name of (await readdir(path)).sort())
|
|
await visit(join(path, name));
|
|
else if (entry?.isFile() === true)
|
|
parts.push({
|
|
kind: "file",
|
|
file: { bytes: (await readFile(path)).toString("base64") },
|
|
metadata: {
|
|
"urn:agentenv:data-objects/v1": { path: relative(home, path) },
|
|
},
|
|
});
|
|
};
|
|
for (const path of paths) await visit(join(home, path));
|
|
return parts;
|
|
};
|
|
|
|
/** The most of a command's stdout the computer's computer-exec answers with. */
|
|
const EXEC_STDOUT_BYTES = 1024 * 1024;
|
|
|
|
/** Process groups a computer's commands started, so closing the computer can stop them. */
|
|
interface ExecSessions {
|
|
readonly running: Set<ChildProcess>;
|
|
/** Start identity recorded while the process was still the one this computer launched. */
|
|
readonly owned: Map<number, string>;
|
|
}
|
|
|
|
/** Remember a live PID only with the start identity that proves it is still that process. */
|
|
const rememberOwned = async (
|
|
sessions: ExecSessions,
|
|
pid: number | undefined,
|
|
) => {
|
|
if (pid === undefined || sessions.owned.has(pid)) return;
|
|
const observed = await observeProcessStartIdentity(pid);
|
|
if (observed?.state === "readable")
|
|
sessions.owned.set(pid, observed.identity);
|
|
};
|
|
|
|
/** Child processes of `pid`, which a new session keeps after the parent exits. */
|
|
const noteChildren = async (
|
|
sessions: ExecSessions,
|
|
pid: number | undefined,
|
|
) => {
|
|
if (pid === undefined) return;
|
|
try {
|
|
const text = readFileSync(
|
|
`/proc/${String(pid)}/task/${String(pid)}/children`,
|
|
"utf8",
|
|
);
|
|
for (const id of text.trim().split(/\s+/u)) {
|
|
const child = Number(id);
|
|
if (Number.isSafeInteger(child) && child > 0)
|
|
await rememberOwned(sessions, child);
|
|
}
|
|
} catch {
|
|
// The process has exited, or this host does not expose /proc.
|
|
}
|
|
};
|
|
|
|
/** Stop commands this computer started, including captures detached into their own session. */
|
|
const stopExecSessions = async (sessions: ExecSessions) => {
|
|
for (const child of sessions.running) await noteChildren(sessions, child.pid);
|
|
for (const child of sessions.running) child.kill("SIGTERM");
|
|
for (const [pid, identity] of sessions.owned)
|
|
await signalProcessWithStartIdentity(pid, identity, "SIGTERM", {
|
|
sendSignal: (target, signal) => {
|
|
try {
|
|
process.kill(-target, signal);
|
|
} catch {
|
|
process.kill(target, signal);
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
/** Run one computer-exec command on this host as the computer would: in the home, its stdin closed, its stdout cut at the cap. */
|
|
const runExec = (
|
|
exec: NonNullable<FakeComputerOptions["exec"]>,
|
|
token: string,
|
|
argv: readonly string[],
|
|
sessions: ExecSessions,
|
|
) =>
|
|
new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
const [command = "", ...rest] = argv;
|
|
const child = spawn(command, rest, {
|
|
cwd: exec.home,
|
|
env: {
|
|
...process.env,
|
|
...exec.env,
|
|
HOME: exec.home,
|
|
PATH: `${exec.bin}:${process.env.PATH ?? ""}`,
|
|
AGENTENV_CONTROL_TOKEN: token,
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
sessions.running.add(child);
|
|
const stdout: Buffer[] = [];
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
|
|
child.stderr.on("data", (chunk: Buffer) => (stderr += String(chunk)));
|
|
child.once("error", reject);
|
|
child.once("close", (code) => {
|
|
void (async () => {
|
|
await noteChildren(sessions, child.pid);
|
|
const output = Buffer.concat(stdout);
|
|
for (const match of output
|
|
.toString("utf8")
|
|
.matchAll(/"pid":\s*(\d+)/gu)) {
|
|
const capture = Number(match[1]);
|
|
if (Number.isSafeInteger(capture) && capture > 0)
|
|
await rememberOwned(sessions, capture);
|
|
}
|
|
sessions.running.delete(child);
|
|
resolve({
|
|
exit_code: code,
|
|
stdout: output.subarray(0, EXEC_STDOUT_BYTES).toString("utf8"),
|
|
stderr,
|
|
truncated: output.length > EXEC_STDOUT_BYTES,
|
|
timed_out: false,
|
|
});
|
|
})().catch(reject);
|
|
});
|
|
});
|
|
|
|
/** A reply the fake loses after doing the work, as a dropped connection would. */
|
|
const LOST = Symbol("lost reply");
|
|
/** A JSON-RPC request calling one of the driver's tools. */
|
|
const toolCall = z.object({
|
|
method: z.literal("tools/call"),
|
|
params: z.object({ name: z.string() }),
|
|
});
|
|
|
|
/** One of the app's data methods. */
|
|
const appRoute = async (
|
|
app: FakeApp,
|
|
request: { id?: number; method?: string },
|
|
) => {
|
|
const method = request.method ?? "";
|
|
app.methods.push(method);
|
|
if (method === "data/get") await app.onGet?.();
|
|
if (method === "data/get" && (app.failGets ?? 0) > 0) {
|
|
app.failGets = (app.failGets ?? 0) - 1;
|
|
return {
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
error: { code: -32000, message: "the app is busy", data: {} },
|
|
};
|
|
}
|
|
if (method === "data/reset") {
|
|
app.state = structuredClone(app.seed);
|
|
app.unready = app.unreadyAfterReset ?? 0;
|
|
}
|
|
return {
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
result:
|
|
method === "data/get"
|
|
? {
|
|
parts: [
|
|
// Each file's bytes follow its view, so they change when the state does.
|
|
...Object.entries(app.state).map(([path, view]) => ({
|
|
kind: "file",
|
|
file: {
|
|
bytes: Buffer.from(JSON.stringify(view)).toString("base64"),
|
|
name: path.split("/").at(-1),
|
|
},
|
|
metadata: { "urn:agentenv:data-objects/v1": { path } },
|
|
})),
|
|
{ kind: "data", data: { state: app.state } },
|
|
],
|
|
}
|
|
: {},
|
|
};
|
|
};
|
|
|
|
/** Not ready while the app relaunches after a reset; undefined once it is, for the usual readiness. */
|
|
const relaunching = (app: FakeApp | undefined) => {
|
|
if (app === undefined || (app.unready ?? 0) === 0) return undefined;
|
|
app.unready = (app.unready ?? 0) - 1;
|
|
return { ready: false, checks: [{ name: app.name, ok: false }] };
|
|
};
|
|
|
|
/** One command run as the computer would, its reply lost when the test asks. */
|
|
const execRoute = async (
|
|
exec: NonNullable<FakeComputerOptions["exec"]>,
|
|
token: string,
|
|
argv: readonly string[],
|
|
sessions: ExecSessions,
|
|
) => {
|
|
const answer = await runExec(exec, token, argv, sessions);
|
|
const { dropReplies } = exec;
|
|
const requested = dropReplies?.[0];
|
|
const [command, operation] = requested?.split(":") ?? [];
|
|
if (
|
|
command !== argv[4] ||
|
|
(operation !== undefined && !argv[5]?.includes(`\"${operation}\"`))
|
|
)
|
|
return answer;
|
|
if (dropReplies === undefined) return answer;
|
|
dropReplies.shift();
|
|
return LOST;
|
|
};
|
|
|
|
/** The card, the app's data methods, exec and home files, when the fake serves them; undefined for other routes. */
|
|
const buildRoute = async (
|
|
options: FakeComputerOptions,
|
|
token: string,
|
|
path: string,
|
|
parsed: unknown,
|
|
sessions: ExecSessions,
|
|
): Promise<unknown> => {
|
|
const { app, exec } = options;
|
|
const request = parsed as {
|
|
id?: number;
|
|
method?: string;
|
|
argv?: string[];
|
|
params?: { paths?: string[] };
|
|
};
|
|
if (path === "/.well-known/agent-env.json")
|
|
return {
|
|
name: "fake-computer",
|
|
children_environments:
|
|
app === undefined
|
|
? []
|
|
: [{ name: app.name, url: `/svc/${app.name}/agentenv` }],
|
|
};
|
|
if (app !== undefined && path === `/svc/${app.name}/agentenv`)
|
|
return appRoute(app, request);
|
|
if (path === "/agentenv/ext/computer-ready/status") return relaunching(app);
|
|
if (exec === undefined) return undefined;
|
|
if (path === "/agentenv/ext/computer-exec/run")
|
|
return execRoute(exec, token, request.argv ?? [], sessions);
|
|
const paths = request.params?.paths ?? [];
|
|
// Recordings keep the canned segment; other paths are the home's files.
|
|
if (
|
|
path !== "/agentenv" ||
|
|
(paths[0] ?? ".agentenv-recordings/").startsWith(".agentenv-recordings/")
|
|
)
|
|
return undefined;
|
|
return {
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
result: { parts: await homeFileParts(exec.home, paths) },
|
|
};
|
|
};
|
|
|
|
interface FakeState {
|
|
readonly options: FakeComputerOptions;
|
|
readonly recordingRequests: FakeComputer["recordingRequests"];
|
|
readonly dataRequests: unknown[];
|
|
polls: number;
|
|
lostStarts: number;
|
|
}
|
|
|
|
const segments = FAKE_SEGMENTS.map((bytes, index) => ({
|
|
index,
|
|
path: segmentPath(index),
|
|
bytes: bytes.length,
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
}));
|
|
|
|
/** The framework-only routes the token guards: readiness, recording and the driver's data/get. */
|
|
const frameworkRoute = (
|
|
state: FakeState,
|
|
path: string,
|
|
parsed: unknown,
|
|
): unknown => {
|
|
if (path === "/agentenv/ext/computer-ready/status") {
|
|
state.polls += 1;
|
|
const ready =
|
|
state.options.readinessFails !== true &&
|
|
state.polls > (state.options.notReadyPolls ?? 0);
|
|
return {
|
|
ready,
|
|
checks: [
|
|
{
|
|
name: "display",
|
|
ok: ready,
|
|
detail: state.options.readinessFails === true ? "Xvfb exited" : "",
|
|
...(state.options.readinessFails === true ? { final: true } : {}),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (path.startsWith("/agentenv/ext/computer-recording/")) {
|
|
state.recordingRequests.push({ path, body: parsed });
|
|
if (path.endsWith("/start") && state.lostStarts > 0) {
|
|
state.lostStarts -= 1;
|
|
return LOST;
|
|
}
|
|
return path.endsWith("/start")
|
|
? {
|
|
recording_id: "rec-000000000001",
|
|
started_at: "2026-11-05T00:00:00Z",
|
|
format: "video/mp4; codecs=avc1",
|
|
}
|
|
: {
|
|
recording_id: "rec-000000000001",
|
|
fps: 10,
|
|
started_at: "2026-11-05T00:00:00Z",
|
|
ended_at: "2026-11-05T00:00:15Z",
|
|
ended_by: "request",
|
|
segments,
|
|
};
|
|
}
|
|
const { id, params } = parsed as { id: number; params: { paths: string[] } };
|
|
state.dataRequests.push(params.paths);
|
|
const padding = Buffer.alloc(state.options.segmentPadding ?? 0, 0x20);
|
|
return {
|
|
jsonrpc: "2.0",
|
|
id,
|
|
result: {
|
|
parts: FAKE_SEGMENTS.flatMap((bytes, index) => {
|
|
const home = `.agentenv-recordings/${segmentPath(index)}`;
|
|
return params.paths.some(
|
|
(requested) => home === requested || home.startsWith(`${requested}/`),
|
|
)
|
|
? [
|
|
{
|
|
kind: "file",
|
|
file: {
|
|
bytes: Buffer.concat([bytes, padding]).toString("base64"),
|
|
name: home.split("/").at(-1),
|
|
},
|
|
metadata: { "urn:agentenv:data-objects/v1": { path: home } },
|
|
},
|
|
]
|
|
: [];
|
|
}),
|
|
},
|
|
};
|
|
};
|
|
|
|
/** Start the fake driver, with the misbehaviour a test asks for. */
|
|
export const startFakeComputer = async (
|
|
options: FakeComputerOptions = {},
|
|
): Promise<FakeComputer> => {
|
|
const token = "fake-control-token-0123456789abcdef";
|
|
const calls: FakeComputer["calls"] = [];
|
|
const unauthorized: string[] = [];
|
|
const state: FakeState = {
|
|
options,
|
|
recordingRequests: [],
|
|
dataRequests: [],
|
|
polls: 0,
|
|
lostStarts: options.lostRecordingStarts ?? 0,
|
|
};
|
|
const sessions: ExecSessions = { running: new Set(), owned: new Map() };
|
|
const refusals = new Map<string, string>();
|
|
const lostReplies = new Set<string>();
|
|
const { mcp, transport } = await startFakeDriver(calls, refusals, options);
|
|
const server = createServer((request, response) => {
|
|
void (async () => {
|
|
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
const body = await readBody(request);
|
|
// REA's client needs no server-initiated stream; a 405 says so.
|
|
if (url.pathname === "/mcp" && request.method === "GET") {
|
|
sendJson(response, 405, {});
|
|
return;
|
|
}
|
|
if (url.pathname === "/mcp") {
|
|
const headers = new Headers();
|
|
for (const [key, value] of Object.entries(request.headers))
|
|
if (typeof value === "string") headers.set(key, value);
|
|
const answer = await transport.handleRequest(
|
|
new Request(url, {
|
|
method: request.method ?? "GET",
|
|
headers,
|
|
...(body.length === 0 ? {} : { body }),
|
|
}),
|
|
);
|
|
const called = toolCall.safeParse(
|
|
body.length === 0 ? null : JSON.parse(String(body)),
|
|
);
|
|
if (called.success && lostReplies.delete(called.data.params.name)) {
|
|
response.destroy();
|
|
return;
|
|
}
|
|
response.writeHead(answer.status, Object.fromEntries(answer.headers));
|
|
response.end(Buffer.from(await answer.arrayBuffer()));
|
|
return;
|
|
}
|
|
if (request.headers.authorization !== `Bearer ${token}`) {
|
|
unauthorized.push(url.pathname);
|
|
sendJson(response, 401, { ok: false });
|
|
return;
|
|
}
|
|
const parsed: unknown = body.length === 0 ? {} : JSON.parse(String(body));
|
|
const answer =
|
|
(await buildRoute(options, token, url.pathname, parsed, sessions)) ??
|
|
frameworkRoute(state, url.pathname, parsed);
|
|
if (answer === LOST) response.destroy();
|
|
else sendJson(response, 200, answer);
|
|
})().catch((cause: unknown) => {
|
|
response.destroy(cause instanceof Error ? cause : undefined);
|
|
});
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const { port } = server.address() as AddressInfo;
|
|
return {
|
|
url: `http://127.0.0.1:${String(port)}`,
|
|
token,
|
|
calls,
|
|
unauthorized,
|
|
recordingRequests: state.recordingRequests,
|
|
dataRequests: state.dataRequests,
|
|
refuseNext: (name, message) => refusals.set(name, message),
|
|
loseNext: (name) => lostReplies.add(name),
|
|
close: async () => {
|
|
await stopExecSessions(sessions);
|
|
await mcp.close();
|
|
server.closeAllConnections();
|
|
await new Promise((resolve) => server.close(resolve));
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* A stand-in `agent_env.bundle.run_bundle`, which REA's real helper imports:
|
|
* it notes each call, loads each of the bundle's Python files in a child
|
|
* Python as agentenv's `_declare` does, which compiles it and caches its
|
|
* bytecode beside it unless Python is told not to, copies the bundle and
|
|
* writes to stdout as a build would, then hands out the fake computer, or
|
|
* the one the bundle's `fake-computer-url` names.
|
|
*/
|
|
const FAKE_BUNDLE_MODULE = String.raw`
|
|
import glob
|
|
import json
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import types
|
|
|
|
STATE = os.environ["FAKE_STATE"]
|
|
DECLARE = """
|
|
import importlib.util, sys
|
|
folder, file = sys.argv[1], sys.argv[2]
|
|
sys.path.insert(0, folder)
|
|
spec = importlib.util.spec_from_file_location("computer_driver_module", folder + "/" + file)
|
|
spec.loader.get_code(spec.name)
|
|
"""
|
|
|
|
|
|
def _note(name, value):
|
|
with open(os.path.join(STATE, name), "a") as handle:
|
|
handle.write(json.dumps(value) + "\n")
|
|
|
|
|
|
def _interrupted(*_):
|
|
_note("interrupted.jsonl", {"pid": os.getpid()})
|
|
raise KeyboardInterrupt("cancelled")
|
|
|
|
|
|
class _Result:
|
|
def __init__(self, run):
|
|
self.runs = (run,)
|
|
|
|
def teardown(self):
|
|
_note("teardowns.jsonl", {"pid": os.getpid()})
|
|
still_up = ("sbx-fake",) if os.environ.get("FAKE_TEARDOWN_FAILS") else ()
|
|
report = types.SimpleNamespace(still_up=still_up)
|
|
return types.SimpleNamespace(runs=(types.SimpleNamespace(torn_down=report),))
|
|
|
|
|
|
def run_bundle(root, *, tasks=(), keep=False, emulate=True, sandbox=None, on_progress=None):
|
|
previous = signal.signal(signal.SIGTERM, _interrupted)
|
|
_note("runs.jsonl", {"root": str(root), "tasks": list(tasks), "keep": keep, "emulate": emulate,
|
|
"sandbox": sandbox})
|
|
for path in glob.glob(os.path.join(root, "envs", "*", "*.py")):
|
|
subprocess.run([sys.executable, "-c", DECLARE, os.path.dirname(path), os.path.basename(path)], check=True)
|
|
shutil.copytree(root, os.path.join(STATE, "bundle"), dirs_exist_ok=True)
|
|
os.system("echo a build writing to stdout")
|
|
print("agentenv writing to stdout")
|
|
on_progress("deploying the computer")
|
|
time.sleep(float(os.environ.get("FAKE_DEPLOY_SECONDS", "0")))
|
|
signal.signal(signal.SIGTERM, previous)
|
|
chatter = int(os.environ.get("FAKE_CHATTER_BYTES", "0"))
|
|
for line in range(chatter // 80):
|
|
print(f"chatter {line:05d}: Get:1 http://ports.ubuntu.com/ubuntu-ports noble/main arm64 libfoo", file=sys.stderr)
|
|
if os.environ.get("FAKE_FAIL"):
|
|
print(f"E: {os.environ['FAKE_FAIL']}", file=sys.stderr, flush=True)
|
|
raise RuntimeError(os.environ["FAKE_FAIL"])
|
|
url = os.environ["FAKE_COMPUTER_URL"]
|
|
named = os.path.join(root, "fake-computer-url")
|
|
if os.path.exists(named):
|
|
with open(named) as handle:
|
|
url = handle.read().strip()
|
|
env = types.SimpleNamespace(environment_url=url, mcp_url=url + "/mcp", sandbox_id="sbx-fake",
|
|
control_token=os.environ["FAKE_COMPUTER_TOKEN"])
|
|
run = types.SimpleNamespace(context=types.SimpleNamespace(deployed_envs=[env]), error=None, failed_steps=[])
|
|
return _Result(run)
|
|
`;
|
|
|
|
/** A fake runner: an `agent-env` script naming the host's python3 and a fake `agent_env` package on PYTHONPATH. */
|
|
export interface FakeAgentEnvRunner {
|
|
readonly state: string;
|
|
readonly environment: Readonly<Record<string, string>>;
|
|
/** Lines one JSON-lines note file holds. */
|
|
notes(name: string): Promise<unknown[]>;
|
|
}
|
|
|
|
export const fakeAgentEnvRunner = async (
|
|
computer: Pick<FakeComputer, "url" | "token">,
|
|
): Promise<FakeAgentEnvRunner> => {
|
|
const state = await createTestTempDirectory("fake-agentenv-");
|
|
const python = execFileSync(
|
|
"python3",
|
|
["-c", "import sys; print(sys.executable)"],
|
|
{ encoding: "utf8" },
|
|
).trim();
|
|
await mkdir(join(state, "site", "agent_env"), { recursive: true });
|
|
const runner = join(state, "agent-env");
|
|
await writeFile(
|
|
runner,
|
|
`#!${python}\n# Stand-in runner: REA reads only its interpreter.\n`,
|
|
);
|
|
await chmod(runner, 0o755);
|
|
await writeFile(join(state, "site", "agent_env", "__init__.py"), "");
|
|
await writeFile(
|
|
join(state, "site", "agent_env", "bundle.py"),
|
|
FAKE_BUNDLE_MODULE,
|
|
);
|
|
return {
|
|
state,
|
|
environment: {
|
|
REA_AGENT_ENV_COMMAND: runner,
|
|
PYTHONPATH: join(state, "site"),
|
|
FAKE_STATE: state,
|
|
FAKE_COMPUTER_URL: computer.url,
|
|
FAKE_COMPUTER_TOKEN: computer.token,
|
|
},
|
|
notes: async (name) => {
|
|
const path = join(state, name);
|
|
try {
|
|
await access(path);
|
|
} catch {
|
|
return [];
|
|
}
|
|
return (await readFile(path, "utf8"))
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line) as unknown);
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* A build computer owner over REA's real helper, a fake runner and a fake
|
|
* driver, and a bundle-folder start; the test closes what it starts.
|
|
*/
|
|
export const fakeBuildComputers = async (
|
|
options: FakeComputerOptions = {},
|
|
environment: Readonly<Record<string, string>> = {},
|
|
) => {
|
|
const computer = await startFakeComputer(options);
|
|
onTestFinished(() => computer.close());
|
|
const runner = await fakeAgentEnvRunner(computer);
|
|
const computers = new DesktopBuildComputers({
|
|
...process.env,
|
|
...runner.environment,
|
|
...environment,
|
|
});
|
|
const bundle = await createTestTempDirectory("exported-bundle-");
|
|
await mkdir(join(bundle, "tasks"));
|
|
await writeFile(join(bundle, "tasks", "smoke.json"), "[]\n");
|
|
const start = () =>
|
|
computers.start(
|
|
{ source: { kind: "bundle", path: bundle } },
|
|
new AbortController().signal,
|
|
);
|
|
return { computer, runner, computers, start };
|
|
};
|