Files
reverse-engineering-anything/src/application/desktop/DesktopBuildComputers.ts
T
d2f9b8b7de feat(desktop): build several tasks from one capture, and measure each task's pass rate within a budget (#9216)
* docs(desktop): clarify gzip scrub limits

* fix(setup): own desktop build process cleanup

* fix(doctor): compare desktop settings with resolved defaults

* fix(desktop): refuse a build runtime whose export takes no task ends

Several tasks pass --task-end to the build computer's export, so a runtime
packed before it now fails at the start of the capture with the repack fix,
as a runtime without synthetic seeds already did, rather than at export.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(desktop): format the noisy-record draft files with the pinned oxfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(desktop): give the root-array draft test its own describe

The records describe grew past the 150-line lint limit with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(doctor): inspect client-recorded desktop runners

* fix(doctor): reuse matching runner probes

* fix(setup): propagate cancellation to desktop actions

* fix(desktop): rewrite a download's launch keys written with spaces around =

The desktop entry parser accepts `Exec = notes %F` and an indented
`TryExec =notes`, so the install script now matches those keys the same way
instead of leaving the original program in place. Adds the feature's tasks.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): keep why a measured run failed in its solve record

A solve that returned an outcome dropped the runner's error, so a run that
failed to deploy, solve or verify was retried with no reason recorded. The
record, the measure result and difficulty.json now carry it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(setup): stop Hopper on cancel, never report a cancelled setup ready, and keep a failed action's client setting

- The Hopper install gets setup's signal: Linux already honoured it, and the
  macOS installer now stops its downloads and stops before mounting or copying.
- runSetup checks the signal after the final doctor and close, so a
  cancellation there raises instead of returning ready.
- A failed pack or agentenv install no longer writes its archive or runner to
  client configs, so the setting the client already had stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(setup): keep the completed setup result in a cancellation after cleanup

A cancellation during the final doctor or host close carried only the applied
actions, dropping each client's result and backup path and the doctor report.
It now carries the whole result as setup_result, as doctor's cancellation
carries its report.

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

* fix(xml): avoid scanning selected ancestors per tag

* fix(desktop): exclude covered text runs from draft counts

* fix: preserve text when drafting desktop checks

* fix: restore nested desktop state aliases

* refactor: share UUID recognition across draft checks

* fix: retain multiline XML attribute context

* fix: retain relationship identifiers in task drafts

* docs: clarify desktop draft identifier handling

* test(desktop): type formatting run cases as pairs

* fix(desktop): carry document format into selector evaluation

* test(desktop): separate markup boundary regression group

* fix(desktop): propagate formats through draft selectors

* fix(desktop): switch selector format within archive entries

* test(desktop): remove duplicate seed filename case

* fix(setup): retain diagnostics when cancellation overlaps failure

* refactor(setup): keep command cancellation at its owner

* test(setup): verify cancellation at the application boundary

* test(desktop): pass JSON format in exported index parity

* docs(desktop): clarify task checks before scratch export

* fix(desktop): respell selected paths in file URLs

* test(desktop): group captured document spelling cases

* test(desktop): run installer scripts at boundary

* style(desktop): remove trailing blank line

* 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

* fix: align desktop numeric seed comparisons

* test(desktop): keep one draft exclusion fixture import

* test(desktop): retain log eligibility across draft test groups

* fix(doctor): resolve empty registered desktop runner

* 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: retain quoted identifier text in task drafts

* fix: recognize Unicode XML in desktop state readers

* fix(desktop): bound nested draft checks

* fix(desktop): explain missing arrays in truncated views

* fix(desktop): find XML type references in archives

* test(desktop): remove duplicated capture assertions

* fix(desktop): retain nested omitted text boundaries

* style(desktop): format draft value imports

* test(desktop): group model and draft value 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): retain placed-item sibling guards

* fix(desktop): preserve archive paths and bound doctor reads

