mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 09:52:27 +00:00
* 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): keep selected logs and stop treating unknown walks as complete Match an app name as a whole token, treat an omitted unchecked_paths count as incomplete, seed Chromium user_prefs.json, and keep a caller-selected log even when that path is also a state directory. A log inside the app's own folder still marks that folder so settings beside it stay seeded, while a lock in the home does not. Sibling locks under a selected folder stay out unless that lock was the selected path. * 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): keep a lone lock a document and drop one beside its file A selected name such as notes.lck stays a document, so launch and the draft still treat it as the caller's file. A lock beside a recorded sibling is left out of the seed unless that lock path itself was selected. * 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): keep a document whose name ends in .lock A lock beside a recorded file still stays out of the seed unless that lock was selected. A document such as Cargo.lock in the home is the user's file, so the seed keeps it. An application lock in the home, such as poedit.lock, stays out. * 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): do not let a left-out lock hide settings checks A sibling lock the seed omits is not the document the draft follows. Settings changes stay suggested. The unused sibling-lock name helper is removed. * 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): leave out a sibling lock under a selected folder A file under a selected folder inherits selected_path. That does not make the lock beside its map a chosen document, so a settings change stays drafted. * 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): draft a lock the export selected exactly The draft uses the same selected paths as the export. A lock chosen by itself stays in the draft. A lock under a selected folder stays out. * 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 * fix(desktop): preserve explicitly selected journal documents * 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 * fix(desktop): retain app state logs in exported seeds * 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 * docs(desktop): clarify recovered seed format * test(desktop): group shipped gzip seed checks * 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 * docs(desktop): clarify unreadable dconf versions * 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>
750 lines
26 KiB
TypeScript
750 lines
26 KiB
TypeScript
import {
|
|
chmod,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
rm,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
|
|
import type { Client } from "@modelcontextprotocol/client";
|
|
import type { CallToolResult } from "@modelcontextprotocol/server";
|
|
import { expect, it, vi } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
import { seededLockStartNotice } from "../../../src/application/desktop/DesktopBuildComputers.js";
|
|
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
|
import {
|
|
buildComputerFakes as fakes,
|
|
bundleFolder,
|
|
callOk,
|
|
connectBuildServer as connect,
|
|
expectNoToken,
|
|
startBuildComputer,
|
|
} from "../../fixtures/buildComputerMcp.js";
|
|
import {
|
|
FAKE_FRAME_PNG,
|
|
type FakeApp,
|
|
type FakeComputer,
|
|
} from "../../fixtures/fakeAgentEnvComputer.js";
|
|
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
|
|
|
const images = (result: CallToolResult) =>
|
|
result.content.filter((block) => block.type === "image");
|
|
|
|
const startFromBundle = async (client: Client, computer: FakeComputer) => {
|
|
const started = await client.callTool({
|
|
name: "start_build_computer",
|
|
arguments: { source: { kind: "bundle", path: await bundleFolder() } },
|
|
});
|
|
expect(started.isError, JSON.stringify(started)).not.toBe(true);
|
|
expectNoToken(computer, started);
|
|
return {
|
|
started,
|
|
value: toolContract("start_build_computer").outputSchema.parse(
|
|
started.structuredContent,
|
|
),
|
|
};
|
|
};
|
|
|
|
it("keeps a computer up, forwards actions with their images and tears it down on close", async () => {
|
|
const { computer, runner } = await fakes({ notReadyPolls: 2 });
|
|
const client = await connect(runner.environment);
|
|
const { started, value } = await startFromBundle(client, computer);
|
|
expect(value.sandbox_id).toBe("sbx-fake");
|
|
expect(images(started)).toEqual([
|
|
{ type: "image", data: FAKE_FRAME_PNG, mimeType: "image/png" },
|
|
]);
|
|
expect(await runner.notes("runs.jsonl")).toMatchObject([
|
|
{ tasks: ["smoke"], keep: true, emulate: false, sandbox: "local" },
|
|
]);
|
|
// Readiness was polled with the token until it passed; /mcp needs none.
|
|
expect(computer.unauthorized).toEqual([]);
|
|
|
|
const act = (action: string, fields: Record<string, unknown> = {}) =>
|
|
client.callTool({
|
|
name: "act_on_build_computer",
|
|
arguments: { computer_id: value.computer_id, action, ...fields },
|
|
});
|
|
const screenshot = await act("screenshot", {
|
|
region: [0, 0, 500, 500],
|
|
format: "jpeg",
|
|
space: "norm",
|
|
});
|
|
expect(screenshot.isError).not.toBe(true);
|
|
expect(images(screenshot)).toHaveLength(1);
|
|
expect(screenshot.content).toContainEqual({
|
|
type: "text",
|
|
text: "computer_screenshot done",
|
|
});
|
|
const clicked = await act("click", { x: 640, y: 400, count: 2 });
|
|
expect(images(clicked)).toEqual([]);
|
|
expect(
|
|
toolContract("act_on_build_computer").outputSchema.parse(
|
|
clicked.structuredContent,
|
|
),
|
|
).toMatchObject({
|
|
action: "click",
|
|
action_index: 1,
|
|
result: { applied: "yes" },
|
|
});
|
|
await act("key", { keys: ["ctrl+s"], screenshot: true });
|
|
expect(computer.calls.slice(1)).toEqual([
|
|
{
|
|
name: "computer_screenshot",
|
|
arguments: { region: [0, 0, 500, 500], format: "jpeg", space: "norm" },
|
|
},
|
|
{ name: "computer_click", arguments: { x: 640, y: 400, count: 2 } },
|
|
{
|
|
name: "computer_key",
|
|
arguments: { keys: ["Control", "s"], screenshot: true },
|
|
},
|
|
]);
|
|
|
|
computer.refuseNext(
|
|
"computer_drag",
|
|
"stale_frame: there is no frame yet: take a screenshot first",
|
|
);
|
|
const refused = await act("drag", {
|
|
path: [
|
|
[1, 1],
|
|
[9, 9],
|
|
],
|
|
});
|
|
expectNoToken(computer, refused);
|
|
expect(parseMcpToolError(refused).error.message).toContain(
|
|
"stale_frame: there is no frame yet",
|
|
);
|
|
const misplaced = await act("type", { text: "hi", x: 3 });
|
|
expect(parseMcpToolError(misplaced).error.code).toBe("invalid_request");
|
|
expect(computer.calls.map(({ name }) => name).at(-1)).toBe("computer_drag");
|
|
|
|
const closed = await client.callTool({
|
|
name: "close_build_computer",
|
|
arguments: { computer_id: value.computer_id },
|
|
});
|
|
expect(closed.structuredContent).toEqual({
|
|
computer_id: value.computer_id,
|
|
teardown: "agentenv",
|
|
});
|
|
expect(await runner.notes("teardowns.jsonl")).toHaveLength(1);
|
|
const after = await act("screenshot");
|
|
expect(parseMcpToolError(after).error.message).toContain(
|
|
"start_build_computer",
|
|
);
|
|
});
|
|
|
|
it("renders a build bundle from a request with REA's payload and starts it", async () => {
|
|
const { computer, runner } = await fakes();
|
|
const payload = join(runner.state, "rea-prod.tar.xz");
|
|
await writeFile(payload, "a packed REA build");
|
|
const client = await connect({
|
|
...runner.environment,
|
|
REA_BUILD_COMPUTER_REA_ARCHIVE: payload,
|
|
});
|
|
const started = await client.callTool({
|
|
name: "start_build_computer",
|
|
arguments: {
|
|
source: {
|
|
kind: "request",
|
|
desktop_entry: "org.xfce.mousepad",
|
|
install: {
|
|
kind: "archive",
|
|
package: "mousepad",
|
|
extra_packages: ["librsvg2-common"],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(started.isError, JSON.stringify(started)).not.toBe(true);
|
|
expect(started.structuredContent).toMatchObject({
|
|
environment: "mousepad",
|
|
extra_packages: ["librsvg2-common"],
|
|
});
|
|
const env = join(runner.state, "bundle", "envs", "mousepad");
|
|
expect(await readFile(join(env, "rea-payload.tar"), "utf8")).toBe(
|
|
"a packed REA build",
|
|
);
|
|
const module = await readFile(join(env, "mousepad_app.py"), "utf8");
|
|
expect(module).toContain('Launch(at="demand"');
|
|
expect(module).toContain(
|
|
'packages=Packages(apt=["mousepad", "librsvg2-common", "libgtk-3-bin", "dconf-cli"])',
|
|
);
|
|
const { computer_id: computerId } = toolContract(
|
|
"start_build_computer",
|
|
).outputSchema.parse(started.structuredContent);
|
|
await client.callTool({
|
|
name: "close_build_computer",
|
|
arguments: { computer_id: computerId },
|
|
});
|
|
expect(computer.calls.map(({ name }) => name)).toEqual([
|
|
"computer_screenshot",
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
["glabels-3.0", "glabels"],
|
|
["com.github.xournalpp.xournalpp", "xournalpp"],
|
|
["mousepad", "mousepad"],
|
|
])(
|
|
"names the environment of desktop entry %s %s",
|
|
async (entry, environment) => {
|
|
const { runner } = await fakes();
|
|
const payload = join(runner.state, "rea-prod.tar.xz");
|
|
await writeFile(payload, "a packed REA build");
|
|
const client = await connect({
|
|
...runner.environment,
|
|
REA_BUILD_COMPUTER_REA_ARCHIVE: payload,
|
|
});
|
|
|
|
const started = await callOk(client, "start_build_computer", {
|
|
source: {
|
|
kind: "request",
|
|
desktop_entry: entry,
|
|
install: { kind: "archive", package: environment },
|
|
},
|
|
});
|
|
|
|
expect(started.structuredContent).toMatchObject({ environment });
|
|
expect(await readdir(join(runner.state, "bundle", "envs"))).toEqual([
|
|
environment,
|
|
]);
|
|
},
|
|
);
|
|
|
|
it("names what a request needs when REA's payload is not configured", async () => {
|
|
const { runner } = await fakes();
|
|
const client = await connect(runner.environment);
|
|
const refused = await client.callTool({
|
|
name: "start_build_computer",
|
|
arguments: {
|
|
source: {
|
|
kind: "request",
|
|
desktop_entry: "mousepad",
|
|
install: { kind: "archive", package: "mousepad" },
|
|
},
|
|
},
|
|
});
|
|
expect(JSON.stringify(parseMcpToolError(refused))).toContain(
|
|
"REA_BUILD_COMPUTER_REA_ARCHIVE",
|
|
);
|
|
expect(await runner.notes("runs.jsonl")).toEqual([]);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
["Java17"],
|
|
{
|
|
path: ["source", "install", "extra_packages", 0],
|
|
reason: "invalid_format",
|
|
},
|
|
],
|
|
[
|
|
Array.from({ length: 17 }, (_, index) => `lib${String(index)}`),
|
|
{
|
|
path: ["source", "install", "extra_packages"],
|
|
reason: "out_of_range",
|
|
maximum: 16,
|
|
},
|
|
],
|
|
])(
|
|
"refuses extra packages %j before rendering a bundle",
|
|
async (extra, issue) => {
|
|
const { runner } = await fakes();
|
|
const client = await connect(runner.environment);
|
|
const refused = parseMcpToolError(
|
|
await client.callTool({
|
|
name: "start_build_computer",
|
|
arguments: {
|
|
source: {
|
|
kind: "request",
|
|
desktop_entry: "freeplane",
|
|
install: {
|
|
kind: "archive",
|
|
package: "freeplane",
|
|
extra_packages: extra,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(refused.error.code).toBe("invalid_request");
|
|
expect(refused.error.details?.issues).toEqual([
|
|
expect.objectContaining(issue),
|
|
]);
|
|
expect(await runner.notes("runs.jsonl")).toEqual([]);
|
|
},
|
|
);
|
|
|
|
it("reports the runner's failure with its output and leaves no helper behind", async () => {
|
|
const { runner } = await fakes();
|
|
const client = await connect({
|
|
...runner.environment,
|
|
FAKE_FAIL: "compose failed: no space left on device",
|
|
});
|
|
const failed = await client.callTool({
|
|
name: "start_build_computer",
|
|
arguments: { source: { kind: "bundle", path: await bundleFolder() } },
|
|
});
|
|
const { error } = parseMcpToolError(failed);
|
|
expect(JSON.stringify(error)).toContain(
|
|
"compose failed: no space left on device",
|
|
);
|
|
// The runner's own stdout went to the diagnostics, not the handshake.
|
|
expect(JSON.stringify(error)).toContain("agentenv writing to stdout");
|
|
});
|
|
|
|
it("points a bundle's start that never got ready at the lock files its seed holds", async () => {
|
|
const { runner } = await fakes();
|
|
const start = async (failure: string, bundle: string) =>
|
|
parseMcpToolError(
|
|
await (
|
|
await connect({ ...runner.environment, FAKE_FAIL: failure })
|
|
).callTool({
|
|
name: "start_build_computer",
|
|
arguments: { source: { kind: "bundle", path: bundle } },
|
|
}),
|
|
).error.message;
|
|
const bundle = await bundleFolder();
|
|
const seed = join(bundle, "envs", "calc", "seed");
|
|
await mkdir(join(seed, ".config", "libreoffice", "4"), { recursive: true });
|
|
await mkdir(join(seed, "Documents"));
|
|
await writeFile(join(seed, ".config/libreoffice/4/.lock"), "[Lockdata]\n");
|
|
await writeFile(join(seed, "Documents/.~lock.q1.ods#"), ",user,localhost;\n");
|
|
await writeFile(join(seed, "Documents/q1.ods"), "PK");
|
|
// A lock named for the file beside it, as GnuCash's <book>.LCK; a lone one is no lock.
|
|
await writeFile(join(seed, "Documents/q1.ods.LCK"), "");
|
|
await writeFile(join(seed, "Documents/notes.lck"), "");
|
|
const notReady =
|
|
"envs/calc: container/amd64: failed at probe: it didn't boot: the computer wasn't ready within 180 s (not ready: app calc: no window with class 'libreoffice-calc')";
|
|
|
|
const pointed = await start(notReady, bundle);
|
|
const unlocked = await start(notReady, await bundleFolder());
|
|
const otherFailure = await start(
|
|
"compose failed: no space left on device",
|
|
bundle,
|
|
);
|
|
|
|
const locks = [
|
|
"envs/calc/seed/.config/libreoffice/4/.lock",
|
|
"envs/calc/seed/Documents/.~lock.q1.ods#",
|
|
"envs/calc/seed/Documents/q1.ods.LCK",
|
|
];
|
|
expect(pointed).toContain(notReady);
|
|
expect(pointed).toContain(seededLockStartNotice(locks));
|
|
for (const lock of locks) expect(pointed).toContain(lock);
|
|
expect(pointed).not.toContain("Documents/notes.lck");
|
|
expect(unlocked).not.toContain(locks[0]);
|
|
expect(otherFailure).not.toContain(locks[0]);
|
|
});
|
|
|
|
it("points a final readiness failure at seeded locks", async () => {
|
|
const { runner } = await fakes({ readinessFails: true });
|
|
const bundle = await bundleFolder();
|
|
const seed = join(bundle, "envs", "calc", "seed", "Documents");
|
|
await mkdir(seed, { recursive: true });
|
|
await writeFile(join(seed, ".~lock.q1.ods#"), "lock");
|
|
const result = await (
|
|
await connect(runner.environment)
|
|
).callTool({
|
|
name: "start_build_computer",
|
|
arguments: { source: { kind: "bundle", path: bundle } },
|
|
});
|
|
const { error } = parseMcpToolError(result);
|
|
expect(error.message).toContain("display check failed");
|
|
expect(error.message).toContain(".~lock.q1.ods#");
|
|
});
|
|
|
|
it("keeps the end of a long build's output, where its failure is", async () => {
|
|
const { runner } = await fakes();
|
|
const client = await connect({
|
|
...runner.environment,
|
|
FAKE_CHATTER_BYTES: String(64 * 1024),
|
|
FAKE_FAIL: "Unable to locate package nosuchpkg",
|
|
});
|
|
const failed = await client.callTool({
|
|
name: "start_build_computer",
|
|
arguments: { source: { kind: "bundle", path: await bundleFolder() } },
|
|
});
|
|
const projected = JSON.stringify(parseMcpToolError(failed));
|
|
expect(projected).toContain("E: Unable to locate package nosuchpkg");
|
|
// The package-install chatter before it is what the bound drops.
|
|
expect(projected).not.toContain("chatter 00000:");
|
|
});
|
|
|
|
it("keeps the computer's id when its first screenshot does not fit the response", async () => {
|
|
const { computer, runner } = await fakes({
|
|
frameCharacters: 11 * 1024 * 1024,
|
|
});
|
|
const client = await connect({
|
|
...runner.environment,
|
|
REA_MCP_MAX_RESPONSE_BYTES: String(10 * 1024 * 1024),
|
|
});
|
|
const refused = parseMcpToolError(
|
|
await client.callTool({
|
|
name: "start_build_computer",
|
|
arguments: { source: { kind: "bundle", path: await bundleFolder() } },
|
|
}),
|
|
);
|
|
const { computer_id: computerId } = z
|
|
.object({ computer_id: z.uuid() })
|
|
.parse(
|
|
z.object({ result: z.unknown() }).parse(refused.error.details).result,
|
|
);
|
|
// The id names the running computer: it can be driven and closed.
|
|
const shot = await client.callTool({
|
|
name: "act_on_build_computer",
|
|
arguments: { computer_id: computerId, action: "wait", ms: 10 },
|
|
});
|
|
expect(shot.isError).not.toBe(true);
|
|
const closed = await client.callTool({
|
|
name: "close_build_computer",
|
|
arguments: { computer_id: computerId },
|
|
});
|
|
expect(closed.structuredContent).toMatchObject({ teardown: "agentenv" });
|
|
expect(computer.calls[0]?.name).toBe("computer_screenshot");
|
|
});
|
|
|
|
it("takes the first screenshot with the caller's options", async () => {
|
|
const { computer, runner } = await fakes();
|
|
const client = await connect(runner.environment);
|
|
const started = await client.callTool({
|
|
name: "start_build_computer",
|
|
arguments: {
|
|
source: { kind: "bundle", path: await bundleFolder() },
|
|
screenshot: { region: [0, 0, 640, 400], format: "jpeg", quality: 40 },
|
|
},
|
|
});
|
|
expect(started.isError, JSON.stringify(started)).not.toBe(true);
|
|
// The region crops the drawn frame REA waited for.
|
|
expect(computer.calls).toEqual([
|
|
{ name: "computer_screenshot", arguments: {} },
|
|
{
|
|
name: "computer_screenshot",
|
|
arguments: { region: [0, 0, 640, 400], format: "jpeg", quality: 40 },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("waits for the desktop to draw before it returns the first screenshot", async () => {
|
|
const { computer, runner } = await fakes({ blankFrames: 2 });
|
|
const client = await connect(runner.environment);
|
|
const { started } = await startFromBundle(client, computer);
|
|
|
|
expect(images(started)).toEqual([
|
|
{ type: "image", data: FAKE_FRAME_PNG, mimeType: "image/png" },
|
|
]);
|
|
expect(computer.calls).toEqual(
|
|
Array.from({ length: 3 }, () => ({
|
|
name: "computer_screenshot",
|
|
arguments: {},
|
|
})),
|
|
);
|
|
});
|
|
|
|
it("returns the latest frame when a retried screenshot outlasts the wait for the desktop, sending progress meanwhile", async () => {
|
|
// The third screenshot would answer a minute later, well past the 10 s wait.
|
|
const { computer, runner } = await fakes({
|
|
blankFrames: 10_000,
|
|
screenshotDelaysMs: [0, 0, 60_000],
|
|
});
|
|
const client = await connect(runner.environment);
|
|
const progress: { message?: string | undefined }[] = [];
|
|
const startedAt = Date.now();
|
|
const started = await client.callTool(
|
|
{
|
|
name: "start_build_computer",
|
|
arguments: { source: { kind: "bundle", path: await bundleFolder() } },
|
|
},
|
|
{ onprogress: (update) => progress.push(update) },
|
|
);
|
|
|
|
expect(started.isError, JSON.stringify(started)).not.toBe(true);
|
|
expect(Date.now() - startedAt).toBeLessThan(20_000);
|
|
expect(computer.calls).toHaveLength(3);
|
|
expect(images(started)).toHaveLength(1);
|
|
expect(progress.map(({ message }) => message)).toContainEqual(
|
|
expect.stringContaining("waiting for the desktop to draw"),
|
|
);
|
|
}, 40_000);
|
|
|
|
it("leaves the bundle folder's files as they were when it starts a computer from it", async () => {
|
|
const { runner } = await fakes();
|
|
// Python caches bytecode unless told not to; this host does not tell it.
|
|
const client = await connect({
|
|
...runner.environment,
|
|
PYTHONDONTWRITEBYTECODE: "",
|
|
});
|
|
const bundle = await bundleFolder();
|
|
await mkdir(join(bundle, "envs", "notes"), { recursive: true });
|
|
await writeFile(
|
|
join(bundle, "envs", "notes", "notes_app.py"),
|
|
"class Notes:\n pass\n",
|
|
);
|
|
const files = async () => (await readdir(bundle, { recursive: true })).sort();
|
|
const before = await files();
|
|
|
|
const id = await startBuildComputer(client, { kind: "bundle", path: bundle });
|
|
await callOk(client, "close_build_computer", { computer_id: id });
|
|
|
|
expect(
|
|
await readFile(
|
|
join(runner.state, "bundle", "envs", "notes", "notes_app.py"),
|
|
"utf8",
|
|
),
|
|
).toContain("class Notes");
|
|
expect(await files()).toEqual(before);
|
|
});
|
|
|
|
it("refuses to start without an absolute agentenv runner", async () => {
|
|
const client = await connect({ REA_AGENT_ENV_COMMAND: "agent-env" });
|
|
const refused = await client.callTool({
|
|
name: "start_build_computer",
|
|
arguments: { source: { kind: "bundle", path: await bundleFolder() } },
|
|
});
|
|
const { error } = parseMcpToolError(refused);
|
|
expect(error.code).toBe("capability_unavailable");
|
|
expect(JSON.stringify(error)).toContain("REA_AGENT_ENV_COMMAND");
|
|
});
|
|
|
|
it("removes the sandbox's containers by name and label when agentenv's teardown leaves it up", async () => {
|
|
const { computer, runner } = await fakes();
|
|
const docker = join(runner.state, "docker");
|
|
// agentenv runs a computer as agent-<sandbox id> without a label; only its other modes label containers.
|
|
await writeFile(
|
|
docker,
|
|
`#!/bin/sh\necho "$*" >> '${join(runner.state, "docker.log")}'\ncase "$4" in name=*) echo c0ffee ;; label=*) printf 'c0ffee\\nbead00\\n' ;; esac\nexit 0\n`,
|
|
);
|
|
await chmod(docker, 0o755);
|
|
const client = await connect({
|
|
...runner.environment,
|
|
FAKE_TEARDOWN_FAILS: "1",
|
|
REA_DOCKER_COMMAND: docker,
|
|
});
|
|
const { value } = await startFromBundle(client, computer);
|
|
const closed = await client.callTool({
|
|
name: "close_build_computer",
|
|
arguments: { computer_id: value.computer_id },
|
|
});
|
|
expect(closed.structuredContent).toEqual({
|
|
computer_id: value.computer_id,
|
|
teardown: "container_label",
|
|
});
|
|
expect(
|
|
(await readFile(join(runner.state, "docker.log"), "utf8")).split("\n"),
|
|
).toEqual([
|
|
"ps -aq --filter name=^agent-sbx-fake$",
|
|
"ps -aq --filter label=agentenv.sandbox=sbx-fake",
|
|
"rm -f c0ffee bead00",
|
|
"",
|
|
]);
|
|
});
|
|
|
|
it("keeps a computer whose teardown is unconfirmed so close can be retried", async () => {
|
|
const { computer, runner } = await fakes();
|
|
const down = join(runner.state, "docker-down");
|
|
const docker = join(runner.state, "docker");
|
|
await writeFile(down, "");
|
|
await writeFile(
|
|
docker,
|
|
`#!/bin/sh\n[ -e '${down}' ] && { echo "Cannot connect to the Docker daemon" >&2; exit 1; }\nexit 0\n`,
|
|
);
|
|
await chmod(docker, 0o755);
|
|
const client = await connect({
|
|
...runner.environment,
|
|
FAKE_TEARDOWN_FAILS: "1",
|
|
REA_DOCKER_COMMAND: docker,
|
|
});
|
|
const { value } = await startFromBundle(client, computer);
|
|
const close = () =>
|
|
client.callTool({
|
|
name: "close_build_computer",
|
|
arguments: { computer_id: value.computer_id },
|
|
});
|
|
const first = parseMcpToolError(await close());
|
|
expect(first.error.code).toBe("cleanup_incomplete");
|
|
expect(JSON.stringify(first)).toContain(
|
|
"Cannot connect to the Docker daemon",
|
|
);
|
|
expect(JSON.stringify(first)).toContain("label=agentenv.sandbox=sbx-fake");
|
|
expect(JSON.stringify(first)).toContain("name=^agent-sbx-fake$");
|
|
// The record stays, so a retry reaches the same sandbox once Docker answers.
|
|
await rm(down);
|
|
expect((await close()).structuredContent).toEqual({
|
|
computer_id: value.computer_id,
|
|
teardown: "container_label",
|
|
});
|
|
expect(await runner.notes("teardowns.jsonl")).toHaveLength(1);
|
|
});
|
|
|
|
it("stops the helper when the caller cancels a start", async () => {
|
|
const { runner } = await fakes();
|
|
const client = await connect({
|
|
...runner.environment,
|
|
FAKE_DEPLOY_SECONDS: "30",
|
|
});
|
|
const controller = new AbortController();
|
|
const call = client.callTool(
|
|
{
|
|
name: "start_build_computer",
|
|
arguments: { source: { kind: "bundle", path: await bundleFolder() } },
|
|
},
|
|
{ signal: controller.signal },
|
|
);
|
|
while ((await runner.notes("runs.jsonl")).length === 0) await delay(50);
|
|
controller.abort();
|
|
await expect(call).rejects.toThrow();
|
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
if ((await runner.notes("interrupted.jsonl")).length > 0) break;
|
|
await delay(50);
|
|
}
|
|
expect(await runner.notes("interrupted.jsonl")).toHaveLength(1);
|
|
});
|
|
|
|
/** An app whose reset keeps the computer unready for `polls` readiness polls, 500 ms apart. */
|
|
const slowApp = (polls: number): FakeApp => ({
|
|
name: "notes",
|
|
state: {},
|
|
seed: {},
|
|
methods: [],
|
|
unreadyAfterReset: polls,
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
failure: "refuses",
|
|
fail: (computer: FakeComputer) =>
|
|
computer.refuseNext("computer_screenshot", "no display to capture"),
|
|
message: "no display to capture",
|
|
},
|
|
{
|
|
failure: "loses the reply to",
|
|
fail: (computer: FakeComputer) => computer.loseNext("computer_screenshot"),
|
|
message: expect.stringMatching(/\S/),
|
|
},
|
|
])(
|
|
"keeps a reset applied when the computer $failure the screenshot after it",
|
|
async ({ fail, message }) => {
|
|
const app = slowApp(0);
|
|
const { computer, runner } = await fakes({ app });
|
|
const client = await connect(runner.environment);
|
|
const id = await startBuildComputer(client, {
|
|
kind: "bundle",
|
|
path: await bundleFolder(),
|
|
});
|
|
fail(computer);
|
|
const reset = await callOk(client, "act_on_build_computer", {
|
|
computer_id: id,
|
|
action: "reset",
|
|
screenshot: true,
|
|
});
|
|
const { result } = toolContract("act_on_build_computer").outputSchema.parse(
|
|
reset.structuredContent,
|
|
);
|
|
expect(result).toEqual({
|
|
reset: true,
|
|
ready_ms: expect.any(Number),
|
|
screenshot_error: message,
|
|
});
|
|
expect(images(reset)).toEqual([]);
|
|
expect(app.methods).toEqual(["data/reset"]);
|
|
expect(computer.calls.map(({ name }) => name).at(-1)).toBe(
|
|
"computer_screenshot",
|
|
);
|
|
},
|
|
);
|
|
|
|
it("runs one computer's calls in the order they arrive and another computer's beside them", async () => {
|
|
const app = slowApp(4);
|
|
const { runner } = await fakes({ app });
|
|
const other = await fakes();
|
|
const client = await connect(runner.environment);
|
|
const first = await startBuildComputer(client, {
|
|
kind: "bundle",
|
|
path: await bundleFolder(),
|
|
});
|
|
const second = await startBuildComputer(client, {
|
|
kind: "bundle",
|
|
path: await bundleFolder(other.computer),
|
|
});
|
|
const act = (id: string, action: string) =>
|
|
callOk(client, "act_on_build_computer", { computer_id: id, action });
|
|
const order: string[] = [];
|
|
const reset = act(first, "reset").then(() => order.push("reset"));
|
|
await vi.waitFor(() => expect(app.methods).toEqual(["data/reset"]));
|
|
const behind = act(first, "screenshot").then(() =>
|
|
order.push("screenshot behind the reset"),
|
|
);
|
|
await act(second, "screenshot");
|
|
order.push("other computer's screenshot");
|
|
await Promise.all([reset, behind]);
|
|
expect(order).toEqual([
|
|
"other computer's screenshot",
|
|
"reset",
|
|
"screenshot behind the reset",
|
|
]);
|
|
}, 30_000);
|
|
|
|
it("sends progress while an action waits for the call before it on its computer", async () => {
|
|
const app = slowApp(14);
|
|
const { runner } = await fakes({ app });
|
|
const client = await connect(runner.environment);
|
|
const id = await startBuildComputer(client, {
|
|
kind: "bundle",
|
|
path: await bundleFolder(),
|
|
});
|
|
const reset = callOk(client, "act_on_build_computer", {
|
|
computer_id: id,
|
|
action: "reset",
|
|
});
|
|
await vi.waitFor(() => expect(app.methods).toEqual(["data/reset"]));
|
|
const progress: unknown[] = [];
|
|
const waited = await client.callTool(
|
|
{
|
|
name: "act_on_build_computer",
|
|
arguments: { computer_id: id, action: "screenshot" },
|
|
},
|
|
{ onprogress: (update) => progress.push(update) },
|
|
);
|
|
expect(waited.isError, JSON.stringify(waited)).not.toBe(true);
|
|
expect(progress.length).toBeGreaterThan(0);
|
|
await reset;
|
|
}, 30_000);
|
|
|
|
it("cancels a computer's call in progress on close and refuses the calls waiting behind it", async () => {
|
|
const app = slowApp(600);
|
|
const { computer, runner } = await fakes({ app });
|
|
const client = await connect(runner.environment);
|
|
const id = await startBuildComputer(client, {
|
|
kind: "bundle",
|
|
path: await bundleFolder(),
|
|
});
|
|
const calls = computer.calls.length;
|
|
const call = (name: string, fields: Record<string, unknown> = {}) =>
|
|
client.callTool({ name, arguments: { computer_id: id, ...fields } });
|
|
const reset = call("act_on_build_computer", { action: "reset" });
|
|
await vi.waitFor(() => expect(app.methods).toEqual(["data/reset"]));
|
|
const waiting = [
|
|
call("act_on_build_computer", { action: "screenshot" }),
|
|
call("stop_build_capture"),
|
|
];
|
|
const closed = await callOk(client, "close_build_computer", {
|
|
computer_id: id,
|
|
});
|
|
expect(closed.structuredContent).toEqual({
|
|
computer_id: id,
|
|
teardown: "agentenv",
|
|
});
|
|
for (const result of await Promise.all([reset, ...waiting]))
|
|
expect(parseMcpToolError(result).error.code).toBe("cancelled");
|
|
expect(computer.calls).toHaveLength(calls);
|
|
expect(app.methods).toEqual(["data/reset"]);
|
|
expect(await runner.notes("teardowns.jsonl")).toHaveLength(1);
|
|
expect(
|
|
parseMcpToolError(await call("act_on_build_computer", { action: "wait" }))
|
|
.error.message,
|
|
).toContain("start_build_computer");
|
|
});
|