diff --git a/.agents/skills/reverse-engineer-anything/references/desktop-build.md b/.agents/skills/reverse-engineer-anything/references/desktop-build.md index 3734627b59..c50e292dfb 100644 --- a/.agents/skills/reverse-engineer-anything/references/desktop-build.md +++ b/.agents/skills/reverse-engineer-anything/references/desktop-build.md @@ -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 --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 --task --agent claude-code --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 diff --git a/docs/desktop-build-computer.md b/docs/desktop-build-computer.md index 46c890df46..544a7a20ef 100644 --- a/docs/desktop-build-computer.md +++ b/docs/desktop-build-computer.md @@ -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-.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: diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 83e0f4d1a3..9260b6349c 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -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`; diff --git a/src/application/ClientRegistrationEnvironment.ts b/src/application/ClientRegistrationEnvironment.ts index 5833b81cde..60f9718110 100644 --- a/src/application/ClientRegistrationEnvironment.ts +++ b/src/application/ClientRegistrationEnvironment.ts @@ -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>, 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. */ diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 8bd457cc22..455b2d4f2e 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -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>; + /** 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, diff --git a/src/application/DesktopBuildDoctor.test.ts b/src/application/DesktopBuildDoctor.test.ts new file mode 100644 index 0000000000..1e199673ad --- /dev/null +++ b/src/application/DesktopBuildDoctor.test.ts @@ -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 = {}, + overrides: Partial = {}, +): 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 => { + 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", + ), + }), + ); + }); +}); diff --git a/src/application/DesktopBuildDoctor.ts b/src/application/DesktopBuildDoctor.ts new file mode 100644 index 0000000000..5d8abf241e --- /dev/null +++ b/src/application/DesktopBuildDoctor.ts @@ -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; + readonly platform: NodeJS.Platform; + readonly run: typeof execFileOutput; + readonly clientIds: readonly string[]; + readonly registrations: ( + clientIds: readonly string[] | undefined, + settings: Readonly>, + owned?: undefined, + observe?: (client: string, name: string, value: string) => void, + ) => Promise; +} + +/** What doctor already knows that the build checks reuse. */ +export interface DesktopBuildContext { + readonly host: Pick; + 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; + 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 => + 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 --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(" ")}`, + }, + ); diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 169fb88872..eb61415619 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -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>, + ownedProviderSetting?: OwnedProviderSetting, + observeProviderSetting?: ( + client: string, + name: string, + value: string, + ) => void, ): Promise; ilspyCmdVersion?(path: string): Promise; + /** Desktop build host facts; read only when that scope is selected. */ + desktopBuildFacts?(clientIds: readonly string[]): Promise; } /** 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; + readonly desktopBuildFacts?: ( + input: DesktopBuildFactsInput, + ) => Promise; } /** 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 ( diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts index 65938e0ef8..dd9573226d 100644 --- a/src/application/DoctorDiagnostics.ts +++ b/src/application/DoctorDiagnostics.ts @@ -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): DoctorCheck => { const failure = { remediation: `Install ${SUPPORTED_NODE_VERSION_PROSE}.`, classification: "missing_dependency", diff --git a/src/application/DoctorProviderIds.ts b/src/application/DoctorProviderIds.ts index 06a4f13a68..380e4353ee 100644 --- a/src/application/DoctorProviderIds.ts +++ b/src/application/DoctorProviderIds.ts @@ -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}-`); diff --git a/src/application/DoctorScope.ts b/src/application/DoctorScope.ts index 870562b8c9..9b5c204caa 100644 --- a/src/application/DoctorScope.ts +++ b/src/application/DoctorScope.ts @@ -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, diff --git a/src/application/MacHopper.test.ts b/src/application/MacHopper.test.ts index d2c75ff296..ba3853fc1f 100644 --- a/src/application/MacHopper.test.ts +++ b/src/application/MacHopper.test.ts @@ -35,12 +35,14 @@ class FakeMacHopperHost implements MacHopperInstallHost { opens = 0; replaces = false; downloads: Array<{ url: string; timeoutMs: number | undefined }> = []; + downloadSignals: Array = []; download = ( url: string, - options?: { readonly timeoutMs?: number }, + options?: { readonly timeoutMs?: number; readonly signal?: AbortSignal }, ): Promise => { 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); +}); diff --git a/src/application/MacHopper.ts b/src/application/MacHopper.ts index bb8a1fd7f9..b0deb4850a 100644 --- a/src/application/MacHopper.ts +++ b/src/application/MacHopper.ts @@ -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; createTemporaryDirectory(): Promise; createMountDirectory(path: string): Promise; @@ -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 => { + 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 => { 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), }; } diff --git a/src/application/Setup.desktopBuild.test.ts b/src/application/Setup.desktopBuild.test.ts new file mode 100644 index 0000000000..244198c63d --- /dev/null +++ b/src/application/Setup.desktopBuild.test.ts @@ -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 = {}) => { + 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 => ({ + 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((resolve) => { + started = resolve; + }); + const cleanup = new Promise((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], + }); + }, + ); +}); diff --git a/src/application/Setup.recovery.test.ts b/src/application/Setup.recovery.test.ts index 0071617240..0c00cbf559 100644 --- a/src/application/Setup.recovery.test.ts +++ b/src/application/Setup.recovery.test.ts @@ -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 } }, + }, + }); + }); +}); diff --git a/src/application/Setup.ts b/src/application/Setup.ts index 4cd51383be..ae8826aff3 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -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 => { + assertSetupActive(options.signal); const appliedActions: string[] = []; let plannedActions: readonly SetupAction[] = []; const clients: Record = {}; @@ -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 + >; }) => { 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); diff --git a/src/application/SetupCancellation.ts b/src/application/SetupCancellation.ts new file mode 100644 index 0000000000..1d26a20c70 --- /dev/null +++ b/src/application/SetupCancellation.ts @@ -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 } }), + }); +}; diff --git a/src/application/SetupClients.ts b/src/application/SetupClients.ts index a8892c74ce..5d291f604c 100644 --- a/src/application/SetupClients.ts +++ b/src/application/SetupClients.ts @@ -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, @@ -58,19 +60,24 @@ export const configureDetectedClients = async (options: { readonly host: SetupHost; readonly detectedClients: readonly SetupClient[]; readonly providerEnvironment: SetupProviderEnvironment; + readonly clientEnvironments?: + | Readonly> + | undefined; readonly command: readonly string[]; readonly clients: Record; readonly appliedActions: string[]; readonly onProgress?: (event: SetupProgressEvent) => void; + readonly signal?: AbortSignal | undefined; }): Promise => { 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; }; diff --git a/src/application/SetupDesktopBuild.ts b/src/application/SetupDesktopBuild.ts new file mode 100644 index 0000000000..47b4948a3c --- /dev/null +++ b/src/application/SetupDesktopBuild.ts @@ -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>; +} + +/** Effects `rea setup --desktop-build` needs from its host. */ +export interface DesktopBuildSetupHost { + facts(clientIds: readonly string[]): Promise; + /** Pack this REA as the build-computer runtime; the reason when it failed. */ + packRuntime( + archive: string, + signal?: AbortSignal, + ): Promise; + /** Install the pinned agentenv-framework with uv; the reason when it failed. */ + installAgentenv( + uv: string, + signal?: AbortSignal, + ): Promise; +} + +/** 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 + >; + readonly remediation?: string; + /** Run the approved install and pack; the remediation for whatever failed. */ + apply( + appliedActions: string[], + onProgress?: (event: SetupProgressEvent) => void, + signal?: AbortSignal, + ): Promise; + /** 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, + clientIds: readonly string[], +): Promise => { + 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(); + 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 => { + 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}`], +}); diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index d59260c3b3..05e54b67df 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -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[0], command: readonly string[], ): Promise => { 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(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, + resources: ArtifactResourceScope, +) => + systemDesktopBuildSetupHost( + environment, + doctorHost.platform, + doctorHost.clientRegistrations, + resources, + ); + const setupInstallerDiagnostic = ( value: string | undefined, ): string | undefined => { diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index 5cc3762841..242b5ed2e3 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -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> + | 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[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> + | 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> + | 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, diff --git a/src/application/SetupTypes.ts b/src/application/SetupTypes.ts index 0f9a51467d..0df4ef2406 100644 --- a/src/application/SetupTypes.ts +++ b/src/application/SetupTypes.ts @@ -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; linuxDistribution(): Promise; initialSetupState(scope?: DoctorScope): Promise; - installHopper(replaceExisting: boolean): Promise; + /** Cancellation stops downloads and later steps; a started package transaction finishes. */ + installHopper( + replaceExisting: boolean, + signal?: AbortSignal, + ): Promise; /** 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>; installSkill(clientIds: readonly string[]): Promise; doctor(scope?: DoctorScope): Promise>>; + /** 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; } diff --git a/src/application/desktop/DesktopBuildBundle.ts b/src/application/desktop/DesktopBuildBundle.ts index a93a2b0361..ceb018bdf6 100644 --- a/src/application/desktop/DesktopBuildBundle.ts +++ b/src/application/desktop/DesktopBuildBundle.ts @@ -30,7 +30,7 @@ const reaPayload = async ( ): Promise => { 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 --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 }; diff --git a/src/application/desktop/DesktopBuildComputers.ts b/src/application/desktop/DesktopBuildComputers.ts index 8406289ec5..0534c98a35 100644 --- a/src/application/desktop/DesktopBuildComputers.ts +++ b/src/application/desktop/DesktopBuildComputers.ts @@ -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, private readonly settle: BuildSettle = SETTLE_TIMING, + private readonly defaults: BuildComputerDefaults = {}, ) {} async start( @@ -360,6 +365,11 @@ export class DesktopBuildComputers { progress: ProgressReporter | undefined, ): ReturnType { 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, }); diff --git a/src/cli/interactiveSetup.ts b/src/cli/interactiveSetup.ts index 6a4af58753..5e79ae74c2 100644 --- a/src/cli/interactiveSetup.ts +++ b/src/cli/interactiveSetup.ts @@ -36,6 +36,7 @@ export const confirmInteractiveSetup = async ( actions: readonly SetupAction[], accessible: boolean, context?: SetupConfirmationContext, + signal?: AbortSignal, ): Promise => { 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 => { 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, + signal?: AbortSignal, ): Promise => { 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, + signal?: AbortSignal, ): Promise => { 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, diff --git a/src/cli/setupCommands.ts b/src/cli/setupCommands.ts index d40723bf8e..6f7da20f63 100644 --- a/src/cli/setupCommands.ts +++ b/src/cli/setupCommands.ts @@ -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, + 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 => + 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[]; diff --git a/src/composition/capabilityServices.ts b/src/composition/capabilityServices.ts index b215962336..3c99c8212a 100644 --- a/src/composition/capabilityServices.ts +++ b/src/composition/capabilityServices.ts @@ -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(), ), diff --git a/src/config/kunaCacheDirectory.ts b/src/config/kunaCacheDirectory.ts index f40a9faac1..4a6bd4551f 100644 --- a/src/config/kunaCacheDirectory.ts +++ b/src/config/kunaCacheDirectory.ts @@ -9,15 +9,23 @@ export const defaultKunaCacheDirectory = ( home: string, environment: Readonly, platform: NodeJS.Platform, +): string => reaCacheDirectory(home, environment, platform, "kuna"); + +/** A per-user cache directory REA owns on this host: `rea/` under the platform's cache root. */ +export const reaCacheDirectory = ( + home: string, + environment: Readonly, + 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); }; /** diff --git a/src/desktop/AgentEnvComputerHelper.ts b/src/desktop/AgentEnvComputerHelper.ts index 710102b4a3..f5c8e02301 100644 --- a/src/desktop/AgentEnvComputerHelper.ts +++ b/src/desktop/AgentEnvComputerHelper.ts @@ -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; diff --git a/src/desktop/BuildComputerEnvironment.test.ts b/src/desktop/BuildComputerEnvironment.test.ts new file mode 100644 index 0000000000..3fa707112f --- /dev/null +++ b/src/desktop/BuildComputerEnvironment.test.ts @@ -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"); + }, +); diff --git a/src/desktop/BuildComputerEnvironment.ts b/src/desktop/BuildComputerEnvironment.ts new file mode 100644 index 0000000000..20850725a4 --- /dev/null +++ b/src/desktop/BuildComputerEnvironment.ts @@ -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, + 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, + 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 `. */ +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, +): Promise => { + 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, +): Promise => + value === undefined || value === "" + ? programOnPath(program, environment) + : Promise.resolve(value); + +const isFile = (path: string): Promise => + 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, + platform: NodeJS.Platform = process.platform, + sources: BuildComputerDefaults = {}, +): Promise> => { + const defaults: Record = {}; + 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 }; +}; diff --git a/src/desktop/DesktopBuildDoctorFacts.test.ts b/src/desktop/DesktopBuildDoctorFacts.test.ts new file mode 100644 index 0000000000..6e0f06ce30 --- /dev/null +++ b/src/desktop/DesktopBuildDoctorFacts.test.ts @@ -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 | undefined; + try { + const facts = await Promise.race([ + pending, + new Promise((_, 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", + }); +}); diff --git a/src/desktop/DesktopBuildDoctorFacts.ts b/src/desktop/DesktopBuildDoctorFacts.ts new file mode 100644 index 0000000000..6ba882e8a3 --- /dev/null +++ b/src/desktop/DesktopBuildDoctorFacts.ts @@ -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 => { + 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>(); + const versionFor = (path: string): Promise => { + 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> = { + ...(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 => + access(path, constants.X_OK).then( + async () => (await stat(path)).isFile(), + () => false, + ); + +const settingCommand = async ( + environment: Readonly, + name: BuildComputerSetting, + program: string, +): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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>, + versionFor: (path: string) => Promise, +): Promise => { + const selected = input.clientIds.length === 0 ? undefined : input.clientIds; + const recordedRunners = new Map(); + 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 => { + 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 => { + 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-`, and those it labels with their id. */ +const leftoverContainers = async ( + input: DesktopBuildFactsInput, + docker: string, +): Promise => { + const names = new Set(); + 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(); +}; diff --git a/src/desktop/DesktopBuildOwnedCommand.test.ts b/src/desktop/DesktopBuildOwnedCommand.test.ts new file mode 100644 index 0000000000..12a220c868 --- /dev/null +++ b/src/desktop/DesktopBuildOwnedCommand.test.ts @@ -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((resolve) => { + entered = resolve; + }); + const cleanupRelease = new Promise((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(); + } +}); diff --git a/src/desktop/DesktopBuildOwnedCommand.ts b/src/desktop/DesktopBuildOwnedCommand.ts new file mode 100644 index 0000000000..d2b7fd1a54 --- /dev/null +++ b/src/desktop/DesktopBuildOwnedCommand.ts @@ -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; + readonly timeoutMs: number; + readonly maxOutputBytes: number; + readonly resources: ArtifactResourceScope; + readonly partialPath?: string; + readonly workspace?: string; + readonly signal?: AbortSignal; +}): Promise => { + 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)}`; +}; diff --git a/src/desktop/DesktopBuildSetupHost.test.ts b/src/desktop/DesktopBuildSetupHost.test.ts new file mode 100644 index 0000000000..4546b823bd --- /dev/null +++ b/src/desktop/DesktopBuildSetupHost.test.ts @@ -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?.(); + } + }, +); diff --git a/src/desktop/DesktopBuildSetupHost.ts b/src/desktop/DesktopBuildSetupHost.ts new file mode 100644 index 0000000000..fb039684d1 --- /dev/null +++ b/src/desktop/DesktopBuildSetupHost.ts @@ -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; + readonly platform: NodeJS.Platform; + readonly registrations: DoctorHost["clientRegistrations"]; + probe( + command: string, + arguments_: readonly string[], + ): Promise<{ readonly stdout: string }>; +} + +const exists = (path: string): Promise => + 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, + 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 => { + 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, + archive: string, + platform: NodeJS.Platform, +): Promise> => { + const settings: Record = {}; + 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>, +): Promise> => { + 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 = {}; + 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; +}; diff --git a/src/doctorRuntime.ts b/src/doctorRuntime.ts index 52297153b3..6533aaf7c8 100644 --- a/src/doctorRuntime.ts +++ b/src/doctorRuntime.ts @@ -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, diff --git a/src/domain/desktop/appTaskDraftFileNotes.test.ts b/src/domain/desktop/appTaskDraftFileNotes.test.ts index 310b9f87e9..01323443a4 100644 --- a/src/domain/desktop/appTaskDraftFileNotes.test.ts +++ b/src/domain/desktop/appTaskDraftFileNotes.test.ts @@ -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", () => { diff --git a/tests/boundary/cli/desktopBuildHost.test.ts b/tests/boundary/cli/desktopBuildHost.test.ts new file mode 100644 index 0000000000..22c1494b92 --- /dev/null +++ b/tests/boundary/cli/desktopBuildHost.test.ts @@ -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"); + }, +); diff --git a/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts b/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts index 0f1336526f..ec0e4b29d1 100644 --- a/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts +++ b/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts @@ -752,7 +752,7 @@ describe("setup client configuration cleanup ownership", () => { const failure = await filterClientsNeedingConfigure( host, [client], - {}, + { providerEnvironment: {} }, ["rea", "mcp"], ) .then((detectedClients) => diff --git a/tests/boundary/process/desktopBuildOwnedCommand.test.ts b/tests/boundary/process/desktopBuildOwnedCommand.test.ts new file mode 100644 index 0000000000..c351a491d9 --- /dev/null +++ b/tests/boundary/process/desktopBuildOwnedCommand.test.ts @@ -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(); + } + }, +); diff --git a/tests/boundary/providers/desktopExportPython.test.ts b/tests/boundary/providers/desktopExportPython.test.ts index 0139220fab..95fe8fe9fc 100644 --- a/tests/boundary/providers/desktopExportPython.test.ts +++ b/tests/boundary/providers/desktopExportPython.test.ts @@ -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. */ diff --git a/tests/fixtures/buildComputerMcp.ts b/tests/fixtures/buildComputerMcp.ts index 1222ea2611..f8d0229b8f 100644 --- a/tests/fixtures/buildComputerMcp.ts +++ b/tests/fixtures/buildComputerMcp.ts @@ -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" }); diff --git a/tests/fixtures/fakeAgentEnvComputer.ts b/tests/fixtures/fakeAgentEnvComputer.ts index 274a3ebb8e..dada1986b0 100644 --- a/tests/fixtures/fakeAgentEnvComputer.ts +++ b/tests/fixtures/fakeAgentEnvComputer.ts @@ -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 => { + const empty = await createTestTempDirectory("build-defaults-"); + return { archiveDirectory: empty, searchPath: empty }; + }; + export const fakeBuildComputers = async ( options: FakeComputerOptions = {}, environment: Readonly> = {}, @@ -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");