From c2caae88f8bf3290ad5535ccac9bdd35b6733852 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:50:01 +0800 Subject: [PATCH] refactor(cli): share process environment preparation Give the launcher and shared command worker one lightweight owner for package-version binding, Windows empty-home handling and Android browser-cache defaults. Preserve per-command preparation and environment restoration, package the owner explicitly, and retain its package and JavaScript recovery CI scopes. Validation: dispatcher/version/shared-session, home fallback, evaluator/MCP/process cleanup and CI scope tests passed. Functional archive/install/repack, installed static CLI analysis and MCP checks passed; no real Codex/model trial or Windows/Termux runtime qualification was performed. --- package.json | 1 + scripts/ci/scopes.mjs | 2 ++ scripts/lib/cli-process-environment.mjs | 26 +++++++++++++++ scripts/prepack.mjs | 1 + scripts/rea.mjs | 30 +++++------------ scripts/verify-package-pack.mjs | 2 ++ tests/boundary/cli/dispatcher.test.ts | 11 ++++++- .../boundary/filesystem/ciChangeScope.test.ts | 10 ++++++ tests/support/cli/cliCommandSessionWorker.mjs | 32 +++++++------------ 9 files changed, 72 insertions(+), 43 deletions(-) create mode 100644 scripts/lib/cli-process-environment.mjs diff --git a/package.json b/package.json index 5d525b82df..ab439eb950 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "bridge/android/ReaJadxBridge.java", "bridge/mitmproxy/capture.py", "scripts/rea.mjs", + "scripts/lib/cli-process-environment.mjs", "scripts/rea-android-workspace-guardian.mjs", "scripts/electron-active-hook.cjs", "scripts/electron-active-hook-boundaries.cjs", diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index a3562d6681..367a317901 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -145,6 +145,7 @@ export const SCOPES = { "scripts/prepack.mjs", "scripts/prepare.mjs", "scripts/rea.mjs", + "scripts/lib/cli-process-environment.mjs", "install.sh", "src/config/**", "src/application/Setup*.ts", @@ -377,6 +378,7 @@ export const SCOPES = { "src/contracts/toolContracts.ts", "src/contracts/toolEffects.ts", "scripts/rea.mjs", + "scripts/lib/cli-process-environment.mjs", "src/process/**", "src/artifacts/SafeOutputTree.ts", "scripts/verify/javascript/recovery.mjs", diff --git a/scripts/lib/cli-process-environment.mjs b/scripts/lib/cli-process-environment.mjs new file mode 100644 index 0000000000..8876318c24 --- /dev/null +++ b/scripts/lib/cli-process-environment.mjs @@ -0,0 +1,26 @@ +import { join } from "node:path"; + +/** Prepare the selected process environment before importing CLI or MCP dependencies. */ +export const prepareCliProcessEnvironment = ( + environment, + platform, + packageVersion, +) => { + environment.REA_PACKAGE_VERSION = packageVersion; + // Windows home lookup throws for an empty USERPROFILE. Dependencies resolve + // home during import, before REA's own resolver can apply its account fallback. + if (platform === "win32" && environment.USERPROFILE === "") { + delete environment.USERPROFILE; + } + + // Playwright's registry assumes a desktop host while choosing its cache. + // Termux needs an explicit path before the registry initializes. + if ( + platform === "android" && + environment.PLAYWRIGHT_BROWSERS_PATH === undefined + ) { + const cacheRoot = + environment.XDG_CACHE_HOME ?? join(environment.HOME ?? ".", ".cache"); + environment.PLAYWRIGHT_BROWSERS_PATH = join(cacheRoot, "ms-playwright"); + } +}; diff --git a/scripts/prepack.mjs b/scripts/prepack.mjs index 4279aab88f..f3c8061927 100644 --- a/scripts/prepack.mjs +++ b/scripts/prepack.mjs @@ -45,6 +45,7 @@ if (await pathExists(join(root, "src"))) { run(process.execPath, ["scripts/generate-skill-metadata.mjs", "--check"]); } else { for (const path of [ + "scripts/lib/cli-process-environment.mjs", "dist/main.js", "dist/mcp/main.js", "dist/cli-runtime/cli.js", diff --git a/scripts/rea.mjs b/scripts/rea.mjs index fa956d3293..27f334994a 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -1,9 +1,11 @@ #!/usr/bin/env node import { access } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { prepareCliProcessEnvironment } from "./lib/cli-process-environment.mjs"; + // Route production MCP before importing Incur. Incur owns registration helpers // such as `mcp add`, while the MCP runtime serves the tool catalog over stdio // or, with `--http`, over streamable HTTP. @@ -11,7 +13,11 @@ const args = process.argv.slice(2); const { default: packageJson } = await import("../package.json", { with: { type: "json" }, }); -process.env.REA_PACKAGE_VERSION = packageJson.version; +prepareCliProcessEnvironment( + process.env, + process.platform, + packageJson.version, +); const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const builtinArgumentRuntime = "dist/cli-runtime/cliBuiltinArguments.js"; const builtinArguments = (await compiledRuntimeExists([builtinArgumentRuntime])) @@ -36,26 +42,6 @@ const isMcpHttpMode = const SIGPIPE_EXIT_STATUS = 141; // Set once the reader of CLI output has closed the pipe. let stdoutClosed = false; -// Windows home lookup throws for an empty USERPROFILE. Dependencies resolve -// home during import, before REA's own resolver can apply its account fallback. -if (process.platform === "win32" && process.env.USERPROFILE === "") { - delete process.env.USERPROFILE; -} - -// Playwright's registry assumes one of its three desktop host platforms when -// it chooses the browser cache directory. Termux reports `android`, although -// its Node runtime and filesystem are POSIX-compatible. Keep the cache path -// explicit so REA can start on Termux. Browser launch still requires a -// caller-supplied Android-compatible Chromium executable. -if ( - process.platform === "android" && - process.env.PLAYWRIGHT_BROWSERS_PATH === undefined -) { - const cacheRoot = - process.env.XDG_CACHE_HOME ?? join(process.env.HOME ?? ".", ".cache"); - process.env.PLAYWRIGHT_BROWSERS_PATH = join(cacheRoot, "ms-playwright"); -} - const runtimeFiles = [ builtinArgumentRuntime, ...(isMcpMode || isMcpHttpMode diff --git a/scripts/verify-package-pack.mjs b/scripts/verify-package-pack.mjs index 9111f67887..993061f657 100644 --- a/scripts/verify-package-pack.mjs +++ b/scripts/verify-package-pack.mjs @@ -37,6 +37,8 @@ export async function verifyPackagePack({ root, workspace }) { throw new Error("package did not expose both rea command entry points"); if (packedManifest.mcpName !== "io.github.morluto/rea") throw new Error("package did not retain its MCP Registry ownership marker"); + if (!packedFiles.includes("package/scripts/lib/cli-process-environment.mjs")) + throw new Error("package omitted the CLI process environment owner"); if (packedFiles.some((path) => path.startsWith("package/.agents/"))) throw new Error( "package included authored skill sources instead of only the generated bundle", diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts index 16b7205361..e617ee6ab2 100644 --- a/tests/boundary/cli/dispatcher.test.ts +++ b/tests/boundary/cli/dispatcher.test.ts @@ -15,7 +15,7 @@ const installCliRuntime = async ( cliSource: string, resultOutput = "export const createCliResultOutput = () => undefined;\n", ): Promise => { - await workspace.mkdir("scripts"); + await workspace.mkdir("scripts/lib"); await workspace.mkdir("dist"); // The bundle's entrypoints and shared chunks form one emitted runtime. await cp("dist/cli-runtime", workspace.path("dist/cli-runtime"), { @@ -23,6 +23,10 @@ const installCliRuntime = async ( }); await Promise.all([ copyFile("scripts/rea.mjs", workspace.path("scripts/rea.mjs")), + copyFile( + "scripts/lib/cli-process-environment.mjs", + workspace.path("scripts/lib/cli-process-environment.mjs"), + ), workspace.write( "package.json", JSON.stringify({ type: "module", version: "1.0.0" }), @@ -289,8 +293,13 @@ describe("executable dispatcher", () => { "explains how to restore a missing compiled runtime", async ({ processes, workspace }) => { const scripts = await workspace.mkdir("scripts"); + await workspace.mkdir("scripts/lib"); await Promise.all([ copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")), + copyFile( + "scripts/lib/cli-process-environment.mjs", + workspace.path("scripts/lib/cli-process-environment.mjs"), + ), workspace.write( "package.json", JSON.stringify({ type: "module", version: "1.0.0" }), diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 2d423d89cb..ce3217f79b 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -1256,6 +1256,16 @@ it.each([ package: false, }, ], + [ + "scripts/lib/cli-process-environment.mjs", + { + code: true, + package: true, + runtime: true, + javascript_recovery: true, + readiness: false, + }, + ], [ "tests/conformance/readiness/report/reconstructionReadiness.ts", { diff --git a/tests/support/cli/cliCommandSessionWorker.mjs b/tests/support/cli/cliCommandSessionWorker.mjs index c98b6aa3ba..c75e3a33da 100644 --- a/tests/support/cli/cliCommandSessionWorker.mjs +++ b/tests/support/cli/cliCommandSessionWorker.mjs @@ -1,8 +1,9 @@ import { Writable } from "node:stream"; import { access } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { prepareCliProcessEnvironment } from "../../../scripts/lib/cli-process-environment.mjs"; import { createCliSessionFrameReader, encodeCliSessionFrame, @@ -19,23 +20,6 @@ const { default: packageJson } = await import( { with: { type: "json" } } ); -const prepareCliProcess = () => { - process.env.REA_PACKAGE_VERSION = packageJson.version; - // Windows home lookup throws for an empty USERPROFILE. Dependencies resolve - // home during import, before REA's own resolver can apply its account fallback. - if (process.platform === "win32" && process.env.USERPROFILE === "") { - delete process.env.USERPROFILE; - } - if ( - process.platform === "android" && - process.env.PLAYWRIGHT_BROWSERS_PATH === undefined - ) { - const cacheRoot = - process.env.XDG_CACHE_HOME ?? join(process.env.HOME ?? ".", ".cache"); - process.env.PLAYWRIGHT_BROWSERS_PATH = join(cacheRoot, "ms-playwright"); - } -}; - const runtimeFiles = [ "dist/cli-runtime/cli.js", "dist/cli-runtime/cliOutput.js", @@ -43,7 +27,11 @@ const runtimeFiles = [ "dist/cli-runtime/cliResultOutput.js", ]; -prepareCliProcess(); +prepareCliProcessEnvironment( + process.env, + process.platform, + packageJson.version, +); if (!(await compiledRuntimeExists(runtimeFiles))) { process.stderr.write( `REA's compiled runtime is missing. Run \`npm ci && npm run build:cached\` in ${packageRoot} to install dependencies and build REA, then restart it. If this is an installed package, reinstall rea-agents.\n`, @@ -118,7 +106,11 @@ async function handle(value) { } for (const key of Object.keys(process.env)) delete process.env[key]; Object.assign(process.env, request.env); - prepareCliProcess(); + prepareCliProcessEnvironment( + process.env, + process.platform, + packageJson.version, + ); process.chdir(request.cwd); const result = await runOneShot(request.arguments); await writeFrame({