feat(desktop): set up and check a desktop build host in one command (#9199)

* 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): keep a run's font on the run, not the frame's text

A formatting attribute such as Scribus FONT is not the document's words.
Joining it across runs made a frame-wide text check of ItalicBold that
failed when a run was split. The font stays a value check on its own run.

* fix(desktop): do not pair JSON records by a selector-shaped field

A field named @record_id matched the id suffix and became [@record_id=one],
which reads an XML attribute. Records paired while their pointers matched
nothing. Names that start with @ or > are not id fields.

* fix(desktop): count a save-numbered id once when pairing members

xml:id and draw:id values such as id123456 were both generated and
numbered, so the same selector was counted twice and refused. A text
edit of two elements keyed only by those ids now keeps its check.

* fix(desktop): count members of root JSON arrays

* fix(desktop): preserve selected state directory aliases

* docs(desktop): explain tool and skill refresh prerequisites

* fix(desktop): align nested state selections with aliases

* 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): exclude unseeded locks from task checks

* fix(desktop): preserve draft bounds and launch URLs

* fix(desktop): render draft values using their source format

* 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 captured URLs and user text

* fix(desktop): preserve local file URL authority

* 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): classify numeric timestamps using field context

* fix(desktop): retain context for generated draft values

* 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

* 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): isolate launcher URL encoding

* fix(desktop): retain clean text in noisy record fields

* fix(desktop): classify added line values in one pass

* fix(desktop): apply XML quote rules to text drafts

* fix(desktop): distinguish XML attributes from quoted text

* fix(desktop): preserve parsed XML line context

* Format appTaskChecks.ts with the pinned oxfmt

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

* fix(desktop): retain partial XML syntax facts

* fix(desktop): recognize markdown autolinks in drafts

* fix(desktop): reuse export exclusions in task drafts

* fix(desktop): report unchecked client build paths

* docs(desktop): clarify gzip scrub limits

* fix(desktop): reuse export exclusions in task drafts

* docs(desktop): clarify gzip scrub limits

* fix(setup): own desktop build process cleanup

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

* 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(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

* 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(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): retain record identity in draft checks

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

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

* style(desktop): format review fixes

* 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>
This commit is contained in:
authored and GitHub committed 2026-11-09 23:02:31 +08:00
1 parent 61ab3f0c4f
commit 240f08d406
45 files changed
+4868 -92

No files matched your search

@@ -5,9 +5,11 @@ task, such as "Build me an Inkscape environment where the agent changes a
poster's headline and saves it". You are the building agent: you drive the
app on a build computer through REA's MCP tools, write the task, export the
bundle and witness it in a fresh computer. REA runs no model. The tools are
MCP-only and need Docker, `REA_AGENT_ENV_COMMAND` and
`REA_BUILD_COMPUTER_REA_ARCHIVE` where the REA server starts; when a start
reports one missing, tell the person what to set and stop.
MCP-only and need a Linux host with Docker, agentenv and REA's packed
runtime. When a start reports one missing, tell the person to run
`rea doctor --provider desktop_build` and apply its fixes, or
`rea setup --client <client> --desktop-build` and restart the client, and
stop.
For the whole build:
@@ -32,6 +34,13 @@ start without asking:
and with which model.
4. If so, whether they want to review the task before those runs.
When you cannot ask, as in a headless `claude -p` run, do not stop: take
these defaults for whatever the request leaves open, say which you took in
your first message and in the report, and go on. Install from the Ubuntu
archive, make a synthetic seed, skip the review, and make 3 solver runs
within $4 in all, with the model the request names or else the runner's
default.
## When you are blocked
Stop, say what you need and wait only for:
@@ -565,10 +574,10 @@ Stop after three witnesses and report what still fails.
## Solver runs
Only if the requester asked for them. If they wanted to review the task
first, show them its prompt and checks and wait for their approval. Then run
the bundle within the budget, one run at a time, with the runner
`REA_AGENT_ENV_COMMAND` names:
Only if the requester asked for them, or by the headless default above. If
they wanted to review the task first, show them its prompt and checks and
wait for their approval. Then run the bundle within the budget, one run at a
time, with `REA_AGENT_ENV_COMMAND`'s runner, or else `agent-env` on PATH:
`agent-env run <bundle> --task <name> --agent claude-code --model <model>`.
The runner needs a model endpoint in your shell: `LITELLM_BASE_URL` and
`LITELLM_API_KEY`, or `base_url` and `api_key` under `[model]` in
+39 -1
View File
@@ -18,6 +18,43 @@ app, exporting its bundle and witnessing the task on such a computer are in
skill for doing all of it from a request is
[desktop builds](https://github.com/morluto/rea/blob/main/.agents/skills/reverse-engineer-anything/references/desktop-build.md).
## Set up a build host
On a Linux host with Node.js and Docker, from a REA
[checkout](getting-started/installation.md#run-rea-from-a-checkout):
```bash
rea setup --client claude_code --desktop-build --install-agentenv
rea doctor --provider desktop_build --client claude_code
```
Setup shows its plan and asks before it changes anything; `--dry-run` only
prints it and `--yes` approves it. It packs REA's runtime archive into
`~/.cache/rea/build-computer/rea-<version>.tar.xz` (under `$XDG_CACHE_HOME`
when set, `~/Library/Caches` on macOS), and packs again once REA's version
changes; an archive `REA_BUILD_COMPUTER_REA_ARCHIVE` already names is
recorded instead. It records `REA_AGENT_ENV_COMMAND`, `REA_DOCKER_COMMAND` and
`REA_BUILD_COMPUTER_REA_ARCHIVE` as absolute paths in that client's REA
registration, keeping a value the registration already has other than an
archive setup packed for an earlier REA version, and refreshes the skill.
`--install-agentenv` runs `uv tool install agentenv-framework==0.9.1431`
when `uv` is on `PATH`, and otherwise prints the command. Setup never
installs Docker or Node.js and never runs `sudo`. Restart the client
afterwards.
Doctor is read-only and checks, in order: a Linux amd64 or arm64 host,
Node.js, procps `ps`, the Docker CLI, a Docker daemon reachable without
`sudo`, 20 GB free where Docker keeps images, agentenv-framework 0.9.1431,
the runtime archive of this REA version, the build settings in the
registration or the environment, and the skill. Each failure names its fix.
It also reports, without counting them against `healthy`, whether a model
endpoint for solver runs is configured and which agentenv containers
earlier builds left. Pass `--json` for the full report.
When a setting is unset where the server starts, REA uses `agent-env` and
`docker` on `PATH` and the archive setup packed for this REA version. The
rest of this page is the same setup by hand.
## Requirements
- A Linux host on amd64 or arm64 with about 20 GB of free disk for the
@@ -58,7 +95,8 @@ that is already running.
## Claude Code
`rea setup --client claude_code` registers REA without these variables. Either
Without `--desktop-build`, `rea setup --client claude_code` registers REA
without these variables. Either
export them in the shell that starts `claude`, which starts REA's server with
its environment:
+1
View File
@@ -385,6 +385,7 @@ Select the part you want to check:
| One analysis engine | `rea doctor --provider ghidra --json` (or `hopper`, `ida`, `jadx`, `kuna`) |
| One agent registration | `rea doctor --client codex --json` (see [client IDs](#supported-agents)) |
| Installed workflow instructions | `rea doctor --skill --json` |
| Desktop app environment builds | `rea doctor --provider desktop_build --client claude_code --json` |
A scoped report has `scope.mode: "explicit"`. Its `scope_checks` determine
`healthy` and the exit status. Other checks appear in `informational_checks`;
@@ -171,8 +171,16 @@ export interface RecordedProviderSettings {
readonly differing: readonly string[];
/** Recorded as an expression, or in a map REA could not read. */
readonly unresolved: readonly string[];
/** Recorded with a value the caller owns, as an archive setup packed for another version. */
readonly owned?: readonly string[];
}
/** Whether a recorded value is the caller's own, which it may replace; the value never leaves the reader. */
export type OwnedProviderSetting = (name: string, value: string) => boolean;
/** Consume a resolved setting inside its reader without including its value in diagnostics. */
export type ProviderSettingObserver = (name: string, value: string) => void;
/**
* Compare settings selected in this process with a registration's own file.
* The server's environment also depends on what its client passes on, which is
@@ -184,6 +192,8 @@ export const recordedProviderSettings = (
clientName: string | undefined,
selected: Readonly<Record<string, string>>,
platform: NodeJS.Platform = process.platform,
owns?: OwnedProviderSetting,
observe?: ProviderSettingObserver,
): RecordedProviderSettings | undefined => {
const names = Object.keys(selected).sort(compareUnicodeCodePoints);
if (names.length === 0) return undefined;
@@ -203,6 +213,11 @@ export const recordedProviderSettings = (
),
platform,
);
for (const name of names) {
const value = recorded[name];
if (value !== undefined && expressions[name] === undefined)
observe?.(name, value);
}
const facts = {
unrecorded: names.filter(
(name) => recorded[name] === undefined && expressions[name] === undefined,
@@ -215,12 +230,17 @@ export const recordedProviderSettings = (
),
unresolved: names.filter((name) => expressions[name] !== undefined),
};
const owned = names.filter((name) => {
const value = recorded[name];
return value !== undefined && owns?.(name, value) === true;
});
return facts.unrecorded.length +
facts.differing.length +
facts.unresolved.length ===
facts.unresolved.length +
owned.length ===
0
? undefined
: facts;
: { ...facts, ...(owned.length === 0 ? {} : { owned }) };
};
/** Resolve the async provider-specific budget through its retained reader owner. */
@@ -40,6 +40,7 @@ import {
recordedProviderSettings,
} from "./ClientRegistrationEnvironment.js";
import type {
OwnedProviderSetting,
ProviderRequestBudgetResolver,
RecordedProviderSettings,
} from "./ClientRegistrationEnvironment.js";
@@ -110,6 +111,14 @@ interface ClientRegistrationReadOptions {
readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver;
/** Settings to compare with each registration's file; omitted skips that read. */
readonly providerSettings?: Readonly<Record<string, string>>;
/** Which recorded provider settings hold a value the caller owns. */
readonly ownedProviderSetting?: OwnedProviderSetting;
/** Inspect selected resolved settings without returning their values in status. */
readonly observeProviderSetting?: (
client: string,
name: string,
value: string,
) => void;
}
/** Registration facts shared by diagnostics and update maintenance. */
@@ -383,6 +392,11 @@ export const readClientRegistrationInspections = async (
client.name,
options.providerSettings,
options.platform,
options.ownedProviderSetting,
options.observeProviderSetting === undefined
? undefined
: (name, value) =>
options.observeProviderSetting!(client.name, name, value),
),
),
activation,
+451
View File
@@ -0,0 +1,451 @@
import { describe, expect, it, vi } from "vitest";
import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js";
import { createDoctorHostFixture } from "./Doctor.fixture.js";
import { runDoctor, type DoctorCheck, type DoctorHost } from "./Doctor.js";
import type { DesktopBuildFacts } from "./DesktopBuildDoctor.js";
const ready: DesktopBuildFacts = {
ps: { found: true, detail: "/usr/bin/ps" },
docker: { status: "found", path: "/usr/bin/docker", source: "path" },
daemon: { status: "reachable", rootDirectory: "/var/lib/docker" },
disk: { path: "/var/lib/docker", freeBytes: 64e9 },
agentEnv: {
status: "found",
path: "/home/ada/.local/bin/agent-env",
source: "path",
version: "0.9.1431",
},
archive: {
path: "/home/ada/.cache/rea/build-computer/rea-6.8.0.tar.xz",
source: "cache",
status: "current",
},
registrations: [
{ client: "claude_code", registered: true, unrecorded: [], unresolved: [] },
],
environmentSettings: [],
solveEndpoint: "LITELLM_BASE_URL and LITELLM_API_KEY",
leftoverContainers: [],
};
const buildHost = (
facts: Partial<DesktopBuildFacts> = {},
overrides: Partial<DoctorHost> = {},
): DoctorHost =>
createDoctorHostFixture({
platform: "linux",
architecture: "x64",
installationPaths: () => Promise.resolve([]),
clientRegistrations: () => Promise.resolve([]),
desktopBuildFacts: () => Promise.resolve({ ...ready, ...facts }),
...overrides,
});
const diagnose = (host: DoctorHost) =>
runDoctor(undefined, host, { providers: ["desktop_build"] });
const failed = async (
host: DoctorHost,
name: string,
): Promise<DoctorCheck & { readonly remediation: string }> => {
const report = await diagnose(host);
expect(report.healthy).toBe(false);
const check = report.scope_checks.find(
(candidate) => candidate.name === name,
);
expect(check).toMatchObject({ ok: false });
return check as DoctorCheck & { readonly remediation: string };
};
describe("rea doctor --provider desktop_build", () => {
it("requires the build checks in order and reports the rest", async () => {
const report = await diagnose(buildHost());
expect(report.healthy).toBe(true);
expect(
report.scope_checks
.map(({ name }) => name)
.filter((name) => name.startsWith("desktop_build-")),
).toEqual([
"desktop_build-host",
"desktop_build-node",
"desktop_build-ps",
"desktop_build-docker",
"desktop_build-docker-daemon",
"desktop_build-disk",
"desktop_build-agent-env",
"desktop_build-archive",
"desktop_build-registration",
"desktop_build-skill",
]);
expect(report.informational_checks).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "desktop_build-solve-endpoint",
ok: true,
}),
expect.objectContaining({
name: "desktop_build-leftover-containers",
ok: true,
}),
]),
);
});
it("reads no build facts outside its scope", async () => {
const facts = vi.fn(() => Promise.resolve(ready));
const report = await runDoctor(
undefined,
buildHost({}, { desktopBuildFacts: facts }),
);
expect(facts).not.toHaveBeenCalled();
expect(
report.checks.some(({ name }) => name.startsWith("desktop_build")),
).toBe(false);
});
});
describe("desktop build host checks", () => {
it.each([
["darwin", "arm64"],
["linux", "ia32"],
] as const)("refuses a %s %s host", async (platform, architecture) => {
const check = await failed(
buildHost({}, { platform, architecture }),
"desktop_build-host",
);
expect(check.classification).toBe("unsupported_host");
expect(check.remediation).toContain("Linux host on amd64 or arm64");
expect(check.remediation).toContain("its own architecture");
});
it("reuses the Node.js runtime check", async () => {
const check = await failed(
buildHost({}, { runtime: { id: "node", version: "20.11.0" } }),
"desktop_build-node",
);
expect(check.classification).toBe("missing_dependency");
expect(check.remediation).toBe(`Install ${SUPPORTED_NODE_VERSION_PROSE}.`);
});
it("names procps when ps is missing", async () => {
const check = await failed(
buildHost({ ps: { found: false, detail: "No ps is on PATH." } }),
"desktop_build-ps",
);
expect(check.classification).toBe("missing_dependency");
expect(check.remediation).toContain("sudo apt install procps");
});
it("links Docker Engine's install page when no Docker CLI is found", async () => {
const check = await failed(
buildHost({
docker: { status: "missing" },
daemon: { status: "unchecked" },
}),
"desktop_build-docker",
);
expect(check.classification).toBe("missing_dependency");
expect(check.remediation).toContain(
"https://docs.docker.com/engine/install/",
);
});
it("asks to join the docker group when the daemon refuses this user", async () => {
const check = await failed(
buildHost({
daemon: {
status: "permission_denied",
detail:
"permission denied while trying to connect to the Docker daemon socket",
},
}),
"desktop_build-docker-daemon",
);
expect(check.classification).toBe("config_drift");
expect(check.remediation).toContain("sudo usermod -aG docker $USER");
expect(check.remediation).toContain("log out and in again");
});
it("asks for 20 GB where Docker keeps its images", async () => {
const check = await failed(
buildHost({ disk: { path: "/var/lib/docker", freeBytes: 7e9 } }),
"desktop_build-disk",
);
expect(check.classification).toBe("missing_dependency");
expect(check.details).toEqual({ path: "/var/lib/docker", free_bytes: 7e9 });
expect(check.remediation).toContain(
"Free at least 20.0 GB in /var/lib/docker",
);
expect(check.remediation).toContain("data-root");
});
});
describe("desktop build tool checks", () => {
it.each([
[{ status: "missing" } as const],
[
{
status: "found",
path: "/home/ada/.local/bin/agent-env",
source: "path",
version: "0.9.1460",
} as const,
],
])("pins the agentenv-framework bundles target: %j", async (agentEnv) => {
const check = await failed(
buildHost({ agentEnv }),
"desktop_build-agent-env",
);
expect(check.classification).toBe("missing_dependency");
expect(check.remediation).toContain(
"uv tool install agentenv-framework==0.9.1431",
);
});
it("packs the runtime archive through setup when it is missing", async () => {
const check = await failed(
buildHost({
archive: { ...ready.archive, status: "missing" },
}),
"desktop_build-archive",
);
expect(check.classification).toBe("config_drift");
expect(check.remediation).toContain("rea setup");
expect(check.remediation).toContain("--desktop-build");
});
it("repacks a selected archive another REA version built", async () => {
const check = await failed(
buildHost({
archive: {
path: "/home/ada/rea-prod.tar.xz",
source: "environment",
status: "stale",
detail: "/home/ada/rea-prod.tar.xz holds REA 6.7.0.",
},
}),
"desktop_build-archive",
);
expect(check.remediation).toContain(
"npm run pack:build-computer-runtime -- /home/ada/rea-prod.tar.xz",
);
});
});
describe("desktop build registration checks", () => {
it("checks every selected client runner without exposing its path", async () => {
const report = await diagnose(
buildHost({
registrations: [
{
client: "claude_code",
registered: true,
unrecorded: [],
unresolved: [],
runner: { status: "current" },
},
{
client: "cursor",
registered: true,
unrecorded: [],
unresolved: [],
runner: { status: "stale", version: "0.0.1" },
},
],
}),
);
const check = report.scope_checks.find(
({ name }) => name === "desktop_build-agent-env",
);
expect(check).toMatchObject({
ok: false,
detail: expect.stringContaining("cursor's runner is stale"),
});
});
it("keeps an unresolved client runner unknown even when the terminal runner works", async () => {
const report = await diagnose(
buildHost({
registrations: [
{
client: "claude_code",
registered: true,
unrecorded: [],
unresolved: ["REA_AGENT_ENV_COMMAND"],
},
],
}),
);
expect(
report.scope_checks.find(
({ name }) => name === "desktop_build-agent-env",
),
).toMatchObject({
ok: false,
classification: "unknown",
detail: expect.stringContaining("runner is unknown"),
});
});
it("accepts a verified client runner while reporting its difference from the terminal", async () => {
const report = await diagnose(
buildHost({
registrations: [
{
client: "claude_code",
registered: true,
unrecorded: [],
unresolved: [],
differing: ["REA_AGENT_ENV_COMMAND"],
runner: { status: "current" },
},
],
}),
);
expect(
report.scope_checks.find(
({ name }) => name === "desktop_build-registration",
),
).toMatchObject({
ok: true,
detail: expect.stringContaining("REA_AGENT_ENV_COMMAND"),
});
});
});
describe("desktop build registration settings", () => {
it("reports differing or unchecked recorded paths instead of healthy", async () => {
for (const fact of [
{ differing: ["REA_AGENT_ENV_COMMAND"] },
{ unchecked: ["REA_BUILD_COMPUTER_REA_ARCHIVE"] },
]) {
const check = await failed(
buildHost({
registrations: [
{
client: "claude_code",
registered: true,
unrecorded: [],
unresolved: [],
...fact,
},
],
}),
"desktop_build-registration",
);
expect(check.detail).toContain(Object.values(fact)[0]![0]);
expect(check.details).toMatchObject({ registrations: [fact] });
}
});
it("names the registration's missing settings, never their values", async () => {
const check = await failed(
buildHost({
registrations: [
{
client: "claude_code",
registered: true,
unrecorded: [
"REA_DOCKER_COMMAND",
"REA_BUILD_COMPUTER_REA_ARCHIVE",
],
unresolved: [],
},
],
environmentSettings: ["REA_DOCKER_COMMAND"],
}),
"desktop_build-registration",
);
expect(check.classification).toBe("config_drift");
expect(check.details).toEqual({
registrations: [
{
client: "claude_code",
registered: true,
missing: ["REA_BUILD_COMPUTER_REA_ARCHIVE"],
unresolved: [],
},
],
});
expect(check.remediation).toContain(
"rea setup --client claude_code --desktop-build",
);
});
it("accepts build settings this environment sets", async () => {
const report = await diagnose(
buildHost({
registrations: [
{
client: "claude_code",
registered: true,
unrecorded: ["REA_AGENT_ENV_COMMAND"],
unresolved: [],
},
],
environmentSettings: ["REA_AGENT_ENV_COMMAND"],
}),
);
expect(report.scope_checks).toContainEqual(
expect.objectContaining({ name: "desktop_build-registration", ok: true }),
);
});
it("refreshes a skill that does not match this REA", async () => {
const check = await failed(
buildHost(
{},
{
installedSkillIdentity: () =>
Promise.resolve({ version: "60", toolCount: 1, canonical: false }),
},
),
"desktop_build-skill",
);
expect(check.classification).toBe("config_drift");
expect(check.remediation).toContain("rea setup");
});
});
describe("desktop build informational checks", () => {
it("reports a missing solve endpoint without deciding health", async () => {
const report = await diagnose(buildHost({ solveEndpoint: undefined }));
expect(report.healthy).toBe(true);
expect(report.informational_checks).toContainEqual(
expect.objectContaining({
name: "desktop_build-solve-endpoint",
ok: false,
remediation: expect.stringContaining(
"LITELLM_BASE_URL and LITELLM_API_KEY",
),
}),
);
});
it("reports an unavailable endpoint query as unknown", async () => {
const report = await diagnose(buildHost({ solveEndpoint: null }));
expect(report.informational_checks).toContainEqual(
expect.objectContaining({
name: "desktop_build-solve-endpoint",
ok: false,
classification: "unknown",
}),
);
});
it("names the exact command that removes leftover build containers", async () => {
const report = await diagnose(
buildHost({ leftoverContainers: ["agent-0f3a", "agent-91bc"] }),
);
expect(report.healthy).toBe(true);
expect(report.informational_checks).toContainEqual(
expect.objectContaining({
name: "desktop_build-leftover-containers",
ok: false,
remediation: expect.stringContaining(
"docker rm -f agent-0f3a agent-91bc",
),
}),
);
});
});
+457
View File
@@ -0,0 +1,457 @@
import { DESKTOP_EXPORT_FRAMEWORK_VERSION } from "../domain/desktop/appExport.js";
import type { execFileOutput } from "../process/ExecFileOutput.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js";
import type { DoctorCheck, DoctorHost, DoctorSkillState } from "./Doctor.js";
import { nodeCheck } from "./DoctorDiagnostics.js";
import { DESKTOP_BUILD_PROVIDER_ID } from "./DoctorProviderIds.js";
import type { DoctorScopeReport } from "./DoctorScope.js";
/** Free space agentenv's images need where Docker keeps them. */
export const DESKTOP_BUILD_MIN_FREE_BYTES = 20 * 1000 ** 3;
/** The command that installs the agentenv-framework release bundles target. */
export const AGENTENV_INSTALL_COMMAND = `uv tool install agentenv-framework==${DESKTOP_EXPORT_FRAMEWORK_VERSION}`;
/** How a build setting's program was found: set in the environment, or on PATH. */
export type DesktopBuildCommand =
| {
readonly status: "found";
readonly path: string;
readonly source: "environment" | "path";
}
| { readonly status: "missing" }
| { readonly status: "invalid"; readonly value: string };
/** What a desktop build host has, read without changing anything. */
export interface DesktopBuildFacts {
readonly ps: { readonly found: boolean; readonly detail: string };
readonly docker: DesktopBuildCommand;
readonly daemon:
| { readonly status: "reachable"; readonly rootDirectory: string }
| {
readonly status: "permission_denied" | "unreachable";
readonly detail: string;
}
| { readonly status: "unchecked" };
readonly disk:
| { readonly path: string; readonly freeBytes: number }
| { readonly path: string; readonly error: string };
readonly agentEnv: DesktopBuildCommand & {
readonly version?: string | undefined;
};
readonly archive: {
readonly path: string;
readonly source: "environment" | "cache";
readonly status: "current" | "missing" | "stale" | "unknown";
readonly detail?: string;
};
/** Each considered client's registration, by setting names only. */
readonly registrations: readonly {
readonly client: string;
readonly registered: boolean;
readonly unrecorded: readonly string[];
readonly unresolved: readonly string[];
readonly differing?: readonly string[];
readonly unchecked?: readonly string[];
/** Version check of this client's recorded runner, without the recorded path. */
readonly runner?: {
readonly status: "current" | "stale" | "invalid" | "unknown";
readonly version?: string;
};
}[];
/** Build settings this terminal's environment sets. */
readonly environmentSettings: readonly string[];
/** Where a solve endpoint is configured; null means its configuration is unknown. */
readonly solveEndpoint: string | null | undefined;
/** Names of agentenv sandbox containers; undefined when Docker was not reachable. */
readonly leftoverContainers: readonly string[] | undefined;
}
/** What a facts reader gets from doctor: its environment, bounded runner and registration reader. */
export interface DesktopBuildFactsInput {
readonly signal: AbortSignal;
readonly resources: ArtifactResourceScope;
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly platform: NodeJS.Platform;
readonly run: typeof execFileOutput;
readonly clientIds: readonly string[];
readonly registrations: (
clientIds: readonly string[] | undefined,
settings: Readonly<Record<string, string>>,
owned?: undefined,
observe?: (client: string, name: string, value: string) => void,
) => Promise<readonly ClientRegistrationStatus[]>;
}
/** What doctor already knows that the build checks reuse. */
export interface DesktopBuildContext {
readonly host: Pick<DoctorHost, "platform" | "architecture" | "runtime">;
readonly clients: readonly string[];
readonly skill: {
readonly state: DoctorSkillState;
readonly remediation: string | null;
};
}
const INFORMATIONAL = new Set([
`${DESKTOP_BUILD_PROVIDER_ID}-solve-endpoint`,
`${DESKTOP_BUILD_PROVIDER_ID}-leftover-containers`,
]);
/** Whether a desktop build check reports without deciding health. */
export const isDesktopBuildInformationalCheck = (name: string): boolean =>
INFORMATIONAL.has(name);
const named = (name: string) => `${DESKTOP_BUILD_PROVIDER_ID}-${name}`;
const result = (
name: string,
ok: boolean,
detail: string,
outcome: {
readonly classification: Exclude<DoctorCheck["classification"], "healthy">;
readonly remediation: string;
readonly details?: DoctorCheck["details"];
},
): DoctorCheck => ({
name: named(name),
ok,
classification: ok ? "healthy" : outcome.classification,
detail,
...(outcome.details === undefined ? {} : { details: outcome.details }),
...(ok ? {} : { remediation: outcome.remediation }),
});
/** The desktop build checks a doctor scope selects, read through its host. */
export const selectedDesktopBuildChecks = async (
host: DoctorHost,
scope: DoctorScopeReport,
skill: DesktopBuildContext["skill"],
): Promise<readonly DoctorCheck[]> =>
scope.providers.includes(DESKTOP_BUILD_PROVIDER_ID) &&
host.desktopBuildFacts !== undefined
? desktopBuildChecks(await host.desktopBuildFacts(scope.clients), {
host,
clients: scope.clients,
skill,
})
: [];
const setupCommand = (clients: readonly string[]): string =>
clients.length === 1
? `rea setup --client ${clients[0]} --desktop-build`
: "rea setup --client <client> --desktop-build";
/** The desktop build checks, required first and informational last. */
export const desktopBuildChecks = (
facts: DesktopBuildFacts,
context: DesktopBuildContext,
): readonly DoctorCheck[] => [
hostCheck(context.host),
{ ...nodeCheck(context.host), name: named("node") },
result("ps", facts.ps.found, facts.ps.detail, {
classification: "missing_dependency",
remediation:
"Install procps, whose ps REA's process supervisor runs: sudo apt install procps.",
}),
dockerCheck(facts.docker),
daemonCheck(facts.daemon),
diskCheck(facts.disk),
agentEnvCheck(facts),
archiveCheck(facts.archive, context.clients),
registrationCheck(facts, context.clients),
skillCheck(context.skill),
solveEndpointCheck(facts.solveEndpoint),
leftoverContainersCheck(facts.leftoverContainers),
];
const hostCheck = (host: DesktopBuildContext["host"]): DoctorCheck =>
result(
"host",
host.platform === "linux" &&
(host.architecture === "x64" || host.architecture === "arm64"),
`${host.platform} ${host.architecture}`,
{
classification: "unsupported_host",
remediation:
"Builds run on a Linux host on amd64 or arm64 and target its own architecture; run REA on such a host.",
},
);
const commandDetail = (command: DesktopBuildCommand, name: string): string =>
command.status === "found"
? `${command.path} (${command.source === "environment" ? name : "PATH"})`
: command.status === "invalid"
? `${name}=${command.value} is not an absolute path to a program.`
: `${name} is unset and none is on PATH.`;
const dockerCheck = (docker: DesktopBuildCommand): DoctorCheck =>
result(
"docker",
docker.status === "found",
commandDetail(docker, "REA_DOCKER_COMMAND"),
{
classification:
docker.status === "invalid" ? "config_drift" : "missing_dependency",
remediation:
docker.status === "invalid"
? "Set REA_DOCKER_COMMAND to the absolute path of the Docker CLI, or unset it to use docker on PATH."
: "Install Docker Engine: https://docs.docker.com/engine/install/",
},
);
const daemonCheck = (daemon: DesktopBuildFacts["daemon"]): DoctorCheck =>
result(
"docker-daemon",
daemon.status === "reachable",
daemon.status === "reachable"
? `Docker keeps its data in ${daemon.rootDirectory}.`
: daemon.status === "unchecked"
? "Not checked without a Docker CLI."
: daemon.detail,
daemon.status === "unreachable"
? {
classification: "runtime_failure",
remediation:
"Start Docker (sudo systemctl start docker), then rerun rea doctor --provider desktop_build.",
}
: {
classification: "config_drift",
remediation:
"Let your user reach Docker without sudo: sudo usermod -aG docker $USER, then log out and in again.",
},
);
const diskCheck = (disk: DesktopBuildFacts["disk"]): DoctorCheck => {
const gb = (bytes: number) => `${(bytes / 1000 ** 3).toFixed(1)} GB`;
return "error" in disk
? result("disk", false, `${disk.path}: ${disk.error}`, {
classification: "unknown",
remediation: `Check that ${disk.path} is readable, then rerun rea doctor --provider desktop_build.`,
})
: result(
"disk",
disk.freeBytes >= DESKTOP_BUILD_MIN_FREE_BYTES,
`${gb(disk.freeBytes)} free in ${disk.path}.`,
{
classification: "missing_dependency",
remediation: `Free at least ${gb(DESKTOP_BUILD_MIN_FREE_BYTES)} in ${disk.path}, or move Docker's data root (data-root in /etc/docker/daemon.json) to a larger disk.`,
details: { path: disk.path, free_bytes: disk.freeBytes },
},
);
};
const agentEnvCheck = (facts: DesktopBuildFacts): DoctorCheck => {
const { agentEnv } = facts;
const clients = facts.registrations.filter(({ registered }) => registered);
if (
clients.some(
({ runner, unresolved }) =>
runner !== undefined || unresolved.includes("REA_AGENT_ENV_COMMAND"),
)
) {
const rows = clients.map(({ client, runner, unresolved }) => ({
client,
...(runner ??
(unresolved.includes("REA_AGENT_ENV_COMMAND")
? { status: "unknown" as const }
: agentEnv.status !== "found"
? { status: "invalid" as const }
: agentEnv.version === undefined
? { status: "unknown" as const }
: {
status:
agentEnv.version === DESKTOP_EXPORT_FRAMEWORK_VERSION
? ("current" as const)
: ("stale" as const),
version: agentEnv.version,
})),
}));
const healthy = rows.every(({ status }) => status === "current");
return result(
"agent-env",
healthy,
rows
.map(({ client, status }) => `${client}'s runner is ${status}.`)
.join(" "),
{
classification: rows.some(({ status }) => status === "unknown")
? "unknown"
: "missing_dependency",
remediation: `${AGENTENV_INSTALL_COMMAND}, or rea setup --desktop-build --install-agentenv.`,
details: {
expected_version: DESKTOP_EXPORT_FRAMEWORK_VERSION,
clients: rows,
},
},
);
}
const pinned = agentEnv.version === DESKTOP_EXPORT_FRAMEWORK_VERSION;
const detail =
agentEnv.status !== "found"
? commandDetail(agentEnv, "REA_AGENT_ENV_COMMAND")
: `${commandDetail(agentEnv, "REA_AGENT_ENV_COMMAND")} reports ${agentEnv.version === undefined ? "no version" : `agentenv-framework ${agentEnv.version}`}; bundles target ${DESKTOP_EXPORT_FRAMEWORK_VERSION}.`;
return result("agent-env", agentEnv.status === "found" && pinned, detail, {
classification:
agentEnv.status === "invalid" ? "config_drift" : "missing_dependency",
remediation:
agentEnv.status === "invalid"
? `Set REA_AGENT_ENV_COMMAND to the absolute path of agent-env, or unset it to use agent-env on PATH. To install it: ${AGENTENV_INSTALL_COMMAND}`
: `${AGENTENV_INSTALL_COMMAND}, or rea setup --desktop-build --install-agentenv.`,
details: { expected_version: DESKTOP_EXPORT_FRAMEWORK_VERSION },
});
};
const archiveCheck = (
archive: DesktopBuildFacts["archive"],
clients: readonly string[],
): DoctorCheck =>
result(
"archive",
archive.status === "current",
archive.detail ??
(archive.status === "current"
? `${archive.path} holds this REA version.`
: `${archive.path} is missing.`),
{
classification: archive.status === "unknown" ? "unknown" : "config_drift",
remediation:
archive.source === "environment"
? `Repack it with npm run pack:build-computer-runtime -- ${archive.path}, or unset REA_BUILD_COMPUTER_REA_ARCHIVE and run ${setupCommand(clients)}.`
: `Run ${setupCommand(clients)} to pack it.`,
details: { path: archive.path, source: archive.source },
},
);
const registrationCheck = (
facts: DesktopBuildFacts,
clients: readonly string[],
): DoctorCheck => {
const set = new Set(facts.environmentSettings);
const gaps = facts.registrations.map(
({
client,
registered,
unrecorded,
unresolved,
differing = [],
unchecked = [],
runner,
}) => ({
client,
registered,
missing: unrecorded.filter((name) => !set.has(name)),
unresolved: [...unresolved],
...(differing.length === 0 ? {} : { differing: [...differing] }),
...(unchecked.length === 0 ? {} : { unchecked: [...unchecked] }),
...(runner === undefined ? {} : { runner }),
}),
);
const failing = gaps.filter(
({ registered, missing, differing, runner }) =>
!registered ||
missing.length > 0 ||
(differing ?? []).some(
(name) =>
name !== "REA_AGENT_ENV_COMMAND" || runner?.status !== "current",
),
);
const unknown = gaps.filter(
({ unresolved, unchecked }) =>
unresolved.length > 0 || (unchecked?.length ?? 0) > 0,
);
const repair = failing[0]?.client ?? unknown[0]?.client;
return result(
"registration",
gaps.length > 0 && failing.length === 0 && unknown.length === 0,
gaps.length === 0
? "No REA registration was found."
: gaps.map(registrationDetail).join(" "),
{
classification:
failing.length === 0 && gaps.length > 0 ? "unknown" : "config_drift",
remediation: `Run ${setupCommand(repair === undefined ? clients : [repair])}, then restart the client.`,
details: { registrations: gaps },
},
);
};
const registrationDetail = (gap: {
readonly client: string;
readonly registered: boolean;
readonly missing: readonly string[];
readonly unresolved: readonly string[];
readonly differing?: readonly string[];
readonly unchecked?: readonly string[];
}): string => {
if (!gap.registered) return `${gap.client} does not register REA.`;
const details = [
...(gap.missing.length === 0
? []
: [
`${gap.client}'s registration does not record ${gap.missing.join(", ")}, and this environment does not set them.`,
]),
...(gap.unresolved.length === 0
? []
: [`REA could not read ${gap.client}'s ${gap.unresolved.join(", ")}.`]),
...(gap.differing === undefined
? []
: [
`${gap.client}'s registration records different values for ${gap.differing.join(", ")}.`,
]),
...(gap.unchecked === undefined
? []
: [
`REA did not check ${gap.client}'s recorded ${gap.unchecked.join(", ")} against the paths used in this terminal.`,
]),
];
return details.length > 0
? details.join(" ")
: `${gap.client}'s registration records the build settings, or this environment sets them.`;
};
const skillCheck = (skill: DesktopBuildContext["skill"]): DoctorCheck =>
result(
"skill",
skill.state === "aligned",
`The installed REA skill, with its desktop-build reference, is ${skill.state}.`,
{
classification: skill.state === "unknown" ? "unknown" : "config_drift",
remediation:
skill.remediation ?? "Run rea setup --skill to update the REA skill.",
},
);
const solveEndpointCheck = (where: string | null | undefined): DoctorCheck =>
result(
"solve-endpoint",
where !== undefined && where !== null,
where === null
? "REA could not determine whether agentenv has a model endpoint configured."
: where === undefined
? "No model endpoint is configured for solver runs."
: `A model endpoint is configured in ${where}; its availability was not checked.`,
{
classification: where === null ? "unknown" : "missing_dependency",
remediation:
"For solver runs, export LITELLM_BASE_URL and LITELLM_API_KEY in the shell that starts the agent, or set base_url and api_key under [model] in .agentenv/config.toml.",
},
);
const leftoverContainersCheck = (
containers: readonly string[] | undefined,
): DoctorCheck =>
result(
"leftover-containers",
containers === undefined || containers.length === 0,
containers === undefined
? "Not checked without a reachable Docker daemon."
: containers.length === 0
? "No agentenv sandbox containers are left."
: `${String(containers.length)} agentenv sandbox containers remain: ${containers.join(", ")}.`,
{
classification: "config_drift",
remediation: `If no build is running, remove them: docker rm -f ${(containers ?? []).join(" ")}`,
},
);
+50 -3
View File
@@ -56,7 +56,10 @@ import {
type ClientRegistrationStatus,
type UnhealthyClientRegistrationStatus,
} from "./ClientRegistrationStatus.js";
import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js";
import type {
OwnedProviderSetting,
ProviderRequestBudgetResolver,
} from "./ClientRegistrationEnvironment.js";
import { registrationSpelledSettings } from "./SetupRegistrationEnvironment.js";
import {
collectDoctorDiagnostics,
@@ -67,6 +70,11 @@ import {
scopeDoctorChecks,
type DoctorScopeReport,
} from "./DoctorScope.js";
import {
selectedDesktopBuildChecks,
type DesktopBuildFacts,
type DesktopBuildFactsInput,
} from "./DesktopBuildDoctor.js";
/** One actionable environment diagnostic. */
export interface DoctorCheck {
@@ -152,8 +160,16 @@ export interface DoctorHost {
clientRegistrations?(
selectedClientIds?: readonly string[],
providerSettings?: Readonly<Record<string, string>>,
ownedProviderSetting?: OwnedProviderSetting,
observeProviderSetting?: (
client: string,
name: string,
value: string,
) => void,
): Promise<readonly ClientRegistrationStatus[]>;
ilspyCmdVersion?(path: string): Promise<string | undefined>;
/** Desktop build host facts; read only when that scope is selected. */
desktopBuildFacts?(clientIds: readonly string[]): Promise<DesktopBuildFacts>;
}
/** Structured result returned by the read-only doctor workflow. */
@@ -299,6 +315,7 @@ const inspectDoctor = async (
...identity.checks,
...registrationAdvisoryChecks(identity.value.registrations),
...registrationEnvironmentChecks(identity.value.registrations, selections),
...(await selectedDesktopBuildChecks(host, scope, identity.value.skill)),
];
const providers = {
hopperAvailable,
@@ -636,6 +653,9 @@ export interface SystemDoctorHostOptions {
resourceScope: RetainedResourceScope,
signal: AbortSignal,
) => Promise<DoctorCheck>;
readonly desktopBuildFacts?: (
input: DesktopBuildFactsInput,
) => Promise<DesktopBuildFacts>;
}
/** Create diagnostics backed by the current process and host commands. */
@@ -719,7 +739,7 @@ export const systemDoctorHost = (
resourceScope,
inspectionSignal,
);
return {
const host: DoctorHost = {
...(options.signal === undefined ? {} : { signal: options.signal }),
platform,
architecture,
@@ -893,12 +913,21 @@ export const systemDoctorHost = (
throw failure;
}
},
clientRegistrations: (selectedClientIds, providerSettings) =>
clientRegistrations: (
selectedClientIds,
providerSettings,
ownedProviderSetting,
observeProviderSetting,
) =>
readClientRegistrationStatuses(homeDirectory, undefined, {
platform,
environment,
resources,
...(selectedClientIds === undefined ? {} : { selectedClientIds }),
...(ownedProviderSetting === undefined ? {} : { ownedProviderSetting }),
...(observeProviderSetting === undefined
? {}
: { observeProviderSetting }),
...(providerSettings === undefined
? {}
: {
@@ -914,6 +943,24 @@ export const systemDoctorHost = (
}),
}),
};
const readFacts = options.desktopBuildFacts;
return readFacts === undefined
? host
: {
...host,
desktopBuildFacts: (clientIds) =>
resources.run(() =>
readFacts({
environment,
platform,
signal: inspectionSignal,
resources,
run: hostExecFileOutput,
clientIds,
registrations: host.clientRegistrations!,
}),
),
};
};
const readMacosVersion = async (
+8 -4
View File
@@ -5,6 +5,7 @@ import {
import { hopperLauncherDiscovery } from "./HopperLauncherDiscovery.js";
import { macosMajorVersion } from "../domain/macosVersion.js";
import { LINUX_HOPPER_SETUP_HOSTS, supportsReaHost } from "./LinuxHopper.js";
import { isDesktopBuildCheck } from "./DoctorProviderIds.js";
import type {
DoctorCheck,
DoctorHost,
@@ -64,16 +65,17 @@ export const doctorHealthy = (
| undefined;
},
): boolean => {
const audited = checks.filter(({ name }) => !isDesktopBuildCheck(name));
if (providers.providerInspections === undefined)
return checks.every(({ ok }) => ok);
const providerChecks = checks.filter(
return audited.every(({ ok }) => ok);
const providerChecks = audited.filter(
({ name }) =>
name.startsWith("hopper") ||
providers.providerInspections?.some((inspection) =>
providerCheckName(inspection.id, name),
) === true,
);
const coreHealthy = checks
const coreHealthy = audited
.filter((candidate) => !providerChecks.includes(candidate))
.every(({ ok }) => ok);
const configuredProvidersHealthy = providerChecks
@@ -96,7 +98,9 @@ export const doctorHealthy = (
};
/** Runtime support follows the shared rule, which judges Bun by its Node.js compatibility. */
const nodeCheck = ({ runtime }: DoctorHost): DoctorCheck => {
export const nodeCheck = ({
runtime,
}: Pick<DoctorHost, "runtime">): DoctorCheck => {
const failure = {
remediation: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`,
classification: "missing_dependency",
+9 -1
View File
@@ -1,8 +1,16 @@
/** Deep-analysis providers whose readiness can be selected explicitly by doctor. */
/** Doctor's id for the desktop build host scope. */
export const DESKTOP_BUILD_PROVIDER_ID = "desktop_build";
/** Deep-analysis providers and the desktop build host, whose readiness doctor can select explicitly. */
export const DOCTOR_PROVIDER_IDS = [
"hopper",
"ghidra",
"ida",
"jadx",
"kuna",
DESKTOP_BUILD_PROVIDER_ID,
] as const;
/** Whether a check belongs to the desktop build scope, which decides only its own health. */
export const isDesktopBuildCheck = (name: string): boolean =>
name.startsWith(`${DESKTOP_BUILD_PROVIDER_ID}-`);
+29 -1
View File
@@ -5,6 +5,11 @@ import type {
UnhealthyClientRegistrationStatus,
} from "./ClientRegistrationStatus.js";
import { manualRegistrationRemediation } from "./SupportedClients.js";
import { isDesktopBuildInformationalCheck } from "./DesktopBuildDoctor.js";
import {
DESKTOP_BUILD_PROVIDER_ID,
isDesktopBuildCheck,
} from "./DoctorProviderIds.js";
import type {
DoctorCheck,
DoctorProviderInspection,
@@ -79,7 +84,9 @@ export const scopeDoctorChecks = (
? [selectedSkillCheck(input.skillState, input.checks)]
: [];
const selectedProviderChecks = input.scope.providers.flatMap((providerId) =>
selectedProviderChecksFor(providerId, providerChecks, input.providers),
providerId === DESKTOP_BUILD_PROVIDER_ID
? requiredDesktopBuildChecks(input.checks)
: selectedProviderChecksFor(providerId, providerChecks, input.providers),
);
const hopperHostChecks = input.scope.providers.includes("hopper")
? input.checks.filter(({ name }) => name === "host")
@@ -185,6 +192,27 @@ const selectedProviderChecksFor = (
];
};
/** Informational desktop build checks stay outside the scope that decides health. */
const requiredDesktopBuildChecks = (
checks: readonly DoctorCheck[],
): readonly DoctorCheck[] => {
const required = checks.filter(
({ name }) =>
isDesktopBuildCheck(name) && !isDesktopBuildInformationalCheck(name),
);
return required.length > 0
? required
: [
{
name: `provider:${DESKTOP_BUILD_PROVIDER_ID}`,
ok: false,
classification: "unknown",
detail: "This doctor host does not check desktop build hosts.",
remediation: "Run rea doctor --provider desktop_build from the CLI.",
},
];
};
const selectedRegistrationCheck = (
client: string,
status: ClientRegistrationStatus | undefined,
+20 -1
View File
@@ -35,12 +35,14 @@ class FakeMacHopperHost implements MacHopperInstallHost {
opens = 0;
replaces = false;
downloads: Array<{ url: string; timeoutMs: number | undefined }> = [];
downloadSignals: Array<AbortSignal | undefined> = [];
download = (
url: string,
options?: { readonly timeoutMs?: number },
options?: { readonly timeoutMs?: number; readonly signal?: AbortSignal },
): Promise<HopperDownload> => {
this.downloads.push({ url, timeoutMs: options?.timeoutMs });
this.downloadSignals.push(options?.signal);
const feed = url.includes("files-api");
if (!feed) this.packageDownloads += 1;
const failure = feed ? this.feedFailure : this.packageFailure;
@@ -570,3 +572,20 @@ it.each(["feed", "package"] as const)(
expect(host.cleanups).toBe(0);
},
);
it("stops before mounting when setup is cancelled as the package downloads", async () => {
const host = new FakeMacHopperHost();
const controller = new AbortController();
const download = host.download;
host.download = (url, options) => {
if (!url.includes("files-api")) controller.abort(new Error("stop setup"));
return download(url, options);
};
await expect(
installMacHopper({ signal: controller.signal }, host),
).resolves.toMatchObject({ status: "failed", reason: "cancelled" });
expect(host.downloadSignals).toEqual([controller.signal, controller.signal]);
expect(host.mounts).toBe(0);
expect(host.installs).toBe(0);
});
+14 -4
View File
@@ -67,6 +67,7 @@ type MacHopperInstallFailureReason = Extract<
| "bundle"
| "publisher_verification_failed"
| "copy"
| "cancelled"
>;
export type MacHopperBundleInstallResult =
@@ -123,7 +124,7 @@ export type MacHopperInstallResult =
export interface MacHopperInstallHost {
download(
url: string,
options?: { readonly timeoutMs?: number },
options?: { readonly timeoutMs?: number; readonly signal?: AbortSignal },
): Promise<HopperDownload>;
createTemporaryDirectory(): Promise<string>;
createMountDirectory(path: string): Promise<void>;
@@ -159,9 +160,15 @@ export interface MacHopperInstallHost {
* app's `LSMinimumSystemVersion` before it is copied.
*/
export const installMacHopper = async (
options: { readonly replaceExisting?: boolean },
options: {
readonly replaceExisting?: boolean;
/** Stops downloads and the steps after them; a started copy finishes. */
readonly signal?: AbortSignal;
},
host: MacHopperInstallHost,
): Promise<MacHopperInstallResult> => {
const cancellation =
options.signal === undefined ? {} : { signal: options.signal };
const hostVersion = await host.macosVersion();
// Without a comparable host version no declared minimum can be checked.
if (hostVersion === undefined || macosMajorVersion(hostVersion) === undefined)
@@ -175,6 +182,7 @@ export const installMacHopper = async (
result = await (async (): Promise<MacHopperInstallResult> => {
const metadata = await host.download(HOPPER_RELEASES_URL, {
timeoutMs: HOPPER_METADATA_TIMEOUT_MS,
...cancellation,
});
if (!metadata.ok)
return {
@@ -204,7 +212,7 @@ export const installMacHopper = async (
}),
};
installStage = "download";
const archive = await host.download(release.filename);
const archive = await host.download(release.filename, cancellation);
if (!archive.ok)
return {
status: "failed",
@@ -220,6 +228,7 @@ export const installMacHopper = async (
const destinationExists = await host.destinationExists(destination);
if (destinationExists && options.replaceExisting !== true)
return { status: "failed", reason: "destination_exists" };
options.signal?.throwIfAborted();
installStage = "mount";
temporary = await host.createTemporaryDirectory();
const packagePath = posix.join(temporary, "hopper.dmg");
@@ -261,6 +270,7 @@ export const installMacHopper = async (
),
};
}
options.signal?.throwIfAborted();
installStage = "copy";
const installation = await host.installBundle(
source,
@@ -297,7 +307,7 @@ export const installMacHopper = async (
} catch (cause: unknown) {
result = {
status: "failed",
reason: installStage,
reason: options.signal?.aborted === true ? "cancelled" : installStage,
diagnostic: failureMessage(cause),
};
}
+361
View File
@@ -0,0 +1,361 @@
import { describe, expect, it } from "vitest";
import { FakeSetupHost } from "./Setup.fixture.js";
import { runSetup } from "./Setup.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import type {
DesktopBuildSetupFacts,
DesktopBuildSetupHost,
} from "./SetupDesktopBuild.js";
import type { SetupOptions, SetupProviderEnvironment } from "./SetupTypes.js";
const ARCHIVE = "/home/ada/.cache/rea/build-computer/rea-6.8.0.tar.xz";
const SETTINGS = {
REA_AGENT_ENV_COMMAND: "/home/ada/.local/bin/agent-env",
REA_DOCKER_COMMAND: "/usr/bin/docker",
REA_BUILD_COMPUTER_REA_ARCHIVE: ARCHIVE,
};
const desktopHost = (facts: Partial<DesktopBuildSetupFacts> = {}) => {
const calls: string[] = [];
const desktopBuild: DesktopBuildSetupHost = {
facts: () =>
Promise.resolve({
settings: SETTINGS,
selected: [],
archive: ARCHIVE,
archivePacked: false,
agentEnvVersion: "0.9.1431",
recorded: {},
...facts,
}),
packRuntime: (archive) => {
calls.push(`pack ${archive}`);
return Promise.resolve(undefined);
},
installAgentenv: (uv) => {
calls.push(`install ${uv}`);
return Promise.resolve(undefined);
},
};
const host = Object.assign(new FakeSetupHost("linux"), { desktopBuild });
host.clients = [{ name: "claude_code", configPath: "/claude.json" }];
host.hopper = "/opt/hopper/bin/Hopper";
host.skill = "unchanged";
return { host, calls };
};
const desktopOptions = (
approved: boolean,
extra: Partial<SetupOptions> = {},
): SetupOptions => ({
approved,
installHopper: false,
structured: true,
clientIds: ["claude_code"],
desktopBuild: true,
readinessScope: { clients: ["claude_code"], providers: ["desktop_build"] },
...extra,
});
describe("desktop setup cancellation", () => {
it("retains an action failure reported as cancellation arrives", async () => {
const { host } = desktopHost();
const controller = new AbortController();
const reason = new Error("cancel after failure");
host.desktopBuild.packRuntime = async () => {
controller.abort(reason);
return "packing failed: disk full";
};
const failure: unknown = await runSetup(
desktopOptions(true, { signal: controller.signal }),
host,
).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(AnalysisCancelledError);
expect(failure).toMatchObject({
cause: reason,
diagnostics: { setup_failure: "packing failed: disk full" },
});
});
it.each(["install", "pack"] as const)(
"waits for %s cleanup and stops later setup work after cancellation",
async (action) => {
const { host, calls } = desktopHost(
action === "install"
? {
agentEnvVersion: undefined,
uv: {
path: "/usr/local/bin/uv",
agentEnv: "/home/ada/.local/bin/agent-env",
},
}
: {},
);
const controller = new AbortController();
const reason = new Error("stop setup");
let started!: () => void;
let finish!: () => void;
const began = new Promise<void>((resolve) => {
started = resolve;
});
const cleanup = new Promise<void>((resolve) => {
finish = resolve;
});
let closed = false;
const effect = async (_path: string, signal?: AbortSignal) => {
expect(signal).toBe(controller.signal);
started();
await cleanup;
return undefined;
};
Object.assign(
host.desktopBuild,
action === "install"
? { installAgentenv: effect }
: { packRuntime: effect },
);
Object.assign(host, {
close: async () => {
closed = true;
},
});
const pending = runSetup(
desktopOptions(true, {
installAgentenv: action === "install",
signal: controller.signal,
}),
host,
);
void pending.catch(() => undefined);
await began;
controller.abort(reason);
expect(closed).toBe(false);
finish();
const failure: unknown = await pending.catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(AnalysisCancelledError);
expect(failure).toMatchObject({ cause: reason });
expect(closed).toBe(true);
expect(calls).toEqual([]);
expect(host.configuredProviderEnvironments).toEqual([]);
},
);
});
describe("rea setup --desktop-build", () => {
it("plans packing and the build settings without applying them", async () => {
const { host, calls } = desktopHost();
const result = await runSetup(
desktopOptions(false, { dryRun: true }),
host,
);
expect(result.status).toBe("planned");
expect(result.plannedActions.map(({ id }) => id)).toEqual([
"pack_build_runtime",
"configure_client:claude_code",
]);
expect(result.plannedActions[0]).toMatchObject({
target: ARCHIVE,
external: true,
commands: [`npm run pack:build-computer-runtime -- ${ARCHIVE}`],
});
expect(result.plannedActions[1]?.detail).toContain(
`REA_BUILD_COMPUTER_REA_ARCHIVE=${ARCHIVE}`,
);
expect(calls).toEqual([]);
expect(host.configurations).toBe(0);
});
it("packs, then records the build settings, then checks the build host", async () => {
const { host, calls } = desktopHost();
const result = await runSetup(desktopOptions(true), host);
expect(calls).toEqual([`pack ${ARCHIVE}`]);
expect(result.appliedActions).toEqual([
"pack_build_runtime",
"configured_claude_code",
]);
expect(host.configuredProviderEnvironments).toEqual([
expect.objectContaining(SETTINGS),
]);
expect(host.doctorScopes.at(-1)?.providers).toContain("desktop_build");
});
it("keeps a build setting the registration already records", async () => {
const { host } = desktopHost({
recorded: { claude_code: ["REA_DOCKER_COMMAND"] },
});
await runSetup(desktopOptions(true), host);
expect(host.configuredProviderEnvironments[0]).not.toHaveProperty(
"REA_DOCKER_COMMAND",
);
expect(host.configuredProviderEnvironments[0]).toMatchObject({
REA_AGENT_ENV_COMMAND: SETTINGS.REA_AGENT_ENV_COMMAND,
});
});
it("packs nothing when this version's archive is already packed", async () => {
const { host, calls } = desktopHost({ archivePacked: true });
const result = await runSetup(desktopOptions(true), host);
expect(result.plannedActions.map(({ id }) => id)).not.toContain(
"pack_build_runtime",
);
expect(calls).toEqual([]);
expect(host.configuredProviderEnvironments[0]).toMatchObject(SETTINGS);
});
it("records an archive the environment selects instead of packing one", async () => {
const { host, calls } = desktopHost({
settings: {
...SETTINGS,
REA_BUILD_COMPUTER_REA_ARCHIVE: "/srv/rea.tar.xz",
},
selected: ["REA_BUILD_COMPUTER_REA_ARCHIVE"],
recorded: { claude_code: ["REA_BUILD_COMPUTER_REA_ARCHIVE"] },
});
await runSetup(desktopOptions(true), host);
expect(calls).toEqual([]);
expect(host.configuredProviderEnvironments[0]).toMatchObject({
REA_BUILD_COMPUTER_REA_ARCHIVE: "/srv/rea.tar.xz",
});
});
it("installs the pinned agentenv with uv before packing", async () => {
const { host, calls } = desktopHost({
agentEnvVersion: undefined,
uv: {
path: "/usr/local/bin/uv",
agentEnv: "/home/ada/.local/bin/agent-env",
},
});
const result = await runSetup(
desktopOptions(true, { installAgentenv: true }),
host,
);
expect(result.plannedActions[0]).toMatchObject({
id: "install_agentenv",
commands: ["uv tool install agentenv-framework==0.9.1431"],
});
expect(calls).toEqual(["install /usr/local/bin/uv", `pack ${ARCHIVE}`]);
expect(host.configuredProviderEnvironments[0]).toMatchObject({
REA_AGENT_ENV_COMMAND: "/home/ada/.local/bin/agent-env",
});
});
it("prints the agentenv install command when uv is absent", async () => {
const { host, calls } = desktopHost({ agentEnvVersion: undefined });
const result = await runSetup(
desktopOptions(true, { installAgentenv: true }),
host,
);
expect(result.plannedActions.map(({ id }) => id)).not.toContain(
"install_agentenv",
);
expect(calls).toEqual([`pack ${ARCHIVE}`]);
expect(result.status).toBe("needs_human");
expect(result.remediation).toContain(
"uv tool install agentenv-framework==0.9.1431",
);
});
it("re-checks each client with its build settings after installing Hopper", async () => {
const { host } = desktopHost({
recorded: { claude_code: ["REA_DOCKER_COMMAND"] },
});
host.distribution = {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
hopperSetupSupported: true,
};
const checked: SetupProviderEnvironment[] = [];
host.clientNeedsConfigure = (_, environment) => {
checked.push(environment);
return Promise.resolve(true);
};
await runSetup(desktopOptions(true, { installHopper: true }), host);
expect(checked).toEqual([
{
HOPPER_LAUNCHER_PATH: "/manual/Hopper",
REA_AGENT_ENV_COMMAND: SETTINGS.REA_AGENT_ENV_COMMAND,
REA_BUILD_COMPUTER_REA_ARCHIVE: ARCHIVE,
},
]);
expect(host.configuredProviderEnvironments).toEqual([checked[0]]);
});
it("refuses to pack outside a REA checkout before changing anything", async () => {
const { host, calls } = desktopHost({
packBlocker: "Packing REA's build-computer runtime needs a REA checkout.",
});
const result = await runSetup(desktopOptions(true), host);
expect(result.status).toBe("needs_human");
expect(result.remediation).toContain("needs a REA checkout");
expect(calls).toEqual([]);
expect(host.configurations).toBe(0);
});
});
describe("desktop setup failures", () => {
it.each([
{
action: "pack",
kept: "REA_BUILD_COMPUTER_REA_ARCHIVE",
written: "REA_AGENT_ENV_COMMAND",
remediation: "Packing REA's build-computer runtime failed",
},
{
action: "install",
kept: "REA_AGENT_ENV_COMMAND",
written: "REA_BUILD_COMPUTER_REA_ARCHIVE",
remediation: "Installing agentenv failed",
},
] as const)(
"leaves the client's $kept as recorded when $action fails",
async ({ action, kept, written, remediation }) => {
const { host } = desktopHost({
agentEnvVersion: undefined,
uv: {
path: "/usr/local/bin/uv",
agentEnv: "/home/ada/.local/bin/agent-env",
},
});
Object.assign(
host.desktopBuild,
action === "pack"
? { packRuntime: () => Promise.resolve("ENOSPC") }
: { installAgentenv: () => Promise.resolve("uv exited 2") },
);
const result = await runSetup(
desktopOptions(true, { installAgentenv: true }),
host,
);
expect(result.status).toBe("needs_human");
expect(result.remediation).toContain(remediation);
expect(host.configuredProviderEnvironments).toHaveLength(1);
expect(host.configuredProviderEnvironments[0]).not.toHaveProperty(kept);
expect(host.configuredProviderEnvironments[0]).toMatchObject({
REA_DOCKER_COMMAND: SETTINGS.REA_DOCKER_COMMAND,
[written]: SETTINGS[written],
});
},
);
});
+92
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js";
@@ -814,3 +815,94 @@ it.each(["return", "throw"] as const)(
expect(failure.hasCleanupOwner).toBe(false);
},
);
describe("setup cancellation", () => {
it("carries the caller's cancellation into the Hopper install", async () => {
const host = new FakeSetupHost("darwin");
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
const controller = new AbortController();
const reason = new Error("stop setup");
let received: AbortSignal | undefined;
host.installHopper = (_replaceExisting, signal?: AbortSignal) => {
received = signal;
controller.abort(reason);
return Promise.resolve({
status: "failed",
code: "setup_cancelled",
remediation: "Setup was cancelled.",
});
};
const failure: unknown = await runSetup(
{
...options(true, true),
clientIds: ["codex"],
signal: controller.signal,
},
host,
).catch((cause: unknown) => cause);
expect(received).toBe(controller.signal);
expect(failure).toBeInstanceOf(AnalysisCancelledError);
expect(failure).toMatchObject({ cause: reason });
expect(host.configurations).toBe(0);
});
it("reports cancellation during final cleanup with the completed setup result", async () => {
const clients = {
codex: { status: "configured", backupPath: "/fixture/codex.toml.bak" },
cursor: { status: "configured", backupPath: "/fixture/cursor.json.bak" },
} as const;
const input = {
...options(true),
installSkill: false,
proposeHopper: false,
clientIds: Object.keys(clients),
};
const prepared = () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [
{ name: "codex", configPath: "/fixture/codex.toml" },
{ name: "cursor", configPath: "/fixture/cursor.json" },
];
for (const [name, result] of Object.entries(clients))
host.clientResults.set(name, result);
return host;
};
const control = await runSetup(input, prepared());
expect(control).toMatchObject({
status: "ready",
appliedActions: ["configured_codex", "configured_cursor"],
clients,
doctor: { healthy: true, scope: { clients: ["codex", "cursor"] } },
});
const controller = new AbortController();
const reason = new Error("stop setup");
const host = Object.assign(prepared(), {
close: () => {
controller.abort(reason);
return Promise.resolve(undefined);
},
});
const failure: unknown = await runSetup(
{ ...input, signal: controller.signal },
host,
).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(AnalysisCancelledError);
if (!(failure instanceof AnalysisCancelledError))
throw new Error("Expected setup to report its cancellation");
expect(failure).toMatchObject({
cause: reason,
diagnostics: { setup_result: control },
});
expect(projectAnalysisError(failure)).toMatchObject({
code: "cancelled",
details: {
diagnostics: { setup_result: { clients, doctor: control.doctor } },
},
});
});
});
+61 -10
View File
@@ -33,7 +33,12 @@ import {
} from "../domain/analysisErrorBase.js";
import { setupCleanupError, setupCleanupReason } from "./SetupCleanupOwner.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { assertSetupActive } from "./SetupCancellation.js";
import type { SetupSkillInstallError } from "./SetupSkill.js";
import { planDesktopBuild } from "./SetupDesktopBuild.js";
import { DESKTOP_BUILD_PROVIDER_ID } from "./DoctorProviderIds.js";
import type { SetupHopperInstallFailure } from "./SetupInstallFailure.js";
/** Discover, approve, and apply setup actions idempotently. */
@@ -96,6 +101,16 @@ export const runSetup = async (
pending.skill.remediation,
};
}
// Cancellation observed only during the final doctor or cleanup is not success.
if (
cleanup === undefined &&
result.status === "ready" &&
options.signal?.aborted === true
)
throw new AnalysisCancelledError("setup", {
cause: options.signal.reason,
diagnostics: { setup_result: jsonValueSchema.parse(result) },
});
if (cleanup === undefined) return result;
const cleanupReason = setupCleanupReason(cleanup);
const cleanupReport: AnalysisCleanupObservation = {
@@ -123,6 +138,7 @@ const runSetupWorkflow = async (
confirm: SetupConfirmation | undefined,
pending: { skill?: SetupSkillInstallError },
): Promise<SetupResult> => {
assertSetupActive(options.signal);
const appliedActions: string[] = [];
let plannedActions: readonly SetupAction[] = [];
const clients: Record<string, ClientConfigurationResult> = {};
@@ -211,6 +227,7 @@ const runSetupWorkflow = async (
selectedClientIds: discovery.defaultClientIds,
clientSelectionAllowed,
});
assertSetupActive(options.signal);
if (isSetupDecisionCancelled(decision))
return setupCancelled(
selectionActions.plannedActions,
@@ -256,6 +273,12 @@ const runSetupWorkflow = async (
installSkill = skillSelected && skillNeedsInstall;
selectedSkillClientIds = selectedClientIds;
}
const desktop = await planDesktopBuild(
host.desktopBuild,
options,
selectedClientIds,
);
if (desktop.blocker !== undefined) return fail(desktop.blocker);
const planned = await planSetupActions({
discovery: planDiscovery,
host,
@@ -264,8 +287,9 @@ const runSetupWorkflow = async (
clientIds: selectedClientIds,
skillClientIds: selectedSkillClientIds,
installSkill,
clientEnvironments: desktop.clientEnvironments,
});
plannedActions = planned.plannedActions;
plannedActions = [...desktop.actions, ...planned.plannedActions];
if (planned.blocker !== undefined) return fail(planned.blocker);
const hopperRefusal = hopperInstallRefusal(planDiscovery);
if (hopperRefusal !== undefined) return fail(hopperRefusal);
@@ -280,9 +304,13 @@ const runSetupWorkflow = async (
readinessScope = resolvedReadinessScope(
options.readinessScope,
planSelection,
desktop.selected,
);
const manualRemediation = manualClientsRemediation(planned.selectedClients);
const manualRemediation = combineRemediation(
manualClientsRemediation(planned.selectedClients),
desktop.remediation,
);
if (skillSelected) {
const admission = await host.validateSkillDirectories(
selectedSkillClientIds,
@@ -311,6 +339,7 @@ const runSetupWorkflow = async (
selectedClientIds,
clientSelectionAllowed: false,
});
assertSetupActive(options.signal);
if (decision !== undefined) {
if (isSetupDecisionCancelled(decision))
return setupCancelled(
@@ -346,6 +375,13 @@ const runSetupWorkflow = async (
);
if (!admission.ok) return failSkill(admission.error);
}
assertSetupActive(options.signal);
const desktopFailure = await desktop.apply(
appliedActions,
options.onProgress,
options.signal,
);
assertSetupActive(options.signal);
let selectedClients: readonly SetupClient[] =
planSelection.clientsToConfigure;
let hopperWarning: string | undefined;
@@ -360,6 +396,7 @@ const runSetupWorkflow = async (
appliedActions,
selectedClients,
providerEnvironment,
clientEnvironments: desktop.clientEnvironments,
});
// Hopper is optional: a failed install still applies the client
// registrations and skill approved with it (#5113).
@@ -372,17 +409,21 @@ const runSetupWorkflow = async (
warning: hopperWarning,
} = install);
}
assertSetupActive(options.signal);
const clientFailure = await configureDetectedClients({
host,
detectedClients: selectedClients,
providerEnvironment,
clientEnvironments: desktop.clientEnvironments,
command: host.registrationCommand,
clients,
appliedActions,
signal: options.signal,
...(options.onProgress === undefined
? {}
: { onProgress: options.onProgress }),
});
assertSetupActive(options.signal);
// A later failure must not hide a failed Hopper install: keep its repair
// steps, and its code when the later failure carries none.
const withHopperFailure = (result: SetupResult): SetupResult =>
@@ -413,7 +454,9 @@ const runSetupWorkflow = async (
}
const doctor = summarizeDoctor(await host.doctor(readinessScope));
const remediation = combineRemediation(
desktopFailure,
hopperFailure?.remediation ??
desktop.readinessRemediation(doctor.healthy) ??
finalSetupRemediation(
host.platform,
appliedActions.includes("installed_hopper"),
@@ -450,6 +493,9 @@ const installHopperAction = async (input: {
readonly appliedActions: string[];
readonly selectedClients: readonly SetupClient[];
readonly providerEnvironment: SetupProviderEnvironment;
readonly clientEnvironments: Readonly<
Record<string, SetupProviderEnvironment>
>;
}) => {
const label = "Hopper deep-analysis provider";
emitProgress(input.options, {
@@ -457,7 +503,10 @@ const installHopperAction = async (input: {
label,
state: "started",
});
const installed = await input.host.installHopper(input.options.installHopper);
const installed = await input.host.installHopper(
input.options.installHopper,
input.options.signal,
);
if (installed.status === "failed") {
emitProgress(input.options, {
actionId: "install_hopper",
@@ -491,7 +540,7 @@ const installHopperAction = async (input: {
selectedClients: await filterClientsNeedingConfigure(
input.host,
input.selectedClients,
providerEnvironment,
{ providerEnvironment, clientEnvironments: input.clientEnvironments },
input.host.registrationCommand,
),
};
@@ -510,6 +559,7 @@ const installSkillAction = async (
state: "started",
});
const skill = await host.installSkill(skillClientIds);
assertSetupActive(options.signal);
if (!skill.ok) {
if (skill.error.completed) appliedActions.push("installed_skill");
emitProgress(options, {
@@ -612,11 +662,15 @@ const resolvedReadinessScope = (
readonly skillSelected?: boolean;
readonly selectedSkillClientIds: readonly string[];
},
desktopBuild: boolean,
): DoctorScope => ({
clients: selection.selectedClients.map(({ name }) => name),
providers: selection.installHopper
? ["hopper"]
: (requested?.providers ?? []),
providers: [
...new Set([
...(selection.installHopper ? ["hopper"] : (requested?.providers ?? [])),
...(desktopBuild ? [DESKTOP_BUILD_PROVIDER_ID] : []),
]),
],
skill: selection.skillSelected === true || requested?.skill === true,
...(selection.skillSelected === true || requested?.skill === true
? { skillClients: selection.selectedSkillClientIds }
@@ -653,6 +707,3 @@ const summarizeDoctor = (report: DoctorReport): SetupDoctorSummary => ({
},
}),
});
const failureMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
+15
View File
@@ -0,0 +1,15 @@
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
/** Preserve the caller's exact reason while setup unwinds its resource owners. */
export const assertSetupActive = (
signal?: AbortSignal,
failure?: string,
): void => {
if (signal?.aborted)
throw new AnalysisCancelledError("setup", {
cause: signal.reason,
...(failure === undefined
? {}
: { diagnostics: { setup_failure: failure } }),
});
};
+9 -1
View File
@@ -5,6 +5,8 @@ import type {
SetupProgressEvent,
} from "./SetupTypes.js";
import type { SetupClient } from "./SupportedClients.js";
import { clientEnvironment } from "./SetupPlan.js";
import { assertSetupActive } from "./SetupCancellation.js";
export const failedConfigurationMessage = (
result: Extract<ClientConfigurationResult, { readonly status: "failed" }>,
@@ -58,19 +60,24 @@ export const configureDetectedClients = async (options: {
readonly host: SetupHost;
readonly detectedClients: readonly SetupClient[];
readonly providerEnvironment: SetupProviderEnvironment;
readonly clientEnvironments?:
| Readonly<Record<string, SetupProviderEnvironment>>
| undefined;
readonly command: readonly string[];
readonly clients: Record<string, ClientConfigurationResult>;
readonly appliedActions: string[];
readonly onProgress?: (event: SetupProgressEvent) => void;
readonly signal?: AbortSignal | undefined;
}): Promise<string | undefined> => {
let failure: string | undefined;
for (const client of options.detectedClients) {
assertSetupActive(options.signal);
const actionId = `configure_client:${client.name}`;
const label = client.displayName ?? client.name;
options.onProgress?.({ actionId, label, state: "started" });
const result = await options.host.configureClient(
client,
options.providerEnvironment,
clientEnvironment(options, client.name),
options.command,
);
options.clients[client.name] = result;
@@ -96,6 +103,7 @@ export const configureDetectedClients = async (options: {
: "Manual configuration required",
});
}
assertSetupActive(options.signal);
}
return failure;
};
+219
View File
@@ -0,0 +1,219 @@
import { DESKTOP_EXPORT_FRAMEWORK_VERSION } from "../domain/desktop/appExport.js";
import { AGENTENV_INSTALL_COMMAND } from "./DesktopBuildDoctor.js";
import { DESKTOP_BUILD_PROVIDER_ID } from "./DoctorProviderIds.js";
import { assertSetupActive } from "./SetupCancellation.js";
import type {
SetupAction,
SetupOptions,
SetupProgressEvent,
SetupProviderEnvironment,
} from "./SetupTypes.js";
/** What setup reads from a host before it plans a desktop build setup. */
export interface DesktopBuildSetupFacts {
/** Each build setting resolved to an absolute path; absent when none resolves. */
readonly settings: SetupProviderEnvironment;
/** Settings the caller's environment selected, which setup records as they are. */
readonly selected: readonly string[];
readonly archive: string;
readonly archivePacked: boolean;
/** Why this REA cannot pack its runtime, when it cannot. */
readonly packBlocker?: string;
readonly agentEnvVersion?: string | undefined;
/** uv on PATH and the agent-env its tool install places. */
readonly uv?: { readonly path: string; readonly agentEnv: string };
/** Per client, the build settings its registration already records with a value of its own. */
readonly recorded: Readonly<Record<string, readonly string[]>>;
}
/** Effects `rea setup --desktop-build` needs from its host. */
export interface DesktopBuildSetupHost {
facts(clientIds: readonly string[]): Promise<DesktopBuildSetupFacts>;
/** Pack this REA as the build-computer runtime; the reason when it failed. */
packRuntime(
archive: string,
signal?: AbortSignal,
): Promise<string | undefined>;
/** Install the pinned agentenv-framework with uv; the reason when it failed. */
installAgentenv(
uv: string,
signal?: AbortSignal,
): Promise<string | undefined>;
}
/** A desktop build setup as runSetup drives it; inert when it was not selected. */
export interface DesktopBuildSetup {
readonly selected: boolean;
readonly blocker?: string;
readonly actions: readonly SetupAction[];
/**
* Settings added to each selected client's registration only; after apply, a
* setting whose pack or install failed is left as the client records it.
*/
readonly clientEnvironments: Readonly<
Record<string, SetupProviderEnvironment>
>;
readonly remediation?: string;
/** Run the approved install and pack; the remediation for whatever failed. */
apply(
appliedActions: string[],
onProgress?: (event: SetupProgressEvent) => void,
signal?: AbortSignal,
): Promise<string | undefined>;
/** Where to look when the build host is not ready after setup. */
readinessRemediation(healthy: boolean): string | undefined;
}
const inert = (blocker?: string): DesktopBuildSetup => ({
selected: blocker !== undefined,
...(blocker === undefined ? {} : { blocker }),
actions: [],
clientEnvironments: {},
apply: () => Promise.resolve(undefined),
readinessRemediation: () => undefined,
});
/** Plan packing, the optional agentenv install and the settings each registration lacks. */
export const planDesktopBuild = async (
host: DesktopBuildSetupHost | undefined,
options: Pick<SetupOptions, "desktopBuild" | "installAgentenv">,
clientIds: readonly string[],
): Promise<DesktopBuildSetup> => {
if (options.desktopBuild !== true) return inert();
if (host === undefined)
return inert("This setup host cannot set up desktop builds.");
const facts = await host.facts(clientIds);
const pinned = facts.agentEnvVersion === DESKTOP_EXPORT_FRAMEWORK_VERSION;
const install = options.installAgentenv === true && !pinned;
const uv = install ? facts.uv : undefined;
const actions: SetupAction[] = [];
if (uv !== undefined) actions.push(installAction(uv.agentEnv));
// An archive the caller selected is recorded instead of packing REA's own.
if (
!facts.archivePacked &&
!facts.selected.includes("REA_BUILD_COMPUTER_REA_ARCHIVE")
) {
if (facts.packBlocker !== undefined) return inert(facts.packBlocker);
actions.push(packAction(facts.archive));
}
const settings: SetupProviderEnvironment = {
...facts.settings,
...(uv !== undefined && !facts.selected.includes("REA_AGENT_ENV_COMMAND")
? { REA_AGENT_ENV_COMMAND: uv.agentEnv }
: {}),
};
const remediation = agentenvRemediation(pinned, install, uv);
const failed = new Set<string>();
return {
selected: true,
actions,
get clientEnvironments() {
return Object.fromEntries(
clientIds.map((client) => [
client,
Object.fromEntries(
Object.entries(settings).filter(
([name]) =>
!failed.has(name) &&
(facts.selected.includes(name) ||
!(facts.recorded[client] ?? []).includes(name)),
),
),
]),
);
},
...(remediation === undefined ? {} : { remediation }),
apply: (appliedActions, onProgress, signal) =>
applyActions(host, actions, uv?.path, {
appliedActions,
onProgress,
signal,
failed: (kind) => {
if (kind === "pack_build_runtime")
failed.add("REA_BUILD_COMPUTER_REA_ARCHIVE");
else if (!facts.selected.includes("REA_AGENT_ENV_COMMAND"))
failed.add("REA_AGENT_ENV_COMMAND");
},
}),
readinessRemediation: (healthy) =>
healthy
? undefined
: `Run rea doctor --provider ${DESKTOP_BUILD_PROVIDER_ID} and apply each reported fix.`,
};
};
const agentenvRemediation = (
pinned: boolean,
install: boolean,
uv: DesktopBuildSetupFacts["uv"],
): string | undefined =>
pinned || uv !== undefined
? undefined
: install
? `uv is not on PATH. Install agentenv with ${AGENTENV_INSTALL_COMMAND}, then rerun setup.`
: `Desktop builds need agentenv-framework ${DESKTOP_EXPORT_FRAMEWORK_VERSION}: ${AGENTENV_INSTALL_COMMAND}, or rerun setup with --install-agentenv.`;
const applyActions = async (
host: DesktopBuildSetupHost,
actions: readonly SetupAction[],
uv: string | undefined,
progress: {
readonly appliedActions: string[];
readonly onProgress?: ((event: SetupProgressEvent) => void) | undefined;
readonly signal?: AbortSignal | undefined;
/** Leave the setting this failed action produces as each client records it. */
readonly failed: (kind: SetupAction["kind"]) => void;
},
): Promise<string | undefined> => {
const failures: string[] = [];
for (const { id, kind, label, target } of actions) {
assertSetupActive(progress.signal);
progress.onProgress?.({ actionId: id, label, state: "started" });
const failure =
kind === "install_agentenv"
? await host.installAgentenv(uv ?? "uv", progress.signal)
: await host.packRuntime(target, progress.signal);
progress.onProgress?.({
actionId: id,
label,
state: failure === undefined ? "completed" : "failed",
...(failure === undefined ? {} : { detail: failure }),
});
if (failure === undefined) progress.appliedActions.push(id);
assertSetupActive(progress.signal, failure);
if (failure === undefined) continue;
progress.failed(kind);
failures.push(
kind === "install_agentenv"
? `Installing agentenv failed: ${failure} Run ${AGENTENV_INSTALL_COMMAND} yourself, then rerun setup.`
: `Packing REA's build-computer runtime failed: ${failure} Fix the reported problem, then rerun setup with --desktop-build.`,
);
}
return failures.length === 0 ? undefined : failures.join(" ");
};
const installAction = (agentEnv: string): SetupAction => ({
id: "install_agentenv",
kind: "install_agentenv",
label: `agentenv-framework ${DESKTOP_EXPORT_FRAMEWORK_VERSION}`,
target: agentEnv,
detail:
"Install the agentenv-framework release that desktop bundles target as a uv tool; REA runs its agent-env program for build computers.",
external: true,
operation: "install",
networkOrigins: ["https://pypi.org", "https://files.pythonhosted.org"],
commands: [AGENTENV_INSTALL_COMMAND],
});
const packAction = (archive: string): SetupAction => ({
id: "pack_build_runtime",
kind: "pack_build_runtime",
label: "REA build-computer runtime",
target: archive,
detail:
"Pack this REA checkout as the runtime a build computer installs: npm pack, its production dependencies from npm ci --omit=dev --omit=optional, then a .tar.xz named by this REA version, packed again when the version changes.",
external: true,
operation: "create",
networkOrigins: ["https://registry.npmjs.org"],
commands: [`npm run pack:build-computer-runtime -- ${archive}`],
});
+28 -8
View File
@@ -3,12 +3,10 @@ import {
retryClientConfigurationCleanup,
} from "./ClientConfigurationCleanup.js";
import { access } from "node:fs/promises";
import { resolve } from "node:path";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { currentClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import {
SUPPORTED_NODE_VERSION_PROSE,
@@ -49,7 +47,6 @@ import {
setupInstallFailure,
type SetupHopperInstallResult,
} from "./SetupInstallFailure.js";
import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js";
import {
explicitSetupRegistrationEnvironment,
providerRegistrationEnvironment,
@@ -71,6 +68,8 @@ import {
import { bindRegularFileCleanup } from "./RegularFileRead.js";
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
import { analysisProviderSelectorSchema } from "../domain/providerSelection.js";
import { systemDesktopBuildSetupHost } from "../desktop/DesktopBuildSetupHost.js";
import { clientEnvironment } from "./SetupPlan.js";
/**
* The launcher setup registers, and why it must not be registered when it
@@ -103,12 +102,16 @@ const setupRegistration = (
export const filterClientsNeedingConfigure = async (
host: SetupHost,
detectedClients: readonly SetupClient[],
providerEnvironment: SetupProviderEnvironment,
environment: Parameters<typeof clientEnvironment>[0],
command: readonly string[],
): Promise<readonly SetupClient[]> => {
const needs = await Promise.all(
detectedClients.map((client) =>
host.clientNeedsConfigure(client, providerEnvironment, command),
host.clientNeedsConfigure(
client,
clientEnvironment(environment, client.name),
command,
),
),
);
return detectedClients.filter((_, index) => needs[index]);
@@ -216,13 +219,17 @@ export const systemSetupHost = (
doctor: diagnosis,
};
},
installHopper: (replaceExisting) =>
installHopper: (replaceExisting, signal) =>
resources.run<SetupHopperInstallResult>(async () => {
const publisher = platform === "linux" ? publisherCleanup : macCleanup;
const cancellation = signal === undefined ? {} : { signal };
cleanup.admit(publisher);
try {
if (platform === "linux") {
const result = await installLinuxHopper(linuxHopperHost);
const result = await installLinuxHopper(
linuxHopperHost,
cancellation,
);
if (result.cleanupFailure !== undefined) {
pendingLinuxHopperCleanup.retain(result.cleanupFailure);
cleanup.admit(temporaryCleanup);
@@ -253,7 +260,7 @@ export const systemSetupHost = (
if (macHopperHost === undefined)
return setupInstallFailure("unsupported_host");
const result = await installMacHopper(
{ replaceExisting },
{ replaceExisting, ...cancellation },
macHopperHost,
);
if (result.status === "installed")
@@ -321,9 +328,22 @@ export const systemSetupHost = (
resources,
),
doctor: (scope) => runDoctor(undefined, doctorHost, scope),
desktopBuild: desktopBuildHost(doctorHost, environment, resources),
};
};
const desktopBuildHost = (
doctorHost: DoctorHost,
environment: Readonly<NodeJS.ProcessEnv>,
resources: ArtifactResourceScope,
) =>
systemDesktopBuildSetupHost(
environment,
doctorHost.platform,
doctorHost.clientRegistrations,
resources,
);
const setupInstallerDiagnostic = (
value: string | undefined,
): string | undefined => {
+57 -17
View File
@@ -159,6 +159,10 @@ export const planSetupActions = async (input: {
readonly clientIds: readonly string[];
readonly skillClientIds: readonly string[];
readonly installSkill: boolean;
/** Settings added to one client's registration only, by client id. */
readonly clientEnvironments?:
| Readonly<Record<string, SetupProviderEnvironment>>
| undefined;
}): Promise<{
readonly plannedActions: readonly SetupAction[];
readonly selectedClients: readonly SetupClient[];
@@ -199,27 +203,14 @@ export const planSetupActions = async (input: {
clientsToConfigure: [],
blocker: input.host.registrationCommandBlocker,
};
const inspections = await Promise.all(
selectedClients.map(async (client) => ({
client,
inspection: await input.host.inspectClientConfiguration(
client,
input.providerEnvironment,
input.command,
),
})),
);
const invalid = inspections.find(
({ inspection }) => inspection.status === "invalid",
);
if (invalid?.inspection.status === "invalid") {
const inspections = await inspectSelectedClients(input, selectedClients);
if ("blocker" in inspections)
return {
plannedActions: [],
selectedClients,
clientsToConfigure: [],
blocker: `${invalid.client.displayName ?? invalid.client.name}: ${invalid.inspection.remediation}`,
blocker: inspections.blocker,
};
}
let skillDestinations: readonly SkillDestination[] = [];
try {
if (input.installSkill)
@@ -249,6 +240,7 @@ export const planSetupActions = async (input: {
skillDestinations,
clients: clientPlans,
providerEnvironment: input.providerEnvironment,
clientEnvironments: input.clientEnvironments,
...(input.discovery.linuxPackageFamily === undefined
? {}
: { linuxPackageFamily: input.discovery.linuxPackageFamily }),
@@ -275,6 +267,51 @@ export const planSetupActions = async (input: {
};
};
/** Inspect each selected client's registration; the first invalid one blocks the plan. */
const inspectSelectedClients = async (
input: Parameters<typeof planSetupActions>[0],
selectedClients: readonly SetupClient[],
): Promise<
| readonly {
readonly client: SetupClient;
readonly inspection: ClientConfigurationInspection;
}[]
| { readonly blocker: string }
> => {
const inspections = await Promise.all(
selectedClients.map(async (client) => ({
client,
inspection: await input.host.inspectClientConfiguration(
client,
clientEnvironment(input, client.name),
input.command,
),
})),
);
const invalid = inspections.find(
({ inspection }) => inspection.status === "invalid",
);
return invalid?.inspection.status === "invalid"
? {
blocker: `${invalid.client.displayName ?? invalid.client.name}: ${invalid.inspection.remediation}`,
}
: inspections;
};
/** A client's registration settings: the shared ones, then its own. */
export const clientEnvironment = (
input: {
readonly providerEnvironment: SetupProviderEnvironment;
readonly clientEnvironments?:
| Readonly<Record<string, SetupProviderEnvironment>>
| undefined;
},
client: string,
): SetupProviderEnvironment => ({
...input.providerEnvironment,
...input.clientEnvironments?.[client],
});
/** Selected clients whose registration setup writes, with the disclosed operation. */
const plannedClientWrites = (
inspections: readonly {
@@ -319,6 +356,9 @@ const setupPlan = (input: {
readonly backupPath?: string;
}[];
readonly providerEnvironment: SetupProviderEnvironment;
readonly clientEnvironments?:
| Readonly<Record<string, SetupProviderEnvironment>>
| undefined;
readonly linuxPackageFamily?: LinuxPackageFamily;
readonly command: readonly string[];
}): readonly SetupAction[] => [
@@ -359,7 +399,7 @@ const setupPlan = (input: {
detail: clientConfigurationDetail(
client,
input.command,
input.providerEnvironment,
clientEnvironment(input, client.name),
input.installHopper,
),
external: false,
+20 -2
View File
@@ -14,6 +14,7 @@ import type {
AnalysisError,
} from "../domain/analysisErrorBase.js";
import type { SetupClient } from "./SupportedClients.js";
import type { DesktopBuildSetupHost } from "./SetupDesktopBuild.js";
import type {
SetupFailureCode,
SetupHopperInstallResult,
@@ -115,7 +116,11 @@ export interface SetupHost {
macosVersion(): Promise<string | undefined>;
linuxDistribution(): Promise<LinuxDistribution | undefined>;
initialSetupState(scope?: DoctorScope): Promise<SetupInitialState>;
installHopper(replaceExisting: boolean): Promise<SetupHopperInstallResult>;
/** Cancellation stops downloads and later steps; a started package transaction finishes. */
installHopper(
replaceExisting: boolean,
signal?: AbortSignal,
): Promise<SetupHopperInstallResult>;
/** Retry retained owners. Returned or thrown errors with explicit cleanup
* owners follow live release state; ownerless failures remain unconfirmed.
* runSetup does not observe later direct calls to a caller-supplied host. */
@@ -143,6 +148,8 @@ export interface SetupHost {
): Promise<Result<void, SetupSkillInstallError>>;
installSkill(clientIds: readonly string[]): Promise<SkillInstallResult>;
doctor(scope?: DoctorScope): Promise<Awaited<ReturnType<typeof runDoctor>>>;
/** Present where setup can prepare this host for desktop builds. */
readonly desktopBuild?: DesktopBuildSetupHost;
}
/** Structured setup outcome carrying remediation instead of prompting. */
export interface SetupResult {
@@ -216,7 +223,12 @@ export interface SetupClientState {
/** One concrete setup mutation disclosed before approval. */
export interface SetupAction {
readonly id: string;
readonly kind: "install_hopper" | "configure_client" | "install_skill";
readonly kind:
| "install_hopper"
| "configure_client"
| "install_skill"
| "install_agentenv"
| "pack_build_runtime";
readonly label: string;
readonly target: string;
readonly detail: string;
@@ -230,6 +242,8 @@ export interface SetupAction {
/** Explicit authorization supplied by an interactive or unattended CLI adapter. */
export interface SetupOptions {
/** Caller cancellation, carried through approved effects and cleanup. */
readonly signal?: AbortSignal;
readonly approved: boolean;
readonly installHopper: boolean;
readonly structured: boolean;
@@ -243,6 +257,10 @@ export interface SetupOptions {
readonly readinessScope?: DoctorScope;
readonly dryRun?: boolean;
readonly allDetectedClients?: boolean;
/** Pack the build-computer runtime and record the build settings in the selected registrations. */
readonly desktopBuild?: boolean;
/** With desktopBuild, install the pinned agentenv-framework with uv. */
readonly installAgentenv?: boolean;
readonly onProgress?: (event: SetupProgressEvent) => void;
}
@@ -30,7 +30,7 @@ const reaPayload = async (
): Promise<ReaPayload> => {
const path = environment.REA_BUILD_COMPUTER_REA_ARCHIVE;
const reason =
"A build computer from a request needs REA_BUILD_COMPUTER_REA_ARCHIVE, the absolute path of a tar archive of a runnable REA whose one top folder holds scripts/rea.mjs.";
"A build computer from a request needs REA_BUILD_COMPUTER_REA_ARCHIVE, the absolute path of a tar archive of a runnable REA whose one top folder holds scripts/rea.mjs, or the archive rea setup --desktop-build packs for this REA version.";
if (
path === undefined ||
!isAbsolute(path) ||
@@ -46,7 +46,7 @@ const reaPayload = async (
{
userMessage: reason,
remediationAction:
"Pack a built REA with its production dependencies, set REA_BUILD_COMPUTER_REA_ARCHIVE to the archive and restart REA, or start from an exported bundle folder.",
"Run rea setup --client <client> --desktop-build, or pack a built REA with its production dependencies, set REA_BUILD_COMPUTER_REA_ARCHIVE to the archive and restart REA, or start from an exported bundle folder.",
},
);
return { kind: "file", path };
@@ -36,6 +36,10 @@ import {
ComputerHelper,
type ComputerRelease,
} from "../../desktop/AgentEnvComputerHelper.js";
import {
resolveBuildComputerEnvironment,
type BuildComputerDefaults,
} from "../../desktop/BuildComputerEnvironment.js";
import type { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
import type { ProgressReporter } from "../ProgressReporter.js";
import {
@@ -312,6 +316,7 @@ export class DesktopBuildComputers {
constructor(
private readonly environment: Readonly<NodeJS.ProcessEnv>,
private readonly settle: BuildSettle = SETTLE_TIMING,
private readonly defaults: BuildComputerDefaults = {},
) {}
async start(
@@ -360,6 +365,11 @@ export class DesktopBuildComputers {
progress: ProgressReporter | undefined,
): ReturnType<DesktopBuildComputers["start"]> {
const { source } = input;
const environment = await resolveBuildComputerEnvironment(
this.environment,
process.platform,
this.defaults,
);
let target: {
readonly bundle: string;
readonly name: string;
@@ -369,7 +379,7 @@ export class DesktopBuildComputers {
target =
source.kind === "bundle"
? { bundle: source.path, name: source.path }
: await writeRequestBundle(source, this.environment);
: await writeRequestBundle(source, environment);
} catch (cause: unknown) {
return err(failure(START, cause, signal));
}
@@ -379,7 +389,7 @@ export class DesktopBuildComputers {
try {
helper = await ComputerHelper.spawn({
bundle: target.bundle,
environment: this.environment,
environment,
signal,
operation: START,
});
+17 -3
View File
@@ -36,6 +36,7 @@ export const confirmInteractiveSetup = async (
actions: readonly SetupAction[],
accessible: boolean,
context?: SetupConfirmationContext,
signal?: AbortSignal,
): Promise<SetupConfirmationDecision> => {
if (context?.stage === "select" || context === undefined) {
intro("REA setup", promptStreams);
@@ -46,7 +47,12 @@ export const confirmInteractiveSetup = async (
renderKeyboardHelp(accessible);
}
if (context?.stage === "select") {
const selected = await selectSetupActions(actions, accessible, context);
const selected = await selectSetupActions(
actions,
accessible,
context,
signal,
);
return selected === undefined
? cancelledDecision()
: { approved: false, selectedActionIds: selected };
@@ -55,6 +61,7 @@ export const confirmInteractiveSetup = async (
renderPreflight(actions);
const approved = await confirm({
...promptStreams,
...(signal === undefined ? {} : { signal }),
message:
actions.length === 1
? "Apply this change?"
@@ -122,14 +129,15 @@ const selectSetupActions = async (
actions: readonly SetupAction[],
accessible: boolean,
context: SetupConfirmationContext,
signal?: AbortSignal,
): Promise<readonly string[] | undefined> => {
const clientStates = context.clientStates.filter(
({ client }) => client.format !== "unsupported",
);
const initial = new Set(context.selectedClientIds);
const selectedClientIds = accessible
? await selectClientsAccessibly(clientStates, initial)
: await selectClients(clientStates, initial);
? await selectClientsAccessibly(clientStates, initial, signal)
: await selectClients(clientStates, initial, signal);
if (selectedClientIds === undefined) return undefined;
const selectedActionIds = selectedClientIds.map(
(id) => `configure_client:${id}`,
@@ -140,6 +148,7 @@ const selectSetupActions = async (
else {
const included = await confirm({
...promptStreams,
...(signal === undefined ? {} : { signal }),
message: "Install the guided REA skill for CLI use?",
initialValue: false,
vertical: accessible,
@@ -152,6 +161,7 @@ const selectSetupActions = async (
if (hopper !== undefined) {
const included = await confirm({
...promptStreams,
...(signal === undefined ? {} : { signal }),
message: "Install Hopper for deep binary analysis?",
initialValue: false,
vertical: accessible,
@@ -174,10 +184,12 @@ const clientHint = (state: SetupClientState): string =>
const selectClients = async (
states: readonly SetupClientState[],
initial: ReadonlySet<string>,
signal?: AbortSignal,
): Promise<readonly string[] | undefined> => {
if (states.length === 0) return [];
const selection = await multiselect({
...promptStreams,
...(signal === undefined ? {} : { signal }),
message: "Which agents should use REA?",
options: states.map((state) => ({
value: state.client.name,
@@ -200,11 +212,13 @@ const selectClients = async (
const selectClientsAccessibly = async (
states: readonly SetupClientState[],
initial: ReadonlySet<string>,
signal?: AbortSignal,
): Promise<readonly string[] | undefined> => {
const selected: string[] = [];
for (const state of states) {
const included = await confirm({
...promptStreams,
...(signal === undefined ? {} : { signal }),
message: `Configure ${clientDisplayNames.get(state.client.name) ?? state.client.name}? ${clientHint(state)}`,
initialValue: initial.has(state.client.name),
vertical: true,
+58 -13
View File
@@ -2,7 +2,10 @@ import { withCommandCancellation } from "./commandCancellation.js";
import { z } from "incur";
import type { DoctorScope } from "../application/Doctor.js";
import { DOCTOR_PROVIDER_IDS } from "../application/DoctorProviderIds.js";
import {
DESKTOP_BUILD_PROVIDER_ID,
DOCTOR_PROVIDER_IDS,
} from "../application/DoctorProviderIds.js";
import {
isSetupFailure,
type SetupOptions,
@@ -59,6 +62,18 @@ const registerSetupCommand = (
.boolean()
.default(false)
.describe("Also approve Hopper installation with --yes"),
desktopBuild: z
.boolean()
.default(false)
.describe(
"Prepare this host for desktop builds: pack REA's build-computer runtime and record the build settings in the selected registrations",
),
installAgentenv: z
.boolean()
.default(false)
.describe(
"With --desktop-build, also install the agentenv-framework release bundles target with uv",
),
client: z
.array(supportedClientSchema)
.default([])
@@ -100,11 +115,14 @@ const registerSetupCommand = (
}),
alias: { yes: "y" },
run: ({ options, formatExplicit }) =>
logCliCommand(
logger,
CLI_COMMANDS.setup,
() => runSetupCommand({ options, formatExplicit }, environment),
isSetupFailure,
withCommandCancellation((signal) =>
logCliCommand(
logger,
CLI_COMMANDS.setup,
() =>
runSetupCommand({ options, formatExplicit }, environment, signal),
isSetupFailure,
),
),
});
};
@@ -125,7 +143,9 @@ const registerDoctorCommand = (
provider: z
.array(z.enum(DOCTOR_PROVIDER_IDS))
.default([])
.describe("Deep-analysis provider whose readiness is required"),
.describe(
"Deep-analysis provider, or desktop_build for a desktop build host, whose readiness is required",
),
skill: z
.boolean()
.optional()
@@ -207,6 +227,8 @@ const registerMaintenanceCommands = (
interface SetupCommandOptions {
readonly yes: boolean;
readonly installHopper: boolean;
readonly desktopBuild: boolean;
readonly installAgentenv: boolean;
readonly client: readonly string[];
readonly skillClient: readonly string[];
readonly toolFamilies?: string | undefined;
@@ -222,6 +244,7 @@ const runSetupCommand = async (
readonly formatExplicit: boolean;
},
environment: Readonly<NodeJS.ProcessEnv>,
signal: AbortSignal,
) => {
const { options } = input;
const interactive = setupIsInteractive(options, input.formatExplicit);
@@ -233,11 +256,11 @@ const runSetupCommand = async (
import("../doctorRuntime.js"),
]);
const result = await runSetup(
setupRunOptions(input, interactive, hasExplicitScope),
systemSetupHost(createSystemDoctorHost(environment), environment),
{ ...setupRunOptions(input, interactive, hasExplicitScope), signal },
systemSetupHost(createSystemDoctorHost(environment, signal), environment),
interactive
? (actions, context) =>
confirmInteractiveSetup(actions, options.accessible, context)
confirmInteractiveSetup(actions, options.accessible, context, signal)
: undefined,
);
if (interactive) renderInteractiveSetupResult(result, environment);
@@ -261,10 +284,12 @@ const setupRunOptions = (
hasExplicitScope: boolean,
): SetupOptions => {
const { options } = input;
const desktopBuild = desktopBuildSelected(options);
const agentIntegrationSelected =
options.allDetected ||
options.client.length > 0 ||
options.skillClient.length > 0;
options.skillClient.length > 0 ||
desktopBuild;
const hasSelectedClients =
hasExplicitScope &&
(options.client.length > 0 || options.skillClient.length > 0);
@@ -275,6 +300,7 @@ const setupRunOptions = (
dryRun: options.dryRun,
allDetectedClients: options.allDetected,
proposeHopper: interactive || options.installHopper,
...desktopBuildOptions(options),
...(hasSelectedClients ? { clientIds: options.client } : {}),
...(options.skillClient.length > 0
? { skillClientIds: options.skillClient }
@@ -293,7 +319,7 @@ const setupRunOptions = (
...(options.skillClient.length > 0
? { skillClients: options.skillClient }
: {}),
providers: options.installHopper ? ["hopper"] : [],
providers: setupProviders(options),
skill: options.skill ?? agentIntegrationSelected,
},
}
@@ -301,6 +327,23 @@ const setupRunOptions = (
};
};
/** `--install-agentenv` implies `--desktop-build`. */
const desktopBuildSelected = (options: SetupCommandOptions): boolean =>
options.desktopBuild || options.installAgentenv;
const desktopBuildOptions = (
options: SetupCommandOptions,
): Pick<SetupOptions, "desktopBuild" | "installAgentenv"> =>
desktopBuildSelected(options)
? { desktopBuild: true, installAgentenv: options.installAgentenv }
: {};
/** Providers whose readiness the selected setup requires. */
const setupProviders = (options: SetupCommandOptions): readonly string[] => [
...(options.installHopper ? ["hopper"] : []),
...(desktopBuildSelected(options) ? [DESKTOP_BUILD_PROVIDER_ID] : []),
];
const setupIsInteractive = (
options: SetupCommandOptions,
formatExplicit: boolean,
@@ -320,7 +363,9 @@ const setupHasExplicitScope = (options: SetupCommandOptions): boolean =>
options.client.length > 0 ||
options.skillClient.length > 0 ||
options.skill !== undefined ||
options.installHopper;
options.installHopper ||
options.desktopBuild ||
options.installAgentenv;
const doctorScope = (options: {
readonly client: readonly string[];
+4
View File
@@ -122,6 +122,7 @@ import type { FlutterBuildAnalysisPort } from "../application/flutter/FlutterBui
import type { JebAnalysisPort } from "../application/jeb/JebAnalysisPort.js";
import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysisPort.js";
import type { BuildSettle } from "../application/desktop/DesktopBuildSettle.js";
import type { BuildComputerDefaults } from "../desktop/BuildComputerEnvironment.js";
/** Typed injection inputs shared by a capability host, independent of transport. */
export interface CapabilityServiceOptions {
@@ -162,6 +163,8 @@ export interface CapabilityServiceOptions {
readonly fridaInstrumentation?: FridaInstrumentationPort;
/** How build computers read an app until its files settle; the shipped timing on real time unless a test shortens or clocks it. */
readonly desktopBuildSettle?: BuildSettle;
/** Where build computers look for unset settings; the host's PATH and REA cache unless a test isolates them. */
readonly desktopBuildDefaults?: BuildComputerDefaults;
readonly reverseEngineering?: ReverseEngineeringService;
}
@@ -339,6 +342,7 @@ const modules = {
new DesktopBuildComputers(
providerEnvironment,
options.desktopBuildSettle,
options.desktopBuildDefaults,
),
(value) => value.closeAll(),
),
+10 -2
View File
@@ -9,15 +9,23 @@ export const defaultKunaCacheDirectory = (
home: string,
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
): string => reaCacheDirectory(home, environment, platform, "kuna");
/** A per-user cache directory REA owns on this host: `rea/<name>` under the platform's cache root. */
export const reaCacheDirectory = (
home: string,
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
name: string,
): string => {
if (platform === "darwin")
return appendCallerPath(home, "Library/Caches/rea/kuna", platform);
return appendCallerPath(home, `Library/Caches/rea/${name}`, platform);
const xdg = environment.XDG_CACHE_HOME;
const root =
xdg !== undefined && isAbsolute(xdg)
? xdg
: appendCallerPath(home, ".cache", platform);
return appendCallerPath(root, "rea/kuna", platform);
return appendCallerPath(root, `rea/${name}`, platform);
};
/**
+2 -2
View File
@@ -28,7 +28,7 @@ const HANDSHAKE_BYTES = 64 * 1024;
const STDERR_BYTES = 16 * 1024;
const TEARDOWN_MS = 120_000;
const RUNNER_REMEDIATION =
"Set REA_AGENT_ENV_COMMAND to the absolute path of the agent-env program of an agentenv-framework install (a venv, uv tool or pipx), then restart REA.";
"Put agent-env on PATH, or set REA_AGENT_ENV_COMMAND to the absolute path of the agent-env program of an agentenv-framework install (a venv, uv tool or pipx), then restart REA. rea doctor --provider desktop_build checks the build host.";
const handshakeSchema = z.union([
z.strictObject({
@@ -90,7 +90,7 @@ export const agentEnvInterpreter = async (
if (command === undefined || !isAbsolute(command))
throw unavailable(
operation,
"Build computers need REA_AGENT_ENV_COMMAND, the absolute path of agentenv's agent-env program; REA runs its helper with that runner's Python.",
"Build computers need REA_AGENT_ENV_COMMAND, the absolute path of agentenv's agent-env program, or agent-env on PATH; REA runs its helper with that runner's Python.",
);
let program: string;
let head: string;
@@ -0,0 +1,85 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { resolveBuildComputerEnvironment } from "./BuildComputerEnvironment.js";
const host = async () => {
const root = await createTestTempDirectory("rea-build-environment-");
const bin = join(root, "bin");
const cache = join(root, "cache");
const archive = join(
cache,
"rea/build-computer",
`rea-${PRODUCT_IDENTITY.packageVersion}.tar.xz`,
);
await mkdir(bin);
await mkdir(join(cache, "rea/build-computer"), { recursive: true });
for (const program of ["agent-env", "docker"])
await writeFile(join(bin, program), "#!/bin/sh\n", { mode: 0o755 });
await writeFile(archive, "packed");
const environment = {
HOME: join(root, "home"),
PATH: `relative-bin:${bin}`,
XDG_CACHE_HOME: cache,
};
return { bin, archive, environment };
};
it.skipIf(process.platform === "win32")(
"resolves every build setting on a host that exports none of them",
async () => {
const { bin, archive, environment } = await host();
const resolved = await resolveBuildComputerEnvironment(
environment,
"linux",
);
expect(resolved).toMatchObject({
REA_AGENT_ENV_COMMAND: join(bin, "agent-env"),
REA_DOCKER_COMMAND: join(bin, "docker"),
REA_BUILD_COMPUTER_REA_ARCHIVE: archive,
});
},
);
it.skipIf(process.platform === "win32")(
"keeps each build setting the environment sets",
async () => {
const { environment } = await host();
const selected = {
REA_AGENT_ENV_COMMAND: "/opt/agentenv/bin/agent-env",
REA_DOCKER_COMMAND: "/usr/local/bin/docker",
REA_BUILD_COMPUTER_REA_ARCHIVE: "/srv/rea-prod.tar.xz",
};
expect(
await resolveBuildComputerEnvironment(
{ ...environment, ...selected },
"linux",
),
).toMatchObject(selected);
},
);
it.skipIf(process.platform === "win32")(
"looks for defaults only where the caller points",
async () => {
const { environment } = await host();
const empty = await createTestTempDirectory("rea-build-defaults-empty-");
const resolved = await resolveBuildComputerEnvironment(
environment,
"linux",
{ archiveDirectory: empty, searchPath: empty },
);
expect(resolved).not.toHaveProperty("REA_AGENT_ENV_COMMAND");
expect(resolved).not.toHaveProperty("REA_DOCKER_COMMAND");
expect(resolved).not.toHaveProperty("REA_BUILD_COMPUTER_REA_ARCHIVE");
},
);
+126
View File
@@ -0,0 +1,126 @@
import { stat } from "node:fs/promises";
import { isAbsolute } from "node:path";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { reaCacheDirectory } from "../config/kunaCacheDirectory.js";
import { appendCallerPath } from "../filesystem/callerPath.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { resolveProgram } from "./ProgramLookup.js";
/** The settings a build computer reads where the REA server starts. */
export const BUILD_COMPUTER_SETTINGS = [
"REA_AGENT_ENV_COMMAND",
"REA_DOCKER_COMMAND",
"REA_BUILD_COMPUTER_REA_ARCHIVE",
] as const;
/** One of the build computer's settings. */
export type BuildComputerSetting = (typeof BUILD_COMPUTER_SETTINGS)[number];
/** The REA-owned folder `rea setup --desktop-build` packs runtime archives into. */
export const buildComputerArchiveDirectory = (
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform = process.platform,
): string =>
reaCacheDirectory(
homeDirectoryFromEnvironment(environment, platform),
environment,
platform,
"build-computer",
);
/** The runtime archive packed for this REA version, named by that version. */
export const defaultBuildComputerArchive = (
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform = process.platform,
directory = buildComputerArchiveDirectory(environment, platform),
): string =>
appendCallerPath(
directory,
`rea-${PRODUCT_IDENTITY.packageVersion}.tar.xz`,
platform,
);
/** The agentenv-framework version `agent-env --version` reports, as `agent-env <version>`. */
export const agentEnvReportedVersion = (stdout: string): string | undefined =>
/^agent-env (\S+)\s*$/u.exec(stdout.trim())?.[1];
/** An absolute program on PATH, never one a relative PATH entry found. */
export const programOnPath = async (
program: string,
environment: Readonly<NodeJS.ProcessEnv>,
): Promise<string | undefined> => {
const found = await resolveProgram(program, environment, process.cwd());
return found === undefined || found.fromRelativeEntry
? undefined
: found.path;
};
/** Resolve a recorded command using the same empty-value fallback as runtime. */
export const resolveBuildComputerProgram = (
value: string | undefined,
program: string,
environment: Readonly<NodeJS.ProcessEnv>,
): Promise<string | undefined> =>
value === undefined || value === ""
? programOnPath(program, environment)
: Promise.resolve(value);
const isFile = (path: string): Promise<boolean> =>
stat(path).then(
(entry) => entry.isFile(),
() => false,
);
/** Where unset build settings are looked for; a test points both at its own folders. */
export interface BuildComputerDefaults {
/** The folder holding the archive packed for this REA version. */
readonly archiveDirectory?: string;
/** The PATH searched for `agent-env` and `docker`. */
readonly searchPath?: string;
}
/**
* Fill each unset build setting with its default: `agent-env` and `docker`
* on PATH, and the archive packed for this REA version when it exists.
*/
export const resolveBuildComputerEnvironment = async (
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform = process.platform,
sources: BuildComputerDefaults = {},
): Promise<Readonly<NodeJS.ProcessEnv>> => {
const defaults: Record<string, string> = {};
const unset = (name: BuildComputerSetting) =>
environment[name] === undefined || environment[name] === "";
const search = { PATH: sources.searchPath ?? environment.PATH };
const agentEnv = unset("REA_AGENT_ENV_COMMAND")
? await resolveBuildComputerProgram(
environment.REA_AGENT_ENV_COMMAND,
"agent-env",
search,
)
: undefined;
if (agentEnv !== undefined) defaults.REA_AGENT_ENV_COMMAND = agentEnv;
const docker = unset("REA_DOCKER_COMMAND")
? await resolveBuildComputerProgram(
environment.REA_DOCKER_COMMAND,
"docker",
search,
)
: undefined;
if (docker !== undefined) defaults.REA_DOCKER_COMMAND = docker;
const archive = defaultBuildComputerArchive(
environment,
platform,
sources.archiveDirectory,
);
if (
unset("REA_BUILD_COMPUTER_REA_ARCHIVE") &&
isAbsolute(archive) &&
(await isFile(archive))
)
defaults.REA_BUILD_COMPUTER_REA_ARCHIVE = archive;
return Object.keys(defaults).length === 0
? environment
: { ...environment, ...defaults };
};
+600
View File
@@ -0,0 +1,600 @@
import { execFile } from "node:child_process";
import { constants } from "node:fs";
import {
mkdir,
open,
readFile,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { runDoctor, systemDoctorHost } from "../application/Doctor.js";
import { readDesktopBuildFacts } from "./DesktopBuildDoctorFacts.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import {
defaultBuildComputerArchive,
resolveBuildComputerEnvironment,
} from "./BuildComputerEnvironment.js";
const execute = promisify(execFile);
const program = (path: string, body: string) =>
writeFile(path, `#!/bin/sh\n${body}\n`, { mode: 0o755 });
const configRunner = (path: string) =>
program(
path,
`
case "$1 $2" in
"config debug") printf '{"search_path":[{"path":"%s","exists":true}]}' "$AGENT_ENV_CONFIG" ;;
"config explain") printf '%s' "$FAKE_EXPLANATION" ;;
*) echo 'agent-env 0.9.1431' ;;
esac`,
);
const buildHost = async (prefix: string) => {
const root = await createTestTempDirectory(prefix);
const bin = join(root, "bin");
const home = join(root, "home");
await mkdir(bin);
await mkdir(home);
await program(join(bin, "ps"), "exit 0");
return { root, bin, home };
};
const readFacts = async (
environment: NodeJS.ProcessEnv,
clientIds: readonly string[],
) => {
const host = systemDoctorHost({
environment,
platform: "linux",
architecture: "x64",
desktopBuildFacts: readDesktopBuildFacts,
});
try {
return await host.desktopBuildFacts!(clientIds);
} finally {
await host.close?.();
}
};
it.skipIf(process.platform === "win32")(
"refuses a FIFO model configuration without waiting for a writer",
async () => {
const root = await createTestTempDirectory("rea-doctor-fifo-config-");
const fifo = join(root, "config.toml");
const runner = join(root, "agent-env");
await execute("/usr/bin/mkfifo", [fifo]);
await configRunner(runner);
const resources = new ArtifactResourceScope();
const controller = new AbortController();
const pending = readDesktopBuildFacts({
environment: {
HOME: root,
PATH: "",
AGENT_ENV_CONFIG: fifo,
REA_AGENT_ENV_COMMAND: runner,
},
platform: process.platform,
resources,
signal: controller.signal,
run: async (_command, args) => {
if (args[0] === "--version")
return {
stdout: "agent-env 0.9.1431",
stderr: "",
stdoutBytes: 19,
stderrBytes: 0,
};
if (args[1] !== "debug")
throw new Error("config explanation must not run for a FIFO");
const stdout = JSON.stringify({
search_path: [{ path: fifo, exists: true }],
});
return {
stdout,
stderr: "",
stdoutBytes: stdout.length,
stderrBytes: 0,
};
},
clientIds: [],
registrations: async () => [],
});
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const facts = await Promise.race([
pending,
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error("FIFO read blocked")),
2_000,
);
}),
]);
expect(facts.solveEndpoint).toBeNull();
} finally {
if (timer !== undefined) clearTimeout(timer);
controller.abort();
const writer = await open(
fifo,
constants.O_WRONLY | constants.O_NONBLOCK,
).catch(() => undefined);
await writer?.close();
await Promise.allSettled([pending]);
await resources.close();
}
},
);
it("propagates cancellation before reading a model configuration", async () => {
const root = await createTestTempDirectory("rea-doctor-cancel-config-");
const config = join(root, "config.toml");
await writeFile(config, "[model]\nname = 'example'\n");
const resources = new ArtifactResourceScope();
const controller = new AbortController();
const reason = new Error("cancel doctor");
try {
const failure: unknown = await readDesktopBuildFacts({
environment: { HOME: root, PATH: "", AGENT_ENV_CONFIG: config },
platform: process.platform,
resources,
signal: controller.signal,
run: async () => {
throw new Error("unexpected command");
},
clientIds: [],
registrations: async () => {
controller.abort(reason);
return [];
},
}).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(AnalysisCancelledError);
expect(failure).toMatchObject({ cause: reason });
} finally {
await resources.close();
}
});
it.skipIf(process.platform === "win32")(
"reads a symlinked regular model configuration",
async () => {
const root = await createTestTempDirectory("rea-doctor-linked-config-");
const target = join(root, "model.toml");
const link = join(root, "config.toml");
const runner = join(root, "agent-env");
await configRunner(runner);
await writeFile(target, "[model]\nbase_url = 'http://example.test'\n");
await symlink(target, link);
const facts = await readFacts(
{
HOME: root,
PATH: "",
AGENT_ENV_CONFIG: link,
REA_AGENT_ENV_COMMAND: runner,
FAKE_EXPLANATION: JSON.stringify({
path: "model.base_url",
value: "http://example.test",
error: null,
}),
},
[],
);
expect(facts.solveEndpoint).toBe(link);
},
);
it.skipIf(process.platform === "win32")(
"uses the owner's endpoint result and leaves references unknown",
async () => {
const root = await createTestTempDirectory("rea-doctor-model-config-");
const config = join(root, "config.toml");
const runner = join(root, "agent-env");
await configRunner(runner);
await writeFile(config, "[model]\ndefault = 'model-only'\n");
const environment = {
HOME: root,
PATH: "",
AGENT_ENV_CONFIG: config,
REA_AGENT_ENV_COMMAND: runner,
};
const explain = (value: unknown) =>
JSON.stringify({ path: "model.base_url", value, error: null });
expect(
(await readFacts({ ...environment, FAKE_EXPLANATION: explain(null) }, []))
.solveEndpoint,
).toBeUndefined();
expect(
(
await readFacts(
{
...environment,
FAKE_EXPLANATION: JSON.stringify({
path: "model.base_url",
value: "",
error: null,
winner: { kind: "env" },
}),
},
[],
)
).solveEndpoint,
).toBeNull();
expect(
(
await readFacts(
{ ...environment, FAKE_EXPLANATION: explain("env:MODEL_BASE") },
[],
)
).solveEndpoint,
).toBeNull();
expect(
(
await readFacts(
{ ...environment, FAKE_EXPLANATION: explain("http://endpoint.test") },
[],
)
).solveEndpoint,
).toBe(config);
},
);
it.skipIf(process.platform === "win32")(
"reports an endpoint query timeout as unknown under doctor's owned deadline",
async () => {
const root = await createTestTempDirectory("rea-doctor-endpoint-timeout-");
const runner = join(root, "agent-env");
await configRunner(runner);
let deadline: number | undefined;
let stopSignal: NodeJS.Signals | undefined;
const host = systemDoctorHost({
environment: { HOME: root, PATH: "", REA_AGENT_ENV_COMMAND: runner },
platform: "linux",
architecture: "x64",
desktopBuildFacts: readDesktopBuildFacts,
execFileOutput: async (_command, args, options) => {
if (args[0] === "config") {
deadline = options?.timeout;
stopSignal = options?.stopSignal;
throw Object.assign(new Error("timed out"), { code: "ETIMEDOUT" });
}
return {
stdout: "agent-env 0.9.1431",
stderr: "",
stdoutBytes: 19,
stderrBytes: 0,
};
},
});
try {
expect((await host.desktopBuildFacts!([])).solveEndpoint).toBeNull();
expect(deadline).toBe(10_000);
expect(stopSignal).toBe("SIGTERM");
} finally {
await host.close?.();
}
},
);
it.skipIf(process.platform === "win32")(
"uses the runtime PATH fallback for a registered empty runner",
async () => {
const { root, bin, home } = await buildHost("rea-desktop-empty-runner-");
const runner = join(bin, "agent-env");
await program(runner, 'echo "agent-env 0.9.1431"');
const environment = {
HOME: home,
USERPROFILE: home,
PATH: `${bin}:/usr/bin:/bin`,
XDG_CACHE_HOME: join(root, "cache"),
};
await writeFile(
join(home, ".claude.json"),
JSON.stringify({
mcpServers: {
rea: {
command: process.execPath,
args: ["/opt/rea/scripts/rea.mjs", "mcp"],
env: { REA_AGENT_ENV_COMMAND: "" },
},
},
}),
);
expect(
(
await resolveBuildComputerEnvironment(
{ ...environment, REA_AGENT_ENV_COMMAND: "" },
"linux",
)
).REA_AGENT_ENV_COMMAND,
).toBe(runner);
const facts = await readFacts(environment, ["claude_code"]);
expect(facts.registrations[0]).toMatchObject({
runner: { status: "current", version: "0.9.1431" },
});
expect(facts.registrations[0]?.differing ?? []).not.toContain(
"REA_AGENT_ENV_COMMAND",
);
expect(
(
await readFacts(
{ ...environment, REA_AGENT_ENV_COMMAND: "/different/agent-env" },
["claude_code"],
)
).registrations[0]?.differing,
).toContain("REA_AGENT_ENV_COMMAND");
const host = systemDoctorHost({
environment,
platform: "linux",
architecture: "x64",
desktopBuildFacts: readDesktopBuildFacts,
});
try {
const report = await runDoctor(undefined, host, {
providers: ["desktop_build"],
clients: ["claude_code"],
});
expect(
report.scope_checks.find(
({ name }) => name === "desktop_build-agent-env",
),
).toMatchObject({ ok: true });
} finally {
await host.close?.();
}
await program(runner, 'echo "agent-env 0.9.1400"');
expect(
(await readFacts(environment, ["claude_code"])).registrations[0],
).toMatchObject({ runner: { status: "stale", version: "0.9.1400" } });
await rm(runner);
expect(
(await readFacts(environment, ["claude_code"])).registrations[0],
).toMatchObject({ runner: { status: "invalid" } });
},
);
it.skipIf(process.platform === "win32")(
"compares registrations to resolved defaults when the terminal has no overrides",
async () => {
const { root, bin, home } = await buildHost("rea-desktop-doctor-defaults-");
await program(join(bin, "docker"), "exit 1");
await program(
join(bin, "agent-env"),
'if [ "$1" = "--version" ]; then printf x >> "$REA_TEST_RUNNER_LOG"; fi; echo "agent-env 0.9.1431"',
);
const environment = {
HOME: home,
USERPROFILE: home,
PATH: `${bin}:/usr/bin:/bin`,
XDG_CACHE_HOME: join(root, "cache"),
REA_TEST_RUNNER_LOG: join(root, "runner-log"),
};
await writeFile(
join(home, ".claude.json"),
JSON.stringify({
mcpServers: {
rea: {
command: process.execPath,
args: ["/opt/rea/scripts/rea.mjs", "mcp"],
env: {
REA_DOCKER_COMMAND: join(bin, "docker"),
REA_AGENT_ENV_COMMAND: join(bin, "agent-env"),
REA_BUILD_COMPUTER_REA_ARCHIVE: defaultBuildComputerArchive(
environment,
"linux",
),
},
},
},
}),
);
const facts = await readFacts(environment, ["claude_code"]);
expect(await readFile(environment.REA_TEST_RUNNER_LOG, "utf8")).toBe("x");
expect(facts.registrations).toMatchObject([
{
client: "claude_code",
registered: true,
unrecorded: [],
unresolved: [],
},
]);
},
);
it.skipIf(process.platform === "win32")(
"compares explicitly selected paths and reports unchecked recorded paths by name only",
async () => {
const { root, bin, home } = await buildHost(
"rea-desktop-doctor-registration-",
);
await writeFile(
join(home, ".claude.json"),
JSON.stringify({
mcpServers: {
rea: {
command: process.execPath,
args: ["/opt/rea/scripts/rea.mjs", "mcp"],
env: {
REA_AGENT_ENV_COMMAND: "/private/old-agent-env",
REA_DOCKER_COMMAND: "/private/docker",
REA_BUILD_COMPUTER_REA_ARCHIVE: "/private/old-archive",
},
},
},
}),
);
const facts = await readFacts(
{
HOME: home,
USERPROFILE: home,
PATH: `${bin}:/usr/bin:/bin`,
XDG_CACHE_HOME: join(root, "cache"),
REA_AGENT_ENV_COMMAND: "/private/current-agent-env",
REA_DOCKER_COMMAND: "/private/docker",
},
["claude_code"],
);
expect(facts.registrations).toMatchObject([
{
client: "claude_code",
registered: true,
differing: ["REA_AGENT_ENV_COMMAND", "REA_BUILD_COMPUTER_REA_ARCHIVE"],
runner: { status: "invalid" },
},
]);
expect(JSON.stringify(facts.registrations)).not.toContain("/private/");
},
);
it.skipIf(process.platform === "win32")(
"reads a refused daemon and names unrecorded settings without their values",
async () => {
const { root, bin, home } = await buildHost("rea-desktop-doctor-facts-");
await program(
join(bin, "docker"),
'echo "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock" >&2\nexit 1',
);
await program(join(bin, "agent-env"), 'echo "agent-env 0.9.1400"');
await writeFile(
join(home, ".claude.json"),
JSON.stringify({
mcpServers: {
rea: {
command: process.execPath,
args: ["/opt/rea/scripts/rea.mjs", "mcp"],
env: { REA_AGENT_ENV_COMMAND: "/private/value/agent-env" },
},
},
}),
);
const facts = await readFacts(
{
HOME: home,
USERPROFILE: home,
PATH: `${bin}:/usr/bin:/bin`,
XDG_CACHE_HOME: join(root, "cache"),
},
["claude_code"],
);
expect(facts).toMatchObject({
ps: { found: true },
docker: { status: "found", path: join(bin, "docker"), source: "path" },
daemon: { status: "permission_denied" },
agentEnv: { status: "found", version: "0.9.1400" },
archive: { source: "cache", status: "missing" },
registrations: [
{
client: "claude_code",
registered: true,
unrecorded: ["REA_BUILD_COMPUTER_REA_ARCHIVE", "REA_DOCKER_COMMAND"],
unresolved: [],
},
],
leftoverContainers: undefined,
});
expect(JSON.stringify(facts)).not.toContain("/private/value");
},
);
it.skipIf(process.platform === "win32")(
"reads a selected archive's REA version and the sandboxes Docker still holds",
async () => {
const { root, bin, home } = await buildHost("rea-desktop-doctor-archive-");
await program(
join(bin, "docker"),
[
'if [ "$1" = info ]; then echo /srv/docker; exit 0; fi',
'case "$4" in name=*) echo agent-0f3a;; label=*) printf "agent-0f3a\\nrelay-0f3a\\n";; esac',
].join("\n"),
);
const packed = join(root, "packed");
await mkdir(join(packed, "rea-prod"), { recursive: true });
await mkdir(join(packed, "rea-prod", "scripts"));
await writeFile(join(packed, "rea-prod", "scripts", "rea.mjs"), "");
await writeFile(
join(packed, "rea-prod/package.json"),
JSON.stringify({ name: "rea-agents", version: "6.7.0" }),
);
const archive = join(root, "rea-prod.tar.xz");
await execute("tar", ["-cJf", archive, "-C", packed, "rea-prod"]);
const facts = await readFacts(
{
HOME: home,
USERPROFILE: home,
PATH: `${bin}:/usr/bin:/bin`,
REA_AGENT_ENV_COMMAND: "agent-env",
REA_BUILD_COMPUTER_REA_ARCHIVE: archive,
},
[],
);
expect(facts).toMatchObject({
daemon: { status: "reachable", rootDirectory: "/srv/docker" },
agentEnv: { status: "invalid", value: "agent-env" },
archive: { path: archive, source: "environment", status: "stale" },
environmentSettings: [
"REA_AGENT_ENV_COMMAND",
"REA_BUILD_COMPUTER_REA_ARCHIVE",
],
leftoverContainers: ["agent-0f3a", "relay-0f3a"],
});
expect(facts.archive.detail).toContain("6.7.0");
},
);
it("recognizes a gzip runtime archive with a different top folder", async () => {
const { root, bin, home } = await buildHost(
"rea-desktop-doctor-archive-gzip-",
);
const packed = join(root, "packed");
await mkdir(join(packed, "package", "scripts"), { recursive: true });
await writeFile(join(packed, "package", "scripts", "rea.mjs"), "");
await writeFile(
join(packed, "package", "package.json"),
JSON.stringify({ version: PRODUCT_IDENTITY.packageVersion }),
);
const archive = join(root, "runtime.tar.gz");
await execute("tar", ["-czf", archive, "-C", packed, "package"]);
const facts = await readFacts(
{
HOME: home,
USERPROFILE: home,
PATH: `${bin}:/usr/bin:/bin`,
REA_BUILD_COMPUTER_REA_ARCHIVE: archive,
},
[],
);
expect(facts.archive).toMatchObject({ path: archive, status: "current" });
const python = (await execute("which", ["python3"])).stdout.trim();
const runner = join(bin, "agent-env");
await writeFile(runner, `#!${python}\nprint('agent-env 0.9.1431')\n`, {
mode: 0o755,
});
const withoutSystemPython = await readFacts(
{
HOME: home,
USERPROFILE: home,
PATH: bin,
REA_AGENT_ENV_COMMAND: runner,
REA_BUILD_COMPUTER_REA_ARCHIVE: archive,
},
[],
);
expect(withoutSystemPython.archive).toMatchObject({
path: archive,
status: "current",
});
});
+524
View File
@@ -0,0 +1,524 @@
import { constants } from "node:fs";
import { access, stat, statfs } from "node:fs/promises";
import { dirname, isAbsolute, join } from "node:path";
import { z } from "zod";
import type {
DesktopBuildCommand,
DesktopBuildFacts,
DesktopBuildFactsInput,
} from "../application/DesktopBuildDoctor.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { safeParseJson } from "../domain/safeJson.js";
import {
readRegularFileText,
RetainedRegularFileCleanupFailure,
} from "../application/RegularFileRead.js";
import { DESKTOP_EXPORT_FRAMEWORK_VERSION } from "../domain/desktop/appExport.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { execFileOutputFailure } from "../process/ExecFileOutput.js";
import { agentEnvInterpreter } from "./AgentEnvComputerHelper.js";
import {
agentEnvReportedVersion,
BUILD_COMPUTER_SETTINGS,
defaultBuildComputerArchive,
programOnPath,
resolveBuildComputerProgram,
type BuildComputerSetting,
} from "./BuildComputerEnvironment.js";
const DEFAULT_DOCKER_ROOT = "/var/lib/docker";
const CONFIG_BYTES = 1024 * 1024;
/** Inspect the same tar compression formats as the build's Python extractor without extracting files. */
const ARCHIVE_VERSION = String.raw`
import json, pathlib, sys, tarfile
roots, versions = set(), {}
with tarfile.open(sys.argv[1], "r|*") as archive:
for entry in archive:
parts = pathlib.PurePosixPath(entry.name).parts
if len(parts) == 3 and parts[1:] == ("scripts", "rea.mjs") and (entry.isfile() or entry.issym()):
roots.add(parts[0])
elif len(parts) == 2 and parts[1] == "package.json" and entry.isfile() and entry.size <= 1048576:
stream = archive.extractfile(entry)
if stream is not None:
try:
versions[parts[0]] = json.load(stream).get("version")
except (ValueError, UnicodeError, AttributeError):
pass
print(json.dumps({"version": versions.get(next(iter(roots))) if len(roots) == 1 else None}))
`;
/** Read what a desktop build needs from this host, changing nothing. */
export const readDesktopBuildFacts = async (
input: DesktopBuildFactsInput,
): Promise<DesktopBuildFacts> => {
const { environment } = input;
const docker = await settingCommand(
environment,
"REA_DOCKER_COMMAND",
"docker",
);
const daemon =
docker.status === "found"
? await dockerDaemon(input, docker.path)
: ({ status: "unchecked" } as const);
const agentEnv = await settingCommand(
environment,
"REA_AGENT_ENV_COMMAND",
"agent-env",
);
const versions = new Map<string, Promise<string | undefined>>();
const versionFor = (path: string): Promise<string | undefined> => {
const existing = versions.get(path);
if (existing !== undefined) return existing;
const pending = agentEnvVersion(input, path);
versions.set(path, pending);
return pending;
};
const archive = await archiveFact(input, agentEnv);
const chosenCommand = (
name: BuildComputerSetting,
command: DesktopBuildCommand,
) =>
(environment[name] ?? "") !== ""
? environment[name]
: command.status === "found"
? command.path
: undefined;
const selectedDocker = chosenCommand("REA_DOCKER_COMMAND", docker);
const selectedAgentEnv = chosenCommand("REA_AGENT_ENV_COMMAND", agentEnv);
const selectedSettings: Partial<Record<BuildComputerSetting, string>> = {
...(selectedDocker === undefined
? {}
: { REA_DOCKER_COMMAND: selectedDocker }),
...(selectedAgentEnv === undefined
? {}
: { REA_AGENT_ENV_COMMAND: selectedAgentEnv }),
REA_BUILD_COMPUTER_REA_ARCHIVE: archive.path,
};
return {
ps: await psFact(input),
docker,
daemon,
disk: await freeSpace(
daemon.status === "reachable"
? daemon.rootDirectory
: DEFAULT_DOCKER_ROOT,
),
agentEnv:
agentEnv.status === "found"
? { ...agentEnv, version: await versionFor(agentEnv.path) }
: agentEnv,
archive,
registrations: await registrationFacts(input, selectedSettings, versionFor),
environmentSettings: BUILD_COMPUTER_SETTINGS.filter(
(name) => (environment[name] ?? "") !== "",
),
solveEndpoint: await solveEndpoint(input, agentEnv),
leftoverContainers:
docker.status === "found" && daemon.status === "reachable"
? await leftoverContainers(input, docker.path)
: undefined,
};
};
/** Run a bounded command; undefined output with its stderr when it fails. */
const attempt = async (
input: DesktopBuildFactsInput,
command: string,
arguments_: readonly string[],
): Promise<
| { readonly ok: true; readonly stdout: string }
| { readonly ok: false; readonly reason: string }
> => {
try {
const { stdout } = await input.run(command, arguments_, {
env: input.environment,
});
return { ok: true, stdout };
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) throw cause;
const failure = execFileOutputFailure(cause);
const stderr = failure?.stderr.trim() ?? "";
return {
ok: false,
reason:
stderr === ""
? cause instanceof Error
? cause.message
: String(cause)
: stderr,
};
}
};
const executable = (path: string): Promise<boolean> =>
access(path, constants.X_OK).then(
async () => (await stat(path)).isFile(),
() => false,
);
const settingCommand = async (
environment: Readonly<NodeJS.ProcessEnv>,
name: BuildComputerSetting,
program: string,
): Promise<DesktopBuildCommand> => {
const value = environment[name];
if (value === undefined || value === "") {
const path = await programOnPath(program, environment);
return path === undefined
? { status: "missing" }
: { status: "found", path, source: "path" };
}
return isAbsolute(value) && (await executable(value))
? { status: "found", path: value, source: "environment" }
: { status: "invalid", value };
};
/** procps `ps` accepts `-p`; busybox's does not. */
const psFact = async (
input: DesktopBuildFactsInput,
): Promise<DesktopBuildFacts["ps"]> => {
const ps = await programOnPath("ps", input.environment);
if (ps === undefined) return { found: false, detail: "No ps is on PATH." };
const probe = await attempt(input, ps, [
"-o",
"pid=",
"-p",
String(process.pid),
]);
return probe.ok
? { found: true, detail: ps }
: {
found: false,
detail: `${ps} is not procps-compatible: ${probe.reason}`,
};
};
const dockerDaemon = async (
input: DesktopBuildFactsInput,
docker: string,
): Promise<DesktopBuildFacts["daemon"]> => {
const info = await attempt(input, docker, [
"info",
"--format",
"{{.DockerRootDir}}",
]);
if (info.ok)
return {
status: "reachable",
rootDirectory: info.stdout.trim() || DEFAULT_DOCKER_ROOT,
};
return {
status: /permission denied/iu.test(info.reason)
? "permission_denied"
: "unreachable",
detail: info.reason,
};
};
/** Free space on the filesystem that holds `path`, or its nearest existing parent. */
const freeSpace = async (path: string): Promise<DesktopBuildFacts["disk"]> => {
let candidate = path;
for (;;) {
try {
const fs = await statfs(candidate);
return { path: candidate, freeBytes: fs.bavail * fs.bsize };
} catch (cause: unknown) {
const parent = dirname(candidate);
if (parent === candidate)
return {
path,
error: cause instanceof Error ? cause.message : String(cause),
};
candidate = parent;
}
}
};
const agentEnvVersion = async (
input: DesktopBuildFactsInput,
agentEnv: string,
): Promise<string | undefined> => {
const reported = await attempt(input, agentEnv, ["--version"]);
return reported.ok ? agentEnvReportedVersion(reported.stdout) : undefined;
};
/** The setup-packed archive is named by its version; another is read for its package version. */
const archiveFact = async (
input: DesktopBuildFactsInput,
agentEnv: DesktopBuildCommand,
): Promise<DesktopBuildFacts["archive"]> => {
const selected = input.environment.REA_BUILD_COMPUTER_REA_ARCHIVE;
const source =
selected === undefined || selected === "" ? "cache" : "environment";
const path =
source === "cache"
? defaultBuildComputerArchive(input.environment, input.platform)
: (selected as string);
const file = await stat(path).then(
(entry) => entry.isFile(),
() => false,
);
if (!file) return { path, source, status: "missing" };
if (source === "cache") return { path, source, status: "current" };
const python =
agentEnv.status === "found"
? await agentEnvInterpreter(
{ ...input.environment, REA_AGENT_ENV_COMMAND: agentEnv.path },
"desktop_build_doctor",
).catch(() => "python3")
: "python3";
const packed = await attempt(input, python, [
"-I",
"-c",
ARCHIVE_VERSION,
path,
]);
if (!packed.ok)
return {
path,
source,
status: "unknown",
detail: `REA could not inspect ${path}: ${packed.reason}`,
};
let version: unknown;
try {
version = (JSON.parse(packed.stdout) as { version?: unknown }).version;
} catch {
version = undefined;
}
return version === PRODUCT_IDENTITY.packageVersion
? { path, source, status: "current" }
: typeof version !== "string"
? {
path,
source,
status: "unknown",
detail: `REA could not identify the version of the runnable archive ${path}.`,
}
: {
path,
source,
status: "stale",
detail: `${path} holds REA ${typeof version === "string" ? version : "of an unknown version"}; this is REA ${PRODUCT_IDENTITY.packageVersion}.`,
};
};
/** Setting names each considered registration records; values stay in its file. */
const registrationFacts = async (
input: DesktopBuildFactsInput,
selectedSettings: Partial<Record<BuildComputerSetting, string>>,
versionFor: (path: string) => Promise<string | undefined>,
): Promise<DesktopBuildFacts["registrations"]> => {
const selected = input.clientIds.length === 0 ? undefined : input.clientIds;
const recordedRunners = new Map<string, string>();
const statuses = await input.registrations(
selected,
Object.fromEntries(
BUILD_COMPUTER_SETTINGS.map((name) => [
name,
selectedSettings[name] ?? "",
]),
),
undefined,
(client, name, value) => {
if (
name === "REA_AGENT_ENV_COMMAND" &&
(selected === undefined || selected.includes(client))
)
recordedRunners.set(client, value);
},
);
const registered = statuses.filter(({ command }) => command.length > 0);
const considered = selected ?? registered.map(({ client }) => client);
return Promise.all(
considered.map(async (client) => {
const status = registered.find((entry) => entry.client === client);
const recordedRunner =
status === undefined ? undefined : recordedRunners.get(client);
const effectiveRunner =
recordedRunner === undefined
? undefined
: await resolveBuildComputerProgram(
recordedRunner,
"agent-env",
input.environment,
);
let runner: DesktopBuildFacts["registrations"][number]["runner"];
if (recordedRunner !== undefined) {
if (
effectiveRunner === undefined ||
!isAbsolute(effectiveRunner) ||
!(await executable(effectiveRunner))
)
runner = { status: "invalid" };
else {
const version = await versionFor(effectiveRunner);
runner =
version === undefined
? { status: "unknown" }
: {
status:
version === DESKTOP_EXPORT_FRAMEWORK_VERSION
? "current"
: "stale",
version,
};
}
}
const differing = (status?.provider_settings?.differing ?? []).filter(
(name) =>
name !== "REA_AGENT_ENV_COMMAND" ||
recordedRunner !== "" ||
effectiveRunner === undefined ||
effectiveRunner !== selectedSettings.REA_AGENT_ENV_COMMAND,
);
const compared = differing.filter(
(name) => selectedSettings[name as BuildComputerSetting] !== undefined,
);
const unchecked = differing.filter(
(name) =>
selectedSettings[name as BuildComputerSetting] === undefined &&
!(name === "REA_AGENT_ENV_COMMAND" && runner !== undefined),
);
return {
client,
registered: status !== undefined,
unrecorded: status?.provider_settings?.unrecorded ?? [],
unresolved: status?.provider_settings?.unresolved ?? [],
...(compared.length === 0 ? {} : { differing: compared }),
...(unchecked.length === 0 ? {} : { unchecked }),
...(runner === undefined ? {} : { runner }),
};
}),
);
};
const solveEndpoint = async (
input: DesktopBuildFactsInput,
runner: DesktopBuildCommand,
): Promise<string | null | undefined> => {
if (input.signal.aborted)
throw new AnalysisCancelledError("doctor", { cause: input.signal.reason });
if (runner.status !== "found") return null;
const discovery = await attempt(input, runner.path, [
"config",
"debug",
"--json",
]);
if (!discovery.ok) return null;
const discovered = safeParseJson(discovery.stdout);
const candidates = discovered.ok
? z
.object({
search_path: z.array(
z.object({ path: z.string(), exists: z.boolean() }),
),
})
.safeParse(discovered.value)
: undefined;
if (candidates === undefined || !candidates.success) return null;
const selected = candidates.data.search_path.find(
({ exists }) => exists,
)?.path;
if (
selected === undefined &&
(input.environment.AGENT_ENV_CONFIG ?? "") !== ""
)
return null;
if (selected !== undefined && !(await canReadModelConfig(selected, input)))
return null;
const explained = await attempt(input, runner.path, [
"config",
"explain",
"model.base_url",
"--json",
]);
if (!explained.ok) return null;
const parsed = safeParseJson(explained.stdout);
const result = parsed.ok
? z
.object({
path: z.literal("model.base_url"),
value: z.unknown(),
error: z.unknown(),
winner: z.object({ kind: z.string() }).nullable().optional(),
})
.safeParse(parsed.value)
: undefined;
if (result === undefined || !result.success || result.data.error != null)
return null;
const value = result.data.value;
// Agentenv's explain command lists an empty env override, while the runtime
// getter falls through to the file. The explanation cannot prove that value.
if (value === "" && result.data.winner?.kind === "env") return null;
if (value === null || value === "") return undefined;
if (typeof value !== "string" || /^(?:env|secret):/u.test(value)) return null;
return result.data.winner?.kind === "env"
? "LITELLM_BASE_URL"
: (selected ?? "agentenv model configuration");
};
const canReadModelConfig = async (
path: string,
input: DesktopBuildFactsInput,
): Promise<boolean> => {
try {
if (input.signal.aborted)
throw new AnalysisCancelledError("doctor", {
cause: input.signal.reason,
});
const candidate = await stat(path);
if (input.signal.aborted)
throw new AnalysisCancelledError("doctor", {
cause: input.signal.reason,
});
if (!candidate.isFile() || candidate.size > CONFIG_BYTES) return false;
await readRegularFileText(path, {
signal: input.signal,
admit: (state) => {
if (state.size > CONFIG_BYTES)
throw new Error("Configuration exceeds the read limit");
},
cancellationFailure: (reason) =>
new AnalysisCancelledError("doctor", { cause: reason }),
});
return true;
} catch (cause: unknown) {
if (cause instanceof RetainedRegularFileCleanupFailure) {
input.resources.retain(cause.cleanupOwner);
throw cause;
}
if (cause instanceof AnalysisCancelledError) throw cause;
if (input.signal.aborted)
throw new AnalysisCancelledError("doctor", {
cause: input.signal.reason,
});
return false;
}
};
/** Sandboxes agentenv runs as `agent-<id>`, and those it labels with their id. */
const leftoverContainers = async (
input: DesktopBuildFactsInput,
docker: string,
): Promise<readonly string[] | undefined> => {
const names = new Set<string>();
for (const filter of ["name=^agent-", "label=agentenv.sandbox"]) {
const listed = await attempt(input, docker, [
"ps",
"-a",
"--filter",
filter,
"--format",
"{{.Names}}",
]);
if (!listed.ok) return undefined;
for (const name of listed.stdout.split(/\s+/u).filter(Boolean))
names.add(name);
}
return [...names].sort();
};
@@ -0,0 +1,276 @@
import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import * as owned from "../process/OwnedCommand.js";
import { runDesktopBuildOwnedCommand } from "./DesktopBuildOwnedCommand.js";
import { systemDesktopBuildSetupHost } from "./DesktopBuildSetupHost.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js";
it
.skipIf(process.platform === "win32" || process.getuid?.() === 0)
.each([true, false])(
"reports only remaining setup resources after partial cleanup (process retained: %s)",
async (processRetained) => {
const root = await createTestTempDirectory("rea-pack-live-cleanup-");
const partial = join(root, "partial.tar.xz");
const parent = join(root, "protected");
const workspace = join(parent, "work");
await mkdir(workspace, { recursive: true });
await writeFile(partial, "partial");
await chmod(parent, 0o500);
const resources = new ArtifactResourceScope();
const stop = vi.fn().mockResolvedValue({ status: "terminated" });
const command = vi
.spyOn(owned, "runOwnedCommand")
.mockImplementation(async (_spawn, _limits, options) => {
if (processRetained)
options?.retainCleanup?.(
{ stop, launch: { ownership: { runId: "pack" } } } as never,
{ status: "incomplete", reason: "still running" },
);
throw new owned.OwnedCommandFailure("timeout", "command timed out");
});
try {
await expect(
runDesktopBuildOwnedCommand({
command: "fixture",
arguments: [],
environment: {},
timeoutMs: 1,
maxOutputBytes: 1024,
resources,
partialPath: partial,
workspace,
}),
).rejects.toThrow();
if (processRetained) {
expect(resources.cleanupResources).toEqual([
partial,
workspace,
"setup process pack",
]);
await expect(resources.retryCleanup()).rejects.toThrow();
}
expect(resources.cleanupResources).toEqual([workspace]);
await expect(stat(partial)).rejects.toMatchObject({ code: "ENOENT" });
await chmod(parent, 0o700);
await resources.retryCleanup();
expect(resources.cleanupResources).toEqual([]);
expect(stop).toHaveBeenCalledTimes(processRetained ? 1 : 0);
await expect(stat(workspace)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
await chmod(parent, 0o700);
await resources.close();
command.mockRestore();
}
},
);
it("retains a failed cleanup and its partial archive until the same owner succeeds", async () => {
const root = await createTestTempDirectory("rea-pack-cleanup-retry-");
const partial = join(root, "partial.tar.xz");
const workspace = join(root, "work");
await mkdir(workspace);
await writeFile(join(workspace, "npm-state"), "in use");
await writeFile(partial, "partial");
const resources = new ArtifactResourceScope();
const stop = vi
.fn()
.mockResolvedValueOnce({ status: "incomplete", reason: "still running" })
.mockResolvedValueOnce({ status: "terminated" });
const mocked = vi
.spyOn(owned, "runOwnedCommand")
.mockImplementation(async (_spawn, _limits, options) => {
options?.retainCleanup?.(
{ stop, launch: { ownership: { runId: "pack" } } } as never,
{ status: "incomplete", reason: "still running" },
);
throw new owned.OwnedCommandFailure("timeout", "command timed out");
});
try {
const failure: unknown = await runDesktopBuildOwnedCommand({
command: "fixture",
arguments: [],
environment: {},
timeoutMs: 1,
maxOutputBytes: 1024,
resources,
partialPath: partial,
workspace,
}).catch((cause: unknown) => cause);
expect(failure).toMatchObject({
message: expect.stringContaining("cleanup is incomplete"),
cause: { reason: "timeout" },
});
expect(await readFile(partial, "utf8")).toBe("partial");
expect(await readFile(join(workspace, "npm-state"), "utf8")).toBe("in use");
await expect(resources.retryCleanup()).rejects.toThrow();
expect(await readFile(partial, "utf8")).toBe("partial");
await resources.retryCleanup();
await expect(stat(partial)).rejects.toMatchObject({ code: "ENOENT" });
await expect(stat(workspace)).rejects.toMatchObject({ code: "ENOENT" });
expect(stop).toHaveBeenCalledTimes(2);
} finally {
mocked.mockRestore();
await resources.close();
}
});
it("keeps cancellation and partial output when cleanup fails during finalization", async () => {
const root = await createTestTempDirectory("rea-pack-cancel-cleanup-");
const partial = join(root, "partial.tar.xz");
await writeFile(partial, "partial");
const resources = new ArtifactResourceScope();
const controller = new AbortController();
const reason = new Error("cancelled during cleanup");
const stop = vi.fn().mockResolvedValue({ status: "terminated" });
const mocked = vi
.spyOn(owned, "runOwnedCommand")
.mockImplementation(async (_spawn, _limits, options) => {
controller.abort(reason);
options?.retainCleanup?.(
{ stop, launch: { ownership: { runId: "pack" } } } as never,
{ status: "incomplete", reason: "cleanup failed" },
);
throw new owned.OwnedCommandFailure(
"cancelled",
"Command cancelled",
null,
null,
{ cancellation: { reason } },
);
});
try {
const failure: unknown = await runDesktopBuildOwnedCommand({
command: "fixture",
arguments: [],
environment: {},
timeoutMs: 1,
maxOutputBytes: 1024,
resources,
partialPath: partial,
signal: controller.signal,
}).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(AnalysisCancelledError);
if (!(failure instanceof AnalysisCancelledError)) throw failure;
expect(failure.cause).toBe(reason);
expect(failure.secondaryFailures[0]?.cause).toMatchObject({
message: expect.stringContaining("cleanup is incomplete"),
cause: { reason: "cancelled", cancellation: { reason } },
});
expect(await readFile(partial, "utf8")).toBe("partial");
await resources.retryCleanup();
await expect(stat(partial)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
mocked.mockRestore();
await resources.close();
}
});
it("retains an observed command failure when cancellation arrives during its cleanup", async () => {
const resources = new ArtifactResourceScope();
const controller = new AbortController();
const reason = new Error("cancel during cleanup");
let entered!: () => void;
let release!: () => void;
const cleanupEntered = new Promise<void>((resolve) => {
entered = resolve;
});
const cleanupRelease = new Promise<void>((resolve) => {
release = resolve;
});
const snapshot = {
stdout: { text: "earlier output", bytes: 14, observedBytes: 14 },
stderr: { text: "disk full", bytes: 9, observedBytes: 9 },
exitCode: 2,
signal: null,
};
const primary = new owned.OwnedCommandFailure(
"process",
"Command failed",
snapshot,
);
const mocked = vi
.spyOn(owned, "runOwnedCommand")
.mockImplementation(async () => {
entered();
await cleanupRelease;
throw new owned.OwnedCommandFailure(
"process",
primary.message,
snapshot,
null,
{
cause: primary,
cancellation: { reason: controller.signal.reason },
},
);
});
const host = systemDesktopBuildSetupHost(
{},
process.platform,
undefined,
resources,
);
const pending = host.installAgentenv("fixture", controller.signal);
try {
await awaitAcknowledgement(cleanupEntered, pending, "command cleanup");
controller.abort(reason);
release();
const failure: unknown = await pending.catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(AnalysisCancelledError);
if (!(failure instanceof AnalysisCancelledError)) throw failure;
expect(failure.cause).toBe(reason);
expect(failure.diagnostics).toMatchObject({ command_failure: "process" });
expect(failure.capturedOutput).toMatchObject({
stdout: "earlier output",
stderr: "disk full",
exit_code: 2,
});
expect(failure.secondaryFailures[0]?.cause).toMatchObject({
reason: "process",
cause: primary,
});
} finally {
controller.abort();
release();
await Promise.allSettled([pending]);
mocked.mockRestore();
await resources.close();
}
});
it("does not publish success when execution finishes but cleanup is incomplete", async () => {
const resources = new ArtifactResourceScope();
const stop = vi.fn().mockResolvedValue({ status: "terminated" });
const mocked = vi
.spyOn(owned, "runOwnedCommand")
.mockImplementation(async (_spawn, _limits, options) => {
options?.retainCleanup?.(
{ stop, launch: { ownership: { runId: "pack" } } } as never,
{ status: "incomplete", reason: "child still running" },
);
return {} as never;
});
try {
await expect(
runDesktopBuildOwnedCommand({
command: "fixture",
arguments: [],
environment: {},
timeoutMs: 1,
maxOutputBytes: 1024,
resources,
}),
).rejects.toThrow("cleanup incomplete");
expect(resources.hasPending).toBe(true);
await resources.retryCleanup();
expect(resources.hasPending).toBe(false);
} finally {
mocked.mockRestore();
await resources.close();
}
});
+171
View File
@@ -0,0 +1,171 @@
import { randomUUID } from "node:crypto";
import { rm } from "node:fs/promises";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import {
OwnedCommandFailure,
runOwnedCommand,
} from "../process/OwnedCommand.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import { artifactCommandOutput } from "../process/ArtifactAnalysisResources.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
/** Run a setup command with ownership of its entire process family and partial output. */
export const runDesktopBuildOwnedCommand = async (input: {
readonly command: string;
readonly arguments: readonly string[];
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly timeoutMs: number;
readonly maxOutputBytes: number;
readonly resources: ArtifactResourceScope;
readonly partialPath?: string;
readonly workspace?: string;
readonly signal?: AbortSignal;
}): Promise<void> => {
let retained = false;
let cleanupReason: string | undefined;
const paths = [input.partialPath, input.workspace].filter(
(path): path is string => path !== undefined,
);
const remainingPaths = new Set(paths);
const removeOwnedPaths = async () => {
const attempted = [...remainingPaths];
const outcomes = await Promise.allSettled(
attempted.map((path) =>
rm(path, { recursive: path === input.workspace, force: true }),
),
);
outcomes.forEach((outcome, index) => {
if (outcome.status === "fulfilled")
remainingPaths.delete(attempted[index]!);
});
const failures = outcomes.flatMap((outcome) =>
outcome.status === "rejected" ? [outcome.reason] : [],
);
if (failures.length > 0)
throw new AggregateError(
failures,
"Could not remove setup command outputs",
);
};
try {
await runOwnedCommand(
{
command: input.command,
arguments: input.arguments,
runId: randomUUID(),
expectedCommand: null,
hostEnvironment: input.environment,
sweepTokenOwnedProcesses: true,
},
{ timeoutMs: input.timeoutMs, diagnosticBytes: input.maxOutputBytes },
{
signal: input.signal,
retainCleanup: (owner, result) => {
retained = true;
cleanupReason = result.reason;
const process = `setup process ${owner.launch.ownership?.runId ?? "unknown"}`;
let processPending = true;
input.resources.retain({
kind: "lease",
resource:
input.partialPath ??
`setup process ${owner.launch.ownership?.runId ?? "unknown"}`,
lease: new AnalysisCleanupLease(
async () => {
if (processPending) {
const stopped = await owner.stop();
if (stopped.status === "incomplete")
throw new Error(stopped.reason);
processPending = false;
}
await removeOwnedPaths();
},
{
get cleanupResources() {
return [
...remainingPaths,
...(processPending ? [process] : []),
];
},
},
),
});
},
},
);
if (retained)
throw new Error(
`Setup command cleanup incomplete: ${cleanupReason ?? "unknown reason"}`,
);
} catch (cause: unknown) {
let failure: unknown = cause;
const preparation =
cause instanceof DarwinProcessOwnershipInspectionError
? cause
: cause instanceof OwnedCommandFailure
? cause.preparationFailure
: undefined;
if (preparation?.cleanupOwner !== undefined)
input.resources.retain({
kind: "lease",
resource: "process ownership inspection",
lease: { release: () => preparation.cleanupOwner!.close() },
});
if (!retained && paths.length > 0) {
try {
await removeOwnedPaths();
} catch (cleanup: unknown) {
input.resources.retain({
kind: "lease",
resource: paths[0]!,
lease: new AnalysisCleanupLease(removeOwnedPaths, {
get cleanupResources() {
return [...remainingPaths];
},
}),
});
failure = new AggregateError(
[cause, cleanup],
"Setup command failed and its partial output could not be removed",
);
}
}
if (retained)
failure = new Error(
`${desktopBuildOwnedCommandReason(cause)}; cleanup is incomplete: ${cleanupReason ?? "unknown reason"}`,
{ cause },
);
if (input.signal?.aborted) {
const command = cause instanceof OwnedCommandFailure ? cause : undefined;
const reason = desktopBuildOwnedCommandReason(cause);
const cancelled = new AnalysisCancelledError("setup", {
cause: input.signal.reason,
diagnostics: {
setup_failure: reason,
...(command === undefined ? {} : { command_failure: command.reason }),
},
...(command?.snapshot == null
? {}
: { capturedOutput: artifactCommandOutput(command.snapshot) }),
});
cancelled.retainSecondaryFailure(
new AnalysisOutputError("setup", reason, { cause: failure }),
);
throw cancelled;
}
throw failure;
}
};
/** Keep the bounded command's stderr with its failure reason. */
export const desktopBuildOwnedCommandReason = (cause: unknown): string => {
const command = cause instanceof OwnedCommandFailure ? cause : undefined;
const stderr = command?.snapshot?.stderr.text.trim() ?? "";
const message = cause instanceof Error ? cause.message : String(cause);
return stderr === "" ? message : `${message}: ${stderr.slice(-2000)}`;
};
+253
View File
@@ -0,0 +1,253 @@
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js";
import { systemDoctorHost } from "../application/Doctor.js";
import { PRODUCT_IDENTITY } from "../identity.js";
import { systemDesktopBuildSetupHost } from "./DesktopBuildSetupHost.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { DESKTOP_EXPORT_FRAMEWORK_VERSION } from "../domain/desktop/appExport.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
const program = (path: string, body: string) =>
writeFile(path, `#!/bin/sh\n${body}\n`, { mode: 0o755 });
it.skipIf(process.platform === "win32")(
"preserves the selected archive through symlink and parent traversal",
async () => {
const root = await createTestTempDirectory("rea-setup-selected-archive-");
await mkdir(join(root, "elsewhere", "child"), { recursive: true });
await symlink(join(root, "elsewhere", "child"), join(root, "link"));
await writeFile(join(root, "archive.tar.xz"), "wrong archive");
await writeFile(
join(root, "elsewhere", "archive.tar.xz"),
"selected archive",
);
const selected = `${root}/link/../archive.tar.xz`;
const resources = new ArtifactResourceScope();
try {
const facts = await systemDesktopBuildSetupHost(
{ ...process.env, REA_BUILD_COMPUTER_REA_ARCHIVE: selected },
process.platform,
undefined,
resources,
).facts([]);
expect(facts.settings.REA_BUILD_COMPUTER_REA_ARCHIVE).toBe(selected);
expect(
await readFile(facts.settings.REA_BUILD_COMPUTER_REA_ARCHIVE!, "utf8"),
).toBe("selected archive");
} finally {
await resources.close();
}
},
);
it.skipIf(process.platform === "win32")(
"retains installer output and its command failure when cancelled",
async () => {
const root = await createTestTempDirectory("rea-setup-cancel-output-");
const uv = join(root, "uv");
const started = join(root, "started");
await writeFile(
uv,
`#!${process.execPath}\nprocess.stderr.write("installer diagnostic\\n");\nrequire("node:fs").writeFileSync(process.env.REA_SETUP_STARTED, "");\nsetInterval(() => {}, 1000);\n`,
{ mode: 0o755 },
);
const resources = new ArtifactResourceScope();
const controller = new AbortController();
const reason = new Error("cancel installer");
const host = systemDesktopBuildSetupHost(
{ ...process.env, REA_SETUP_STARTED: started },
process.platform,
undefined,
resources,
);
const pending = host.installAgentenv(uv, controller.signal);
void pending.catch(() => undefined);
try {
await awaitAcknowledgement(
vi.waitFor(
async () => expect(await readFile(started, "utf8")).toBe(""),
{
timeout: 10_000,
},
),
pending,
"installer output",
10_000,
);
controller.abort(reason);
const failure: unknown = await pending.catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(AnalysisCancelledError);
if (!(failure instanceof AnalysisCancelledError)) throw failure;
expect(failure.cause).toBe(reason);
expect(failure.capturedOutput?.stderr).toContain("installer diagnostic");
expect(failure.secondaryFailures).toHaveLength(1);
expect(failure.secondaryFailures[0]?.cause).toBeDefined();
} finally {
controller.abort();
await Promise.allSettled([pending]);
await resources.close();
}
},
);
it.skipIf(process.platform === "win32")(
"runs the requested pinned installer as an owned command",
async () => {
const root = await createTestTempDirectory("rea-owned-agentenv-install-");
const uv = join(root, "uv");
const log = join(root, "arguments");
await program(uv, 'printf "%s\\n" "$@" > "$REA_TEST_UV_LOG"');
const resources = new ArtifactResourceScope();
try {
const host = systemDesktopBuildSetupHost(
{ ...process.env, REA_TEST_UV_LOG: log },
process.platform,
undefined,
resources,
);
expect(await host.installAgentenv(uv)).toBeUndefined();
expect(await readFile(log, "utf8")).toBe(
`tool\ninstall\nagentenv-framework==${DESKTOP_EXPORT_FRAMEWORK_VERSION}\n`,
);
expect(resources.hasPending).toBe(false);
} finally {
await resources.close();
}
},
);
it.skipIf(process.platform === "win32").each([
["an archive setup packed for another version", false, true],
["an archive setup packed for another version, since deleted", false, false],
["an archive the user chose", true, true],
] as const)(
"replaces only setup's own archive in a registration: %s",
async (_, kept, present) => {
const root = await createTestTempDirectory("rea-desktop-setup-facts-");
const bin = join(root, "bin");
const home = join(root, "home");
const cache = join(root, "cache");
const owned = join(cache, "rea/build-computer");
await mkdir(bin);
await mkdir(home);
await mkdir(owned, { recursive: true });
await program(join(bin, "docker"), "exit 0");
await program(join(bin, "agent-env"), 'echo "agent-env 0.9.1431"');
await program(
join(bin, "uv"),
`[ "$1 $2 $3" = "tool dir --bin" ] && echo ${join(home, ".local/bin")}`,
);
const previous = join(owned, "rea-6.7.0.tar.xz");
if (present) await writeFile(previous, "packed by an earlier setup");
await writeFile(
join(home, ".claude.json"),
JSON.stringify({
mcpServers: {
rea: {
command: process.execPath,
args: ["/opt/rea/scripts/rea.mjs", "mcp"],
env: {
REA_DOCKER_COMMAND: "/custom/docker",
REA_BUILD_COMPUTER_REA_ARCHIVE: kept
? "/srv/rea-mine.tar.xz"
: previous,
},
},
},
}),
);
const environment = {
HOME: home,
USERPROFILE: home,
PATH: bin,
XDG_CACHE_HOME: cache,
};
const doctor = systemDoctorHost({ environment, platform: "linux" });
try {
const facts = await systemDesktopBuildSetupHost(
environment,
"linux",
doctor.clientRegistrations,
new ArtifactResourceScope(),
).facts(["claude_code"]);
expect(facts).toMatchObject({
settings: {
REA_AGENT_ENV_COMMAND: join(bin, "agent-env"),
REA_DOCKER_COMMAND: join(bin, "docker"),
REA_BUILD_COMPUTER_REA_ARCHIVE: join(
owned,
`rea-${PRODUCT_IDENTITY.packageVersion}.tar.xz`,
),
},
archivePacked: false,
agentEnvVersion: "0.9.1431",
uv: {
path: join(bin, "uv"),
agentEnv: join(home, ".local/bin/agent-env"),
},
recorded: {
claude_code: kept
? ["REA_DOCKER_COMMAND", "REA_BUILD_COMPUTER_REA_ARCHIVE"]
: ["REA_DOCKER_COMMAND"],
},
});
} finally {
await doctor.close?.();
}
},
);
it.skipIf(process.platform === "win32")(
"counts the runner as unrecorded where a registration lacks it and it is not installed yet",
async () => {
const root = await createTestTempDirectory("rea-desktop-setup-runner-");
const bin = join(root, "bin");
const home = join(root, "home");
await mkdir(bin);
await mkdir(home);
await program(join(bin, "docker"), "exit 0");
await writeFile(
join(home, ".claude.json"),
JSON.stringify({
mcpServers: {
rea: {
command: process.execPath,
args: ["/opt/rea/scripts/rea.mjs", "mcp"],
env: {
REA_DOCKER_COMMAND: "/custom/docker",
REA_BUILD_COMPUTER_REA_ARCHIVE: "/srv/rea-mine.tar.xz",
},
},
},
}),
);
const environment = {
HOME: home,
USERPROFILE: home,
PATH: bin,
XDG_CACHE_HOME: join(root, "cache"),
};
const doctor = systemDoctorHost({ environment, platform: "linux" });
try {
const facts = await systemDesktopBuildSetupHost(
environment,
"linux",
doctor.clientRegistrations,
new ArtifactResourceScope(),
).facts(["claude_code"]);
expect(facts.settings).not.toHaveProperty("REA_AGENT_ENV_COMMAND");
expect(facts.recorded).toEqual({
claude_code: ["REA_DOCKER_COMMAND", "REA_BUILD_COMPUTER_REA_ARCHIVE"],
});
} finally {
await doctor.close?.();
}
},
);
+302
View File
@@ -0,0 +1,302 @@
import { randomUUID } from "node:crypto";
import { access, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { DoctorHost } from "../application/Doctor.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { assertSetupActive } from "../application/SetupCancellation.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import type {
DesktopBuildSetupFacts,
DesktopBuildSetupHost,
} from "../application/SetupDesktopBuild.js";
import { DESKTOP_EXPORT_FRAMEWORK_VERSION } from "../domain/desktop/appExport.js";
import {
execFileOutput,
execFileOutputFailure,
} from "../process/ExecFileOutput.js";
import {
agentEnvReportedVersion,
BUILD_COMPUTER_SETTINGS,
buildComputerArchiveDirectory,
defaultBuildComputerArchive,
programOnPath,
} from "./BuildComputerEnvironment.js";
import {
desktopBuildOwnedCommandReason,
runDesktopBuildOwnedCommand,
} from "./DesktopBuildOwnedCommand.js";
const PACK_SCRIPT = fileURLToPath(
new URL("../../scripts/pack-build-computer-runtime.mjs", import.meta.url),
);
const CHECKOUT_LOCKFILE = fileURLToPath(
new URL("../../package-lock.json", import.meta.url),
);
const PROBE = { timeout: 10_000, maxBuffer: 64 * 1024 } as const;
const PACK = { timeout: 30 * 60_000, maxBuffer: 16 * 1024 * 1024 } as const;
const INSTALL = { timeout: 10 * 60_000, maxBuffer: 4 * 1024 * 1024 } as const;
const ARCHIVE = "REA_BUILD_COMPUTER_REA_ARCHIVE";
/** What setup's fact readers share: the caller's environment and registration reader. */
interface SetupContext {
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly platform: NodeJS.Platform;
readonly registrations: DoctorHost["clientRegistrations"];
probe(
command: string,
arguments_: readonly string[],
): Promise<{ readonly stdout: string }>;
}
const exists = (path: string): Promise<boolean> =>
access(path).then(
() => true,
() => false,
);
const failureReason = (cause: unknown): string => {
const stderr = execFileOutputFailure(cause)?.stderr.trim() ?? "";
const message = cause instanceof Error ? cause.message : String(cause);
return stderr === "" ? message : `${message}: ${stderr.slice(-2000)}`;
};
/** Setup's desktop build effects on this host. */
export const systemDesktopBuildSetupHost = (
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
registrations: DoctorHost["clientRegistrations"],
resources: ArtifactResourceScope,
): DesktopBuildSetupHost => {
const run = (
command: string,
arguments_: readonly string[],
limits: typeof PROBE,
env = environment,
) =>
execFileOutput(command, arguments_, {
...limits,
env,
stopSignal: "SIGTERM",
});
return {
facts: (clientIds) =>
readSetupFacts(
{
environment,
platform,
registrations,
probe: (command, arguments_) => run(command, arguments_, PROBE),
},
clientIds,
),
async packRuntime(archive, signal) {
const partial = join(dirname(archive), `.rea-${randomUUID()}.tar.xz`);
let commandFinished = false;
return resources.run(async () => {
try {
assertSetupActive(signal);
await mkdir(dirname(archive), { recursive: true });
const workspace = await mkdtemp(join(tmpdir(), "rea-desktop-pack-"));
await runDesktopBuildOwnedCommand({
command: process.execPath,
arguments: [PACK_SCRIPT, partial],
timeoutMs: PACK.timeout,
maxOutputBytes: PACK.maxBuffer,
resources,
partialPath: partial,
workspace,
...(signal === undefined ? {} : { signal }),
environment: {
...environment,
TMPDIR: workspace,
TMP: workspace,
TEMP: workspace,
npm_config_loglevel: "error",
npm_config_progress: "false",
npm_config_fund: "false",
npm_config_audit: "false",
},
});
commandFinished = true;
try {
await rm(workspace, { recursive: true, force: true });
} catch (cause: unknown) {
resources.retain({
kind: "lease",
resource: workspace,
lease: {
release: () => rm(workspace, { recursive: true, force: true }),
},
});
throw cause;
}
assertSetupActive(signal);
await rename(partial, archive);
return undefined;
} catch (cause: unknown) {
if (commandFinished) {
try {
await rm(partial, { force: true });
} catch (cleanup: unknown) {
resources.retain({
kind: "lease",
resource: partial,
lease: { release: () => rm(partial, { force: true }) },
});
if (cause instanceof AnalysisCancelledError) throw cause;
assertSetupActive(
signal,
`${desktopBuildOwnedCommandReason(cause)}; partial archive cleanup failed: ${failureReason(cleanup)}`,
);
return `${desktopBuildOwnedCommandReason(cause)}; partial archive cleanup failed: ${failureReason(cleanup)}`;
}
}
if (cause instanceof AnalysisCancelledError) throw cause;
assertSetupActive(signal, desktopBuildOwnedCommandReason(cause));
return desktopBuildOwnedCommandReason(cause);
}
});
},
async installAgentenv(uv, signal) {
return resources.run(async () => {
try {
assertSetupActive(signal);
await runDesktopBuildOwnedCommand({
command: uv,
arguments: [
"tool",
"install",
`agentenv-framework==${DESKTOP_EXPORT_FRAMEWORK_VERSION}`,
],
timeoutMs: INSTALL.timeout,
maxOutputBytes: INSTALL.maxBuffer,
resources,
environment,
...(signal === undefined ? {} : { signal }),
});
return undefined;
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError) throw cause;
assertSetupActive(signal, desktopBuildOwnedCommandReason(cause));
return desktopBuildOwnedCommandReason(cause);
}
});
},
};
};
const readSetupFacts = async (
context: SetupContext,
clientIds: readonly string[],
): Promise<DesktopBuildSetupFacts> => {
const { environment, platform, probe } = context;
const archive = defaultBuildComputerArchive(environment, platform);
const selected = BUILD_COMPUTER_SETTINGS.filter(
(name) => (environment[name] ?? "") !== "",
);
const settings = await absoluteSettings(environment, archive, platform);
const agentEnv = settings.REA_AGENT_ENV_COMMAND;
const uv = await programOnPath("uv", environment);
const uvBin =
uv === undefined
? undefined
: await probe(uv, ["tool", "dir", "--bin"]).then(
({ stdout }) => stdout.trim(),
() => undefined,
);
return {
settings,
selected,
archive,
archivePacked: await stat(archive).then(
(entry) => entry.isFile(),
() => false,
),
...((await exists(PACK_SCRIPT)) && (await exists(CHECKOUT_LOCKFILE))
? {}
: {
packBlocker:
"Packing REA's build-computer runtime needs a REA checkout: run rea setup --desktop-build from one (see docs/installation.md#run-rea-from-a-checkout), or pack an archive yourself and set REA_BUILD_COMPUTER_REA_ARCHIVE.",
}),
...(agentEnv === undefined
? {}
: await probe(agentEnv, ["--version"]).then(
({ stdout }) => ({
agentEnvVersion: agentEnvReportedVersion(stdout),
}),
() => ({}),
)),
...(uv === undefined || uvBin === undefined || uvBin === ""
? {}
: { uv: { path: uv, agentEnv: join(uvBin, "agent-env") } }),
recorded: await recordedSettings(context, clientIds, settings),
};
};
/** Each build setting as an absolute path: the caller's selection, else its default. */
const absoluteSettings = async (
environment: Readonly<NodeJS.ProcessEnv>,
archive: string,
platform: NodeJS.Platform,
): Promise<Record<string, string>> => {
const settings: Record<string, string> = {};
for (const [name, program] of [
["REA_AGENT_ENV_COMMAND", "agent-env"],
["REA_DOCKER_COMMAND", "docker"],
] as const) {
const value = environment[name] ?? "";
const path =
value === ""
? await programOnPath(program, environment)
: isAbsolute(value)
? value
: await programOnPath(value, environment);
if (path !== undefined) settings[name] = path;
}
const selectedArchive = environment[ARCHIVE] ?? "";
settings[ARCHIVE] =
selectedArchive === ""
? archive
: absoluteCallerPath(selectedArchive, { platform });
return settings;
};
/**
* Per client, the build settings its registration records with a value of its
* own: an archive REA packed for another version is setup's, not the user's,
* whether or not it is still on disk.
*/
const recordedSettings = async (
{ environment, platform, registrations }: SetupContext,
clientIds: readonly string[],
settings: Readonly<Record<string, string>>,
): Promise<Record<string, readonly string[]>> => {
if (registrations === undefined || clientIds.length === 0) return {};
const directory = buildComputerArchiveDirectory(environment, platform);
const setupArchive = (name: string, value: string) =>
name === ARCHIVE &&
value !== settings[ARCHIVE] &&
dirname(resolve(value)) === directory &&
/^rea-.+\.tar\.xz$/u.test(basename(value));
// Every name is compared, so a runner not installed yet still reads as unrecorded.
const compared = {
...Object.fromEntries(BUILD_COMPUTER_SETTINGS.map((name) => [name, ""])),
...settings,
};
const statuses = await registrations(clientIds, compared, setupArchive);
const recorded: Record<string, string[]> = {};
for (const status of statuses) {
if (!clientIds.includes(status.client) || status.command.length === 0)
continue;
const gaps = status.provider_settings;
const replaced = [...(gaps?.unrecorded ?? []), ...(gaps?.owned ?? [])];
recorded[status.client] = BUILD_COMPUTER_SETTINGS.filter(
(name) => !replaced.includes(name),
);
}
return recorded;
};
+2
View File
@@ -5,6 +5,7 @@ import {
type DoctorProviderInspection,
} from "./application/Doctor.js";
import { fileURLToPath } from "node:url";
import { readDesktopBuildFacts } from "./desktop/DesktopBuildDoctorFacts.js";
import {
JADX_DOCTOR_SETTINGS,
inspectJadxProvider,
@@ -413,6 +414,7 @@ export const createSystemDoctorHost = (
})),
] satisfies readonly DoctorProviderInspection[];
},
desktopBuildFacts: readDesktopBuildFacts,
linuxDemoRuntimeCheck: (resourceScope, inspectionSignal) =>
inspectLinuxHopperDemoRuntime(
environment,
@@ -2,6 +2,16 @@ import { describe, expect, it } from "vitest";
import type { JsonValue } from "../jsonValue.js";
import { draftOf, statePath, xml, xmlDraft } from "./appTaskDraft.fixture.js";
import { readAsTextNotes } from "./appTaskDraftFileNotes.js";
it("explains when an XML name is unsupported by the exported reader", () => {
expect(readAsTextNotes("Documents/map.dat", ["<Ϳ/>"])).toEqual([
expect.objectContaining({
code: "read_as_text",
message: expect.stringContaining("XML names are not supported"),
}),
]);
});
describe("draftTaskFiles notes on thin files", () => {
it("says which elements only position names when no check can name a change", () => {
+275
View File
@@ -0,0 +1,275 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { expect } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import { defaultBuildComputerArchive } from "../../../src/desktop/BuildComputerEnvironment.js";
import { DESKTOP_EXPORT_FRAMEWORK_VERSION } from "../../../src/domain/desktop/appExport.js";
const checkSchema = z.object({ name: z.string(), ok: z.boolean() }).loose();
/** A host whose Docker refuses this user and whose Claude Code registration records one build setting. */
const buildHost = async (launcher = "/opt/rea/scripts/rea.mjs") => {
const root = await createTestTempDirectory("rea-desktop-build-cli-");
const bin = join(root, "bin");
const home = join(root, "home");
await mkdir(bin);
await mkdir(home);
await writeFile(
join(bin, "docker"),
'#!/bin/sh\necho "permission denied while trying to connect to the Docker daemon socket" >&2\nexit 1\n',
{ mode: 0o755 },
);
await writeFile(
join(home, ".claude.json"),
JSON.stringify({
mcpServers: {
rea: {
command: process.execPath,
args: [launcher, "mcp"],
env: { REA_DOCKER_COMMAND: "/private/value/docker" },
},
},
}),
);
return {
home,
cache: join(root, "cache"),
environment: {
HOME: home,
USERPROFILE: home,
XDG_CACHE_HOME: join(root, "cache"),
PATH: `${bin}:/usr/bin:/bin`,
HOPPER_LAUNCHER_PATH: join(root, "missing-hopper"),
REA_LOG_LEVEL: "silent",
},
};
};
cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
"doctor checks the selected client's runner even when it is not on PATH",
{ timeout: 60_000 },
async ({ cli }) => {
const { environment, home } = await buildHost();
const bin = join(home, "..", "bin");
const privateBin = join(home, ".local", "bin");
await mkdir(privateBin, { recursive: true });
const runner = join(privateBin, "agent-env");
await writeFile(
runner,
`#!/bin/sh\necho "agent-env ${DESKTOP_EXPORT_FRAMEWORK_VERSION}"\n`,
{ mode: 0o755 },
);
await writeFile(
join(home, ".claude.json"),
JSON.stringify({
mcpServers: {
rea: {
command: process.execPath,
args: ["/opt/rea/scripts/rea.mjs", "mcp"],
env: {
REA_DOCKER_COMMAND: join(bin, "docker"),
REA_AGENT_ENV_COMMAND: runner,
REA_BUILD_COMPUTER_REA_ARCHIVE: defaultBuildComputerArchive(
environment,
process.platform,
),
},
},
},
}),
);
const result = await cli.run({
arguments: [
"doctor",
"--provider",
"desktop_build",
"--client",
"claude_code",
"--json",
],
environment,
timeoutMs: 45_000,
});
const report = z
.object({ scope_checks: z.array(checkSchema) })
.parse(result.json);
expect(
report.scope_checks.find(
({ name }) => name === "desktop_build-agent-env",
),
).toMatchObject({
ok: true,
detail: expect.stringContaining("claude_code"),
});
expect(
report.scope_checks.find(
({ name }) => name === "desktop_build-registration",
),
).toMatchObject({ ok: true });
expect(result.stdout).not.toContain(runner);
},
);
cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
"doctor accepts client paths matching resolved build defaults",
{ timeout: 60_000 },
async ({ cli }) => {
const { environment, home } = await buildHost();
const bin = join(home, "..", "bin");
await writeFile(
join(bin, "agent-env"),
'#!/bin/sh\necho "agent-env 0.9.1431"\n',
{ mode: 0o755 },
);
await writeFile(
join(home, ".claude.json"),
JSON.stringify({
mcpServers: {
rea: {
command: process.execPath,
args: ["/opt/rea/scripts/rea.mjs", "mcp"],
env: {
REA_DOCKER_COMMAND: join(bin, "docker"),
REA_AGENT_ENV_COMMAND: join(bin, "agent-env"),
REA_BUILD_COMPUTER_REA_ARCHIVE: defaultBuildComputerArchive(
environment,
process.platform,
),
},
},
},
}),
);
const result = await cli.run({
arguments: [
"doctor",
"--provider",
"desktop_build",
"--client",
"claude_code",
"--json",
],
environment,
timeoutMs: 45_000,
});
const report = z
.object({ scope_checks: z.array(checkSchema) })
.parse(result.json);
expect(
report.scope_checks.find(
({ name }) => name === "desktop_build-registration",
),
).toMatchObject({ ok: true });
},
);
cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
"doctor checks a desktop build host and names settings without their values",
{ timeout: 60_000 },
async ({ cli }) => {
const { environment } = await buildHost();
const result = await cli.run({
arguments: [
"doctor",
"--provider",
"desktop_build",
"--client",
"claude_code",
"--json",
],
environment: {
...environment,
REA_DOCKER_COMMAND: join(environment.HOME, "..", "bin", "docker"),
},
timeoutMs: 45_000,
});
expect(result.exitCode, result.stderr).toBe(1);
const report = z
.object({
healthy: z.boolean(),
scope: z.object({ providers: z.array(z.string()) }).loose(),
scope_checks: z.array(checkSchema),
informational_checks: z.array(checkSchema),
})
.parse(result.json);
expect(report.scope.providers).toEqual(["desktop_build"]);
const checks = new Map(
report.scope_checks.map((check) => [check.name, check]),
);
expect(checks.get("desktop_build-docker-daemon")).toMatchObject({
ok: false,
remediation: expect.stringContaining("sudo usermod -aG docker $USER"),
});
expect(checks.get("desktop_build-registration")).toMatchObject({
ok: false,
detail: expect.stringContaining("REA_DOCKER_COMMAND"),
details: {
registrations: [
{
client: "claude_code",
missing: [
"REA_AGENT_ENV_COMMAND",
"REA_BUILD_COMPUTER_REA_ARCHIVE",
],
differing: ["REA_DOCKER_COMMAND"],
},
],
},
});
expect(report.informational_checks.map(({ name }) => name)).toContain(
"desktop_build-solve-endpoint",
);
expect(result.stdout).not.toContain("/private/value");
},
);
cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
"setup plans the runtime archive and the build settings for a client",
{ timeout: 60_000 },
async ({ cli }) => {
const { environment } = await buildHost(resolve("scripts/rea.mjs"));
const result = await cli.run({
arguments: [
"setup",
"--client",
"claude_code",
"--desktop-build",
"--skill=false",
"--dry-run",
"--json",
],
environment,
timeoutMs: 45_000,
});
const plan = z
.object({
status: z.string(),
plannedActions: z.array(
z.object({ id: z.string(), target: z.string(), detail: z.string() }),
),
})
.parse(result.json);
expect(plan.status).toBe("planned");
const pack = plan.plannedActions.find(
({ id }) => id === "pack_build_runtime",
);
expect(pack?.target).toMatch(
/\/build-computer\/rea-\d+\.\d+\.\d+.*\.tar\.xz$/u,
);
const configure = plan.plannedActions.find(
({ id }) => id === "configure_client:claude_code",
);
expect(configure?.detail).toContain(
`REA_BUILD_COMPUTER_REA_ARCHIVE=${pack?.target ?? ""}`,
);
expect(configure?.detail).not.toContain("REA_DOCKER_COMMAND");
},
);
@@ -752,7 +752,7 @@ describe("setup client configuration cleanup ownership", () => {
const failure = await filterClientsNeedingConfigure(
host,
[client],
{},
{ providerEnvironment: {} },
["rea", "mcp"],
)
.then((detectedClients) =>
@@ -0,0 +1,100 @@
import { mkdir, readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { runDesktopBuildOwnedCommand } from "../../../src/desktop/DesktopBuildOwnedCommand.js";
import { prepareProcessOwnershipInspection } from "../../../src/process/ProcessOwnershipObservation.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it.skipIf(process.platform === "win32")(
"stops the packing process family before removing its partial archive on timeout",
{ timeout: 15_000 },
async () => {
await prepareProcessOwnershipInspection();
const directory = await createTestTempDirectory("rea-owned-pack-");
const partial = join(directory, "partial.tar.xz");
const workspace = join(directory, "work");
await mkdir(workspace);
const pidFile = join(directory, "child.pid");
const child = `require('node:fs').writeFileSync(process.argv[1], String(process.pid)); require('node:fs').writeFileSync(require('node:path').join(require('node:os').tmpdir(), 'child-work'), 'temporary'); setInterval(() => {}, 1000);`;
const parent = `const {spawn} = require('node:child_process'); require('node:fs').writeFileSync(process.argv[1], 'partial'); spawn(process.execPath, ['-e', process.argv[3], process.argv[2]], {stdio:'inherit'}); setInterval(() => {}, 1000);`;
const resources = new ArtifactResourceScope();
try {
await expect(
runDesktopBuildOwnedCommand({
command: process.execPath,
arguments: ["-e", parent, partial, pidFile, child],
environment: {
...process.env,
TMPDIR: workspace,
TMP: workspace,
TEMP: workspace,
},
timeoutMs: 2000,
maxOutputBytes: 4096,
resources,
partialPath: partial,
workspace,
}),
).rejects.toMatchObject({ reason: "timeout" });
const pid = Number(await readFile(pidFile, "utf8"));
expect(Number.isInteger(pid)).toBe(true);
expect(() => process.kill(pid, 0)).toThrow();
await expect(stat(partial)).rejects.toMatchObject({ code: "ENOENT" });
await expect(stat(workspace)).rejects.toMatchObject({ code: "ENOENT" });
expect(resources.hasPending).toBe(false);
} finally {
await resources.close();
}
},
);
it.skipIf(process.platform === "win32")(
"cancels a running setup process family before removing its partial output",
{ timeout: 15_000 },
async () => {
await prepareProcessOwnershipInspection();
const directory = await createTestTempDirectory("rea-owned-pack-cancel-");
const partial = join(directory, "partial.tar.xz");
const pidFile = join(directory, "child.pid");
const child = `require('node:fs').writeFileSync(process.argv[1], String(process.pid)); setInterval(() => {}, 1000);`;
const parent = `const {spawn} = require('node:child_process'); require('node:fs').writeFileSync(process.argv[1], 'partial'); spawn(process.execPath, ['-e', process.argv[3], process.argv[2]], {stdio:'inherit'}); setInterval(() => {}, 1000);`;
const resources = new ArtifactResourceScope();
const controller = new AbortController();
const reason = new Error("caller stopped setup");
const running = runDesktopBuildOwnedCommand({
command: process.execPath,
arguments: ["-e", parent, partial, pidFile, child],
environment: process.env,
timeoutMs: 10_000,
maxOutputBytes: 4096,
resources,
partialPath: partial,
signal: controller.signal,
});
void running.catch(() => undefined);
try {
await vi.waitFor(
async () => {
expect(Number(await readFile(pidFile, "utf8"))).toBeGreaterThan(0);
},
{ timeout: 5000 },
);
const pid = Number(await readFile(pidFile, "utf8"));
controller.abort(reason);
await expect(running).rejects.toMatchObject({
_tag: "AnalysisCancelledError",
operation: "setup",
cause: reason,
diagnostics: { command_failure: "cancelled" },
});
expect(() => process.kill(pid, 0)).toThrow();
await expect(stat(partial)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
controller.abort(reason);
await running.catch(() => undefined);
await resources.close();
}
},
);
@@ -253,6 +253,25 @@ _ensure_state_aliases(home)
},
);
it("restores a selected alias to an existing settings file", async () => {
const root = await createTestTempDirectory("desktop-file-alias-");
const home = join(root, "home");
await mkdir(join(home, ".config"), { recursive: true });
await writeFile(join(home, ".config", "tellicorc"), "saved");
const imports = PYTHON_IMPORTS.split("\n")
.filter((line) => !line.includes("agentenv_protocol"))
.join("\n");
const program = `${imports}
STATE_ALIASES = [("settings", ".config/tellicorc")]
${PYTHON_HELPERS}
home = Path(sys.argv[1])
_ensure_state_aliases(home)
_ensure_state_aliases(home)
`;
execPython(["-I", "-B", "-c", program, home], { encoding: "utf8" });
expect(await readFile(join(home, "settings"), "utf8")).toBe("saved");
});
it("restores a selected settings alias without replacing an existing path", async () => {
const root = await createTestTempDirectory("desktop-state-alias-");
const home = join(root, "home");
@@ -303,6 +322,18 @@ _ensure_state_aliases(home)
}),
).toThrow();
expect(await readFile(join(other, "settings"), "utf8")).toBe("preserve");
const empty = join(root, "empty-home");
await mkdir(empty);
const emptySeed = join(root, "empty-seed");
await mkdir(emptySeed);
execPython(["-I", "-B", "-c", program, emptySeed, empty], {
encoding: "utf8",
});
await writeFile(join(empty, "settings", "new.ini"), "new settings");
expect(
await readFile(join(empty, ".config", "notes", "new.ini"), "utf8"),
).toBe("new settings");
});
/** A stand-in xprop answering the root window list and each window's class and state from a table. */
+2
View File
@@ -12,6 +12,7 @@ import { createServer } from "../../src/server/createServer.js";
import { createTestBinarySession } from "./binarySession.js";
import {
fakeAgentEnvRunner,
isolatedBuildDefaults,
startFakeComputer,
type FakeAgentEnvRunner,
type FakeComputer,
@@ -55,6 +56,7 @@ export const connectBuildServer = async (
...environment,
},
desktopBuildSettle: settle,
desktopBuildDefaults: await isolatedBuildDefaults(),
},
);
const client = new Client({ name: "build-computer-mcp", version: "1" });
+13 -5
View File
@@ -33,6 +33,7 @@ import { z } from "zod";
import { onTestFinished } from "vitest";
import { DesktopBuildComputers } from "../../src/application/desktop/DesktopBuildComputers.js";
import type { BuildComputerDefaults } from "../../src/desktop/BuildComputerEnvironment.js";
import { createTestTempDirectory } from "./temporaryDirectory.js";
/** A 2x1 PNG of a black and a white pixel, the fake driver's drawn desktop. */
@@ -757,6 +758,13 @@ export const fakeAgentEnvRunner = async (
* 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.
*/
/** Build-setting defaults that find nothing: an empty archive folder and PATH, never the host's own. */
export const isolatedBuildDefaults =
async (): Promise<BuildComputerDefaults> => {
const empty = await createTestTempDirectory("build-defaults-");
return { archiveDirectory: empty, searchPath: empty };
};
export const fakeBuildComputers = async (
options: FakeComputerOptions = {},
environment: Readonly<Record<string, string>> = {},
@@ -764,11 +772,11 @@ export const fakeBuildComputers = async (
const computer = await startFakeComputer(options);
onTestFinished(() => computer.close());
const runner = await fakeAgentEnvRunner(computer);
const computers = new DesktopBuildComputers({
...process.env,
...runner.environment,
...environment,
});
const computers = new DesktopBuildComputers(
{ ...process.env, ...runner.environment, ...environment },
undefined,
await isolatedBuildDefaults(),
);
const bundle = await createTestTempDirectory("exported-bundle-");
await mkdir(join(bundle, "tasks"));
await writeFile(join(bundle, "tasks", "smoke.json"), "[]\n");