* fix(doctor): refuse non-files before descriptor admission

* fix(setup): track remaining cleanup resources across retries

* test(desktop): specify XML format in heap pairing fixture

* fix(setup): skip Hopper launch after cancelled copy

* fix: preserve task measurement limits and cleanup

* fix(desktop): retain reported solve sandbox cleanup

* test(desktop): check retained log seed admission

* fix(doctor): query agentenv for model endpoint

* fix(doctor): preserve uncertainty for empty endpoint override

* 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

* test(desktop): remove duplicate history regression

* fix(desktop): align XML names with exported reader

* fix(desktop): create selected state alias targets

* fix(desktop): inspect supported runtime archives

* fix(desktop): keep ordinary text checks exact

* fix(desktop): verify registered build host paths

* fix(desktop): retain record identity in draft checks

* test(desktop): preserve text reference numbers

* fix(desktop): bound download install input reads

* fix(desktop): match placed notes by whole text

* test(desktop): match multiple task export results

* fix(desktop): retain failed measurement file closes

* fix(setup): retain completed actions on cancellation

* test(setup): retain cancelled installer result

* fix(desktop): scrub XML independently of reader compatibility

* fix(desktop): preserve file aliases and reader diagnostics

* style(desktop): format review fixes

* fix(desktop): inspect archives with registered runner

* test(desktop): verify registered archive interpreter

* style(desktop): format capture review fixes

* style(desktop): format review changes

* feat(desktop): target agentenv-framework 0.9.1464

Desktop bundles, setup's --install-agentenv and the doctor's exact-version
check now target 0.9.1464, which keeps an exported app's data/get call,
holds the computer fixture above boot dialogs, leaves an unresolved setting's
build unverified rather than failed, and mounts lock packages instead of
copying them into the image. The web environment lane stays on 0.9.1431.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): retain per-solve sandbox cleanup roots

* test(desktop): expect the solve cleanup helper among the shipped agentenv helpers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(desktop): clean up a stopped solve's sandboxes through agentenv 0.9.1482

agentenv-framework 0.9.1482 lists the local sandboxes under a root
(local_sandbox_ids) and rebuilds one killed while it wrote its port map for
teardown. The solve cleanup helper now terminates each sandbox agentenv
lists, keeping its Docker and host-process checks, and reports anything else
left under the root. Desktop bundles, setup's --install-agentenv and the
doctor's version check target 0.9.1482.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): verify a killed solve's cleanup once its sandbox root is empty

A solve's sandboxes are all local under its private root, and agentenv now
keeps a sandbox's folder until its teardown is confirmed. So when the helper
ends without a report and REA's cleanup empties that root, the cleanup is
verified: a cancelled solve ends as a cancellation and a solve past its
deadline as a failed run, rather than as cleanup the measure cannot vouch for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(desktop): target agentenv-framework 0.9.1485

0.9.1485 keeps a local sandbox's folder until its teardown is confirmed, which
the solve cleanup's verified result relies on. (0.9.1484 failed to publish.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): record a solve stopped before it reports as failed, not as a refused request

A helper killed from outside or stopped at its deadline left no report, so the
measure refused with invalid input and told the caller to fix its model
endpoint. A helper a signal ended now gives a failed solve of unknown cost,
which the measure records and stops on; one that exits on its own without a
report still ends the measure as a solve that could not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): check verifier behavior after selector changes

* test(windows): verify rejected writes during cleanup by effect

* fix(desktop): preserve selected alias target kinds in exports

* fix(cli): retain caller capture paths in environment builds

* fix(ghidra): classify oversized endpoint records consistently

* test: align model and Evidence expectations with current contracts

* fix(desktop): reconcile upstream sandbox cleanup recovery

* fix(docs): link cleanup qualification as repository evidence

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 05:26:28 -05:00

783 lines
26 KiB
TypeScript

import { randomUUID } from "node:crypto";
import {
extraPackagesOf,
type BuildSource,
} from "../../domain/desktop/appBuild.js";
import { counted } from "../../domain/desktop/appExportUnowned.js";
import { SETTLE_TIMING } from "../../domain/desktop/appSettle.js";
import {
driverCall,
type ComputerAction,
type ComputerActionInput,
type ScreenshotOptions,
} from "../../domain/desktop/computerActions.js";
import {
AnalysisError,
type AnalysisCapturedOutput,
} from "../../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { err, ok, type Result } from "../../domain/result.js";
import {
AgentEnvComputer,
AppDataRefusal,
ComputerStartFailure,
computerFailedOnReadiness,
type DriverResult,
} from "../../desktop/AgentEnvComputer.js";
import {
AGENTENV_PROVIDER,
ComputerHelper,
type ComputerRelease,
} from "../../desktop/AgentEnvComputerHelper.js";
import {
resolveBuildComputerEnvironment,
type BuildComputerDefaults,
} from "../../desktop/BuildComputerEnvironment.js";
import { buildSecretsDirectory } from "../../desktop/BuildSecretFiles.js";
import type { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
import type { ProgressReporter } from "../ProgressReporter.js";
import {
BuildComputerActionLog,
type ReadonlyActionLog,
} from "./DesktopBuildActionLog.js";
import { seededLockFiles, writeRequestBundle } from "./DesktopBuildBundle.js";
import { DesktopBuildCapture } from "./DesktopBuildCapture.js";
import { DesktopBuildOwnedWork } from "./DesktopBuildOwnedWork.js";
import {
measureInFreshComputers,
type MeasuredTask,
type MeasureRequest,
} from "./DesktopBuildSolves.js";
import {
DesktopBuildSecrets,
settingsEnvironment,
typeSecret,
} from "./DesktopBuildSecrets.js";
import type { BuildSettle } from "./DesktopBuildSettle.js";
import { DesktopBuildTurns } from "./DesktopBuildTurns.js";
import { DesktopBuildWitness } from "./DesktopBuildWitness.js";
import { ScreenJudgeProcess } from "../../desktop/ScreenJudgeProcess.js";
const START = "start_build_computer";
const ACT = "act_on_build_computer";
const CLOSE = "close_build_computer";
const MEASURE = "measure_build_task";
/** A result with the driver's content blocks, delivered after REA's own. */
export interface ForwardedResult<Value> {
readonly value: Value;
readonly content: DriverResult["content"];
}
/** What an action answers: its place in the log and the driver's structured result. */
type Acted = ForwardedResult<{
readonly computer_id: string;
readonly action: ComputerAction;
readonly action_index: number;
readonly result: Readonly<Record<string, JsonValue>> | null;
}>;
/** What later stages read of a live build computer: its action log and recording hooks. */
export interface BuildComputerSession {
readonly sandboxId: string;
readonly actions: ReadonlyActionLog;
readonly startRecording: AgentEnvComputer["startRecording"];
readonly endRecording: AgentEnvComputer["endRecording"];
readonly recordingSegments: AgentEnvComputer["recordingSegments"];
}
/** A computer REA owns: a live one, or a failed start whose cleanup is still unconfirmed. */
interface HeldComputer {
readonly computer: AgentEnvComputer | undefined;
/** Brings the computer down; absent when no helper was spawned. */
readonly release: (() => Promise<ComputerRelease>) | undefined;
readonly workspace: PrivateRuntimeRoot | undefined;
readonly actions: BuildComputerActionLog;
/** REA's capture of the app and the task's witness, on a live computer. */
readonly steps: BuildComputerSteps | undefined;
/** Every action, step and close on the computer takes its turn here. */
readonly turns: DesktopBuildTurns;
readonly cleanup: {
resources: readonly string[];
errors: Set<WeakRef<ProviderCleanupError>>;
};
closing: boolean;
}
/** What the build step tools work on: a live computer, its capture, its witness and the turns they take. */
export interface BuildComputerSteps {
readonly computer: AgentEnvComputer;
readonly capture: DesktopBuildCapture;
readonly witness: DesktopBuildWitness;
readonly turns: DesktopBuildTurns;
}
const unknownComputer = (operation: string) =>
new AnalysisInputError(operation, {
userMessage:
"No build computer has this id in this REA server; start one with start_build_computer.",
conformsToInputContract: true,
});
const failure = (
operation: string,
cause: unknown,
signal: AbortSignal,
): AnalysisError => {
if (cause instanceof AnalysisError) return cause;
if (signal.aborted)
return new AnalysisCancelledError(operation, { cause: signal.reason });
return new ProviderAdapterError(AGENTENV_PROVIDER, operation, {
userMessage: cause instanceof Error ? cause.message : String(cause),
cause,
});
};
/** The text of a driver's answer, such as a refusal's message. */
const driverMessage = (content: DriverResult["content"]): string =>
content
.flatMap((block) => (typeof block.text === "string" ? [block.text] : []))
.join(" ");
/**
* What a reset did. `data/reset` completing is the observation that the seed
* was restored; readiness and a screenshot after it can still fail or be
* cancelled without erasing that observation.
*/
type ResetCall =
| { readonly kind: "unfinished"; readonly cause: unknown }
| { readonly kind: "interrupted"; readonly cause: unknown }
| { readonly kind: "done"; readonly result: DriverResult };
/**
* Restore the app's data to its seed with `data/reset`, which relaunches the
* app, and wait until the computer reports ready again; a screenshot after,
* when asked.
*/
const reset = async (
computer: AgentEnvComputer,
fields: Readonly<Record<string, JsonValue>>,
signal: AbortSignal,
): Promise<ResetCall> => {
const startedAt = Date.now();
try {
await computer.appData("data/reset", signal);
} catch (cause: unknown) {
return { kind: "unfinished", cause };
}
try {
await computer.waitUntilReady(signal);
const ready = { reset: true, ready_ms: Date.now() - startedAt };
if (fields.screenshot !== true)
return {
kind: "done",
result: { isError: false, content: [], structured: ready },
};
// The reset has happened: a screenshot that fails after it is reported beside it, and only an abort propagates.
const shot = await computer
.callTool("computer_screenshot", {}, signal)
.catch((cause: unknown) => {
if (signal.aborted) throw cause;
return cause instanceof Error ? cause.message : String(cause);
});
if (typeof shot === "string" || shot.isError)
return {
kind: "done",
result: {
isError: false,
content: [],
structured: {
...ready,
screenshot_error:
typeof shot === "string" ? shot : driverMessage(shot.content),
},
},
};
return {
kind: "done",
result: {
isError: false,
content: shot.content,
structured: { ...ready, frame: shot.structured },
},
};
} catch (cause: unknown) {
return { kind: "interrupted", cause };
}
};
/** What every computer of one server shares: how checks settle and the secrets typed on them. */
interface SharedSettings {
readonly settle: BuildSettle;
readonly secrets: DesktopBuildSecrets;
}
/** A live computer as REA holds it: its action log, its capture and witness, and the turns all its calls take. */
const liveComputer = (
computer: AgentEnvComputer,
workspace: PrivateRuntimeRoot | undefined,
source: BuildSource,
{ settle, secrets }: SharedSettings,
judge: ScreenJudgeProcess,
): HeldComputer => {
const actions = new BuildComputerActionLog(secrets.redact);
const turns = new DesktopBuildTurns();
const capture = new DesktopBuildCapture(
computer,
source.kind === "request" ? source : undefined,
{
all: (signal) => secrets.all(signal),
keep: (handle, bytes) => secrets.keep(handle, bytes),
redact: secrets.redact,
get hasDeclaredSecret() {
return secrets.hasDeclaredSecret;
},
},
);
const witness = new DesktopBuildWitness(computer, actions, settle, {
bundle:
source.kind === "bundle"
? { path: source.path, judge: judge.judge }
: undefined,
redact: secrets.redactJson,
});
let closedComputer: Extract<ComputerRelease, { verified: true }> | undefined;
const attempt = async (
release: () => Promise<ComputerRelease>,
): Promise<ComputerRelease> => {
try {
return await release();
} catch (cause: unknown) {
return {
verified: false,
reason: cause instanceof Error ? cause.message : String(cause),
resources: [],
};
}
};
return {
computer,
release: async () => {
const [commands, readers, judgeResult] = await Promise.all([
attempt(() => capture.longRuns.release()),
attempt(() => witness.readers.release()),
attempt(() => judge.close()),
]);
if (!commands.verified || !readers.verified) {
const failed = [commands, readers, judgeResult].filter(
(result) => !result.verified,
);
return {
verified: false,
reason: failed.map((result) => result.reason).join("; "),
resources: [...new Set(failed.flatMap((result) => result.resources))],
};
}
const computerResult =
closedComputer === undefined
? await attempt(() => computer.close())
: closedComputer;
if (computerResult.verified) closedComputer = computerResult;
const failed = [judgeResult, computerResult].filter(
(result) => !result.verified,
);
if (failed.length === 0 && computerResult.verified) return computerResult;
return {
verified: false,
reason: failed.map((result) => result.reason).join("; "),
resources: [...new Set(failed.flatMap((result) => result.resources))],
};
},
workspace,
actions,
steps: {
computer,
capture,
witness,
turns,
},
turns,
cleanup: { resources: [], errors: new Set() },
closing: false,
};
};
/**
* A bundle's start that failed on readiness, naming the lock files its seed
* holds, which an app that finds its profile or document in use may never
* start past; any other failure as it is. Readiness is `reason`, not the
* message.
*/
/** The start failure's notice that the bundle seed still holds lock files. */
export const seededLockStartNotice = (locks: readonly string[]): string =>
`The bundle's seed holds lock files, which can keep the app from starting when it finds its profile or document in use: ${counted(locks)}. Delete them from the bundle, or export it again, which leaves them out, and start again.`;
const withSeededLocks = async (
source: BuildSource,
cause: unknown,
): Promise<unknown> => {
if (source.kind !== "bundle" || !computerFailedOnReadiness(cause))
return cause;
const locks = await seededLockFiles(source.path);
return locks.length === 0
? cause
: new ComputerStartFailure(
`${cause.message} ${seededLockStartNotice(locks)}`,
cause.remediation,
{ cause: cause.cause, reason: cause.reason },
);
};
/**
* Why a start failed, with the helper's output, and, when its teardown is
* unconfirmed, the id REA keeps that teardown under for close to retry.
*/
const startFailure = (
cause: unknown,
signal: AbortSignal,
output: AnalysisCapturedOutput | undefined,
kept: { readonly id: string; readonly release: ComputerRelease },
): AnalysisError => {
if (cause instanceof AnalysisError && kept.release.verified) return cause;
const options = {
cause,
...(output === undefined ? {} : { capturedOutput: output }),
...(kept.release.verified
? {}
: {
cleanup: {
reason: kept.release.reason,
resources: kept.release.resources,
},
diagnostics: { computer_id: kept.id },
remediationAction: `REA keeps this computer's teardown as ${kept.id}; once the reported problem is fixed, call close_build_computer with that computer_id.`,
}),
};
if (signal.aborted)
return new AnalysisCancelledError(START, {
...options,
cause: signal.reason,
});
return new ProviderAdapterError(AGENTENV_PROVIDER, START, {
...(cause instanceof ComputerStartFailure &&
cause.remediation !== undefined &&
kept.release.verified
? { remediationAction: cause.remediation }
: {}),
userMessage: cause instanceof Error ? cause.message : String(cause),
...options,
});
};
/**
* Owns the build computers one REA server keeps up: starts each from a
* request or a bundle, forwards actions to its driver with an action log,
* and brings each down on close or when the server closes. A start in
* progress is owned from its helper's spawn; one that fails without a
* confirmed teardown stays held so close and shutdown can retry it.
*/
export class DesktopBuildComputers {
readonly #computers = new Map<string, HeldComputer>();
readonly #work = new DesktopBuildOwnedWork();
readonly #secrets: DesktopBuildSecrets;
readonly #shared: SharedSettings;
constructor(
private readonly environment: Readonly<NodeJS.ProcessEnv>,
settle: BuildSettle = SETTLE_TIMING,
private readonly defaults: BuildComputerDefaults = {},
) {
this.#secrets = new DesktopBuildSecrets(buildSecretsDirectory(environment));
this.#shared = { settle, secrets: this.#secrets };
}
/** How results show the secrets typed on this server's computers. */
get redaction(): Pick<DesktopBuildSecrets, "redact" | "redactJson"> {
return this.#secrets;
}
async start(
input: {
readonly source: BuildSource;
readonly screenshot?: ScreenshotOptions | undefined;
},
signal: AbortSignal,
progress?: ProgressReporter,
): Promise<
Result<
ForwardedResult<{
readonly computer_id: string;
readonly sandbox_id: string;
readonly environment: string;
readonly extra_packages: string[];
readonly screenshot: Readonly<Record<string, JsonValue>> | null;
}>,
AnalysisError
>
> {
return this.#work.run(START, signal, (stopping) =>
this.#start(input, stopping, progress),
);
}
/** Measure a bundle's task in fresh computers of its own; closing REA stops it, tearing the solve's computer down. */
measure(
input: MeasureRequest,
signal: AbortSignal,
progress?: ProgressReporter,
): Promise<Result<MeasuredTask, AnalysisError>> {
return this.#work.run(MEASURE, signal, (stopping) =>
measureInFreshComputers(
input,
{
environment: this.environment,
defaults: this.defaults,
retainCleanup: (owner) => this.#work.retain(owner),
resources: this.#work.resources,
},
stopping,
progress,
),
);
}
async #start(
input: Parameters<DesktopBuildComputers["start"]>[0],
signal: AbortSignal,
progress: ProgressReporter | undefined,
): ReturnType<DesktopBuildComputers["start"]> {
const { source } = input;
const resolved = await this.#environment(source);
if (!resolved.ok) return resolved;
const environment = resolved.value;
let target: {
readonly bundle: string;
readonly name: string;
readonly workspace?: PrivateRuntimeRoot;
};
try {
target =
source.kind === "bundle"
? { bundle: source.path, name: source.path }
: await writeRequestBundle(source, environment);
} catch (cause: unknown) {
return err(failure(START, cause, signal));
}
const id = randomUUID();
let helper: ComputerHelper | undefined;
let computer: AgentEnvComputer | undefined;
try {
helper = await ComputerHelper.spawn({
bundle: target.bundle,
environment,
signal,
operation: START,
});
const connected = await AgentEnvComputer.connect(
helper,
signal,
progress === undefined
? undefined
: (message) => {
void progress
.report({ phase: START, completed: 0, total: null, message })
.catch(() => undefined);
},
);
computer = connected.computer;
const shot = driverCall("screenshot", input.screenshot ?? {});
if (!shot.ok) throw new TypeError("screenshot options are its own");
// The caller's region, format or quality apply to the drawn frame.
const taken =
Object.keys(shot.value.arguments).length === 0
? connected.frame
: await computer.callTool(
shot.value.tool,
shot.value.arguments,
signal,
);
this.#computers.set(
id,
liveComputer(
computer,
target.workspace,
source,
this.#shared,
new ScreenJudgeProcess(environment),
),
);
return ok({
value: {
computer_id: id,
sandbox_id: computer.sandboxId,
environment: target.name,
extra_packages: [...extraPackagesOf(source)],
screenshot: taken.isError ? null : taken.structured,
},
content: taken.content,
});
} catch (cause: unknown) {
const owner = helper;
const connected = computer;
this.#computers.set(id, {
computer: undefined,
release:
connected !== undefined
? () => connected.close()
: owner === undefined
? undefined
: () => owner.release(),
workspace: target.workspace,
actions: new BuildComputerActionLog(this.#secrets.redact),
steps: undefined,
turns: new DesktopBuildTurns(),
cleanup: { resources: [], errors: new Set() },
closing: true,
});
const release = await this.#retire(id);
const failed = signal.aborted
? cause
: await withSeededLocks(source, cause);
return err(
startFailure(failed, signal, owner?.output(), { id, release }),
);
}
}
/** Forward one action to the computer's driver and log it; a driver refusal is an error carrying its message. */
async act(
input: ComputerActionInput,
signal: AbortSignal,
): Promise<Result<Acted, AnalysisError>> {
const { computer_id: id, action, ...fields } = input;
const held = this.#computers.get(id);
const computer = held?.computer;
if (held === undefined || computer === undefined || held.closing)
return err(unknownComputer(ACT));
const call = driverCall(action, fields);
if (!call.ok)
return err(
new AnalysisInputError(
ACT,
{
userMessage:
call.error.missing === true
? `${action} needs ${call.error.field}.`
: `${action} does not take ${call.error.field}; it takes ${call.error.accepted.join(", ")}.`,
},
[
{
path: [call.error.field],
reason:
call.error.missing === true
? "missing_argument"
: "unknown_argument",
expected: [...call.error.accepted],
},
],
),
);
return held.turns.run(ACT, signal, (turn) =>
this.#forward(held, computer, { id, action, ...call.value }, turn),
);
}
/** Log and send one action on its turn, so the log holds no action still pending while another call runs. */
async #forward(
held: HeldComputer,
computer: AgentEnvComputer,
call: {
readonly id: string;
readonly action: ComputerAction;
readonly tool: string;
readonly arguments: Readonly<Record<string, JsonValue>>;
},
signal: AbortSignal,
): Promise<Result<Acted, AnalysisError>> {
const { id, action } = call;
// A secret is logged by its handle; only the computer receives its value.
const logged = held.actions.begin(action, call.tool, call.arguments);
if (action === "type_secret") {
const handle = String(call.arguments.secret);
const typed = await typeSecret(this.#secrets, computer, handle, signal);
logged.settle(typed.outcome);
return typed.answer(id, logged.index);
}
let result: DriverResult;
try {
if (action === "reset") {
const outcome = await reset(computer, call.arguments, signal);
if (outcome.kind === "unfinished") {
// A tagged app refusal is a failed reset. Any other lost reply,
// including a dropped connection after the seed was restored, stays
// unknown and blocks a near miss that would treat it as absent.
logged.settle("failed", {
unsettled: !(outcome.cause instanceof AppDataRefusal),
});
return err(failure(ACT, outcome.cause, signal));
}
// The seed restore is already observed. A later cancel or readiness
// failure must not make the witness treat that restore as absent.
logged.settle("applied");
if (outcome.kind === "interrupted")
return err(failure(ACT, outcome.cause, signal));
result = outcome.result;
} else {
result = await computer.callTool(call.tool, call.arguments, signal);
}
} catch (cause: unknown) {
logged.settle("failed");
return err(failure(ACT, cause, signal));
}
logged.settle(result.isError ? "refused" : "applied");
if (result.isError)
return err(
new AnalysisInputError(ACT, {
userMessage: `The computer refused the ${action}: ${driverMessage(result.content)}`,
remediationAction:
"Follow the computer's message; actions on frame coordinates need a screenshot first.",
conformsToInputContract: true,
}),
);
return ok({
value: {
computer_id: id,
action,
action_index: logged.index,
result: result.structured,
},
content: result.content,
});
}
/** The helper's environment: the build settings, then each variable a bundle's withheld settings read, from its secret. */
async #environment(
source: BuildSource,
): Promise<Result<Readonly<NodeJS.ProcessEnv>, AnalysisError>> {
const settings = await settingsEnvironment(this.#secrets, source);
if (!settings.ok) return settings;
const environment = await resolveBuildComputerEnvironment(
this.environment,
process.platform,
this.defaults,
);
return ok({ ...environment, ...settings.value });
}
/** Bring a computer down; one whose teardown is not confirmed stays held so close can be retried. */
async close(id: string): Promise<
Result<
{
readonly computer_id: string;
readonly teardown: "agentenv" | "container_label";
},
AnalysisError
>
> {
const held = this.#computers.get(id);
if (held === undefined) return err(unknownComputer(CLOSE));
held.closing = true;
// Tear down only once the call it cancels has ended; the calls waiting are refused.
const release = await held.turns.close(() => this.#retire(id));
held.cleanup.resources = release.verified ? [] : release.resources;
if (release.verified) {
for (const reference of held.cleanup.errors)
reference.deref()?.resolveCleanup();
held.cleanup.errors.clear();
return ok({ computer_id: id, teardown: release.teardown });
}
const failure = new ProviderCleanupError(
AGENTENV_PROVIDER,
release.resources,
{ reason: release.reason },
{ operation: CLOSE, remediationAction: release.reason },
);
failure.bindCleanupResourceOwner({
get cleanupResources() {
return held.cleanup.resources;
},
});
for (const reference of held.cleanup.errors)
if (reference.deref() === undefined)
held.cleanup.errors.delete(reference);
held.cleanup.errors.add(new WeakRef(failure));
return err(failure);
}
/** Release a held computer and its bundle folder; it stays held while either is unconfirmed. */
async #retire(id: string): Promise<ComputerRelease> {
const held = this.#computers.get(id);
if (held === undefined) return { verified: true, teardown: "agentenv" };
held.closing = true;
const release = (await held.release?.()) ?? {
verified: true,
teardown: "agentenv",
};
if (!release.verified) return release;
try {
await held.workspace?.close();
} catch (cause: unknown) {
return {
verified: false,
reason: `the build bundle folder could not be removed: ${cause instanceof Error ? cause.message : String(cause)}`,
resources: [held.workspace?.path ?? id],
};
}
this.#computers.delete(id);
return release;
}
/** Cancel and join the starts in progress, then bring every held computer down, as the server closes. */
async closeAll(): Promise<void> {
const failures = await this.#work.close();
for (const result of await Promise.all(
[...this.#computers.keys()].map((id) => this.close(id)),
))
if (!result.ok) failures.push(result.error);
try {
await this.#secrets.close();
} catch (cause: unknown) {
failures.push(
cause instanceof AnalysisError
? cause
: new ProviderAdapterError(AGENTENV_PROVIDER, "close_build_secrets", {
cause,
userMessage: "Could not close build secret files.",
}),
);
}
if (failures.length > 0)
throw new AggregateError(
failures,
"Build computer teardown is incomplete",
);
}
/** A live computer's capture and witness, for the build step tools; undefined for an unknown or closing id. */
steps(id: string): BuildComputerSteps | undefined {
const held = this.#computers.get(id);
return held === undefined || held.closing ? undefined : held.steps;
}
/** A live computer's action log and recording hooks, for the witness. */
session(id: string): BuildComputerSession | undefined {
const held = this.#computers.get(id);
const computer = held?.computer;
if (held === undefined || computer === undefined) return undefined;
return {
sandboxId: computer.sandboxId,
actions: held.actions,
startRecording: (...request) => computer.startRecording(...request),
endRecording: (...request) => computer.endRecording(...request),
recordingSegments: (...request) => computer.recordingSegments(...request),
};
}
}