diff --git a/scripts/e2e/codex-npm-plugin-live-docker.sh b/scripts/e2e/codex-npm-plugin-live-docker.sh index a09d796cd267..5da28c43bf6b 100644 --- a/scripts/e2e/codex-npm-plugin-live-docker.sh +++ b/scripts/e2e/codex-npm-plugin-live-docker.sh @@ -20,6 +20,9 @@ PROFILE_FILE="${OPENCLAW_CODEX_NPM_PLUGIN_PROFILE_FILE:-${OPENCLAW_TESTBOX_PROFI CODEX_PLUGIN_SPEC="${OPENCLAW_CODEX_NPM_PLUGIN_SPEC:-}" CODEX_PLUGIN_MOUNT=() CODEX_PLUGIN_PACK_DIR="" +CODEX_PLUGIN_REGISTRY_PACKAGE="" +CODEX_PLUGIN_REGISTRY_TARBALL="" +CODEX_PLUGIN_REGISTRY_VERSION="" ASSERT_MAX_TEXT_FILE_BYTES="$( docker_e2e_read_positive_int_env OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES 1048576 )" @@ -90,9 +93,39 @@ prepare_package_tgz() { prepare_package_tgz +configure_codex_plugin_registry_candidate() { + local source_path="$1" + local container_path="/tmp/$(basename "$source_path")" + local package_json + + # Local npm-pack installs must stay untrusted. Serve the exact candidate through the + # fixture registry so this lane exercises the post-publish official install shape. + package_json="$(tar -xOf "$source_path" package/package.json)" + CODEX_PLUGIN_REGISTRY_PACKAGE="$( + node -e ' +const pkg = JSON.parse(process.argv[1]); +if (pkg.name !== "@openclaw/codex") { + throw new Error(`unexpected Codex package name: ${String(pkg.name)}`); +} +process.stdout.write(pkg.name); +' "$package_json" + )" + CODEX_PLUGIN_REGISTRY_VERSION="$( + node -e ' +const pkg = JSON.parse(process.argv[1]); +if (typeof pkg.version !== "string" || pkg.version.length === 0) { + throw new Error("packed Codex plugin is missing a version"); +} +process.stdout.write(pkg.version); +' "$package_json" + )" + CODEX_PLUGIN_REGISTRY_TARBALL="$container_path" + CODEX_PLUGIN_MOUNT=(-v "$source_path":"$container_path":ro) + CODEX_PLUGIN_SPEC="npm:${CODEX_PLUGIN_REGISTRY_PACKAGE}@${CODEX_PLUGIN_REGISTRY_VERSION}" +} + prepare_codex_plugin_spec() { local source_path - local container_path local pack_output if [ -z "$CODEX_PLUGIN_SPEC" ]; then @@ -113,9 +146,7 @@ prepare_codex_plugin_spec() { exit 1 fi source_path="${pack_output[0]}" - container_path="/tmp/$(basename "$source_path")" - CODEX_PLUGIN_MOUNT=(-v "$source_path":"$container_path":ro) - CODEX_PLUGIN_SPEC="npm-pack:$container_path" + configure_codex_plugin_registry_candidate "$source_path" return 0 fi @@ -128,9 +159,7 @@ prepare_codex_plugin_spec() { echo "Codex plugin npm-pack tarball not found: $source_path" >&2 exit 1 fi - container_path="/tmp/$(basename "$source_path")" - CODEX_PLUGIN_MOUNT=(-v "$source_path":"$container_path":ro) - CODEX_PLUGIN_SPEC="npm-pack:$container_path" + configure_codex_plugin_registry_candidate "$source_path" fi } @@ -164,6 +193,9 @@ if ! docker_e2e_run_with_harness \ -e OPENCLAW_CODEX_NPM_PLUGIN_FORCE_UNSAFE_INSTALL="${OPENCLAW_CODEX_NPM_PLUGIN_FORCE_UNSAFE_INSTALL:-1}" \ -e OPENCLAW_CODEX_NPM_PLUGIN_MODEL="${OPENCLAW_CODEX_NPM_PLUGIN_MODEL:-openai/gpt-5.4}" \ -e OPENCLAW_CODEX_NPM_PLUGIN_SPEC="$CODEX_PLUGIN_SPEC" \ + -e OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_PACKAGE="$CODEX_PLUGIN_REGISTRY_PACKAGE" \ + -e OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_TARBALL="$CODEX_PLUGIN_REGISTRY_TARBALL" \ + -e OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_VERSION="$CODEX_PLUGIN_REGISTRY_VERSION" \ -e OPENCLAW_CODEX_NPM_PLUGIN_BINDING_STORE_CONTRACT="$BINDING_STORE_CONTRACT" \ -e OPENCLAW_CODEX_NPM_PLUGIN_SESSION_STORE_CONTRACT="$SESSION_STORE_CONTRACT" \ -e "OPENCLAW_CODEX_NPM_PLUGIN_ASSERT_MAX_TEXT_FILE_BYTES=$ASSERT_MAX_TEXT_FILE_BYTES" \ @@ -210,6 +242,9 @@ if [ -n "${OPENAI_BASE_URL:-}" ]; then fi CODEX_PLUGIN_SPEC="${OPENCLAW_CODEX_NPM_PLUGIN_SPEC:?missing OPENCLAW_CODEX_NPM_PLUGIN_SPEC}" +CODEX_PLUGIN_REGISTRY_PACKAGE="${OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_PACKAGE:-}" +CODEX_PLUGIN_REGISTRY_TARBALL="${OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_TARBALL:-}" +CODEX_PLUGIN_REGISTRY_VERSION="${OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_VERSION:-}" MODEL_REF="${OPENCLAW_CODEX_NPM_PLUGIN_MODEL:?missing OPENCLAW_CODEX_NPM_PLUGIN_MODEL}" POST_UNINSTALL_MODEL_REF="$MODEL_REF" SESSION_ID="codex-npm-plugin-live" @@ -222,9 +257,11 @@ fi dump_debug_logs() { local status="$1" + debug_logs_dumped=1 echo "Codex npm plugin live scenario failed with exit code $status" >&2 openclaw_e2e_dump_logs \ /tmp/openclaw-install.log \ + /tmp/openclaw-codex-plugin-registry.log \ /tmp/openclaw-codex-plugin-install.log \ /tmp/openclaw-codex-plugin-enable.log \ /tmp/openclaw-codex-plugins-list.json \ @@ -244,7 +281,20 @@ dump_debug_logs() { /tmp/openclaw-codex-agent-after-uninstall.json \ /tmp/openclaw-codex-agent-after-uninstall.err } -trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR + +registry_pid="" +debug_logs_dumped=0 +cleanup_scenario() { + local status=$? + trap - EXIT + set +e + openclaw_e2e_stop_process "${registry_pid:-}" + if [ "$status" -ne 0 ] && [ "$debug_logs_dumped" -eq 0 ]; then + dump_debug_logs "$status" + fi + exit "$status" +} +trap cleanup_scenario EXIT mkdir -p "$NPM_CONFIG_PREFIX" "$XDG_CACHE_HOME" "$NPM_CONFIG_CACHE" chmod 700 "$XDG_CACHE_HOME" "$NPM_CONFIG_CACHE" || true @@ -253,6 +303,36 @@ openclaw_e2e_install_package /tmp/openclaw-install.log command -v openclaw >/dev/null openclaw_e2e_enable_openclaw_cli_timeout +if [ -n "$CODEX_PLUGIN_REGISTRY_TARBALL" ]; then + registry_port_file=/tmp/openclaw-codex-plugin-registry.port + rm -f "$registry_port_file" + OPENCLAW_NPM_REGISTRY_UPSTREAM="${OPENCLAW_CODEX_NPM_PLUGIN_REGISTRY_UPSTREAM:-https://registry.npmjs.org}" \ + node scripts/e2e/lib/plugins/npm-registry-server.mjs \ + "$registry_port_file" \ + "$CODEX_PLUGIN_REGISTRY_PACKAGE" \ + "$CODEX_PLUGIN_REGISTRY_VERSION" \ + "$CODEX_PLUGIN_REGISTRY_TARBALL" \ + >/tmp/openclaw-codex-plugin-registry.log 2>&1 & + registry_pid=$! + for _ in $(seq 1 100); do + if [ -s "$registry_port_file" ]; then + break + fi + if ! kill -0 "$registry_pid" 2>/dev/null; then + openclaw_e2e_print_log /tmp/openclaw-codex-plugin-registry.log >&2 + exit 1 + fi + sleep 0.1 + done + if [ ! -s "$registry_port_file" ]; then + openclaw_e2e_print_log /tmp/openclaw-codex-plugin-registry.log >&2 + echo "Timed out waiting for Codex plugin npm fixture registry." >&2 + exit 1 + fi + export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$registry_port_file")" + export npm_config_registry="$NPM_CONFIG_REGISTRY" +fi + echo "Installing Codex plugin: $CODEX_PLUGIN_SPEC" openclaw plugins install "$CODEX_PLUGIN_SPEC" "${PLUGIN_INSTALL_FLAGS[@]}" >/tmp/openclaw-codex-plugin-install.log 2>&1 diff --git a/scripts/e2e/lib/openai-chat-tools/scenario.sh b/scripts/e2e/lib/openai-chat-tools/scenario.sh index 98be670bf5c3..36a454d63cdb 100644 --- a/scripts/e2e/lib/openai-chat-tools/scenario.sh +++ b/scripts/e2e/lib/openai-chat-tools/scenario.sh @@ -62,6 +62,7 @@ gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")" for _ in $(seq 1 360); do if ! kill -0 "$gateway_pid" 2>/dev/null; then echo "gateway exited before listening" >&2 + openclaw_e2e_print_log "$GATEWAY_LOG" >&2 exit 1 fi if node "$entry" gateway health \ diff --git a/scripts/e2e/lib/upgrade-survivor/run.sh b/scripts/e2e/lib/upgrade-survivor/run.sh index dc39700dc377..86f3da1c40f1 100644 --- a/scripts/e2e/lib/upgrade-survivor/run.sh +++ b/scripts/e2e/lib/upgrade-survivor/run.sh @@ -744,13 +744,22 @@ daemon_log="${OPENCLAW_UPGRADE_SURVIVOR_SYSTEMCTL_SHIM_DAEMON_LOG:-/tmp/openclaw printf '%s\n' "$*" >>"$log_file" filtered=() +system_scope=1 +property="" for ((i = 1; i <= $#; i++)); do arg="${!i}" case "$arg" in - --user | --quiet | --no-page | --now) + --user) + system_scope=0 + ;; + --quiet | --no-page | --now | --value) ;; --property) i=$((i + 1)) + property="${!i}" + ;; + --property=*) + property="${arg#--property=}" ;; *) filtered+=("$arg") @@ -853,6 +862,21 @@ case "$command" in exit 3 ;; show) + if [ "$system_scope" = "1" ]; then + case "$property" in + LoadState) + printf 'not-found\n' + ;; + UnitPath) + printf '/etc/systemd/system /usr/lib/systemd/system\n' + ;; + *) + echo "systemctl shim unsupported system-scope show: $*" >&2 + exit 1 + ;; + esac + exit 0 + fi if is_running; then printf 'ActiveState=active\nSubState=running\nMainPID=%s\nExecMainStatus=0\nExecMainCode=0\n' "$(cat "$pid_file")" else diff --git a/scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh b/scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh index eeb2958d42a5..342864776f79 100644 --- a/scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh +++ b/scripts/e2e/lib/upgrade-survivor/update-restart-auth.sh @@ -13,13 +13,22 @@ daemon_log="${OPENCLAW_UPGRADE_SURVIVOR_SYSTEMCTL_SHIM_DAEMON_LOG:-/tmp/openclaw printf '%s\n' "$*" >>"$log_file" filtered=() +system_scope=1 +property="" for ((i = 1; i <= $#; i++)); do arg="${!i}" case "$arg" in - --user | --quiet | --no-page | --now) + --user) + system_scope=0 + ;; + --quiet | --no-page | --now | --value) ;; --property) i=$((i + 1)) + property="${!i}" + ;; + --property=*) + property="${arg#--property=}" ;; *) filtered+=("$arg") @@ -122,6 +131,21 @@ case "$command" in exit 3 ;; show) + if [ "$system_scope" = "1" ]; then + case "$property" in + LoadState) + printf 'not-found\n' + ;; + UnitPath) + printf '/etc/systemd/system /usr/lib/systemd/system\n' + ;; + *) + echo "systemctl shim unsupported system-scope show: $*" >&2 + exit 1 + ;; + esac + exit 0 + fi if is_running; then printf 'ActiveState=active\nSubState=running\nMainPID=%s\nExecMainStatus=0\nExecMainCode=0\n' "$(cat "$pid_file")" else diff --git a/scripts/lib/cross-os-release-checks/installed.ts b/scripts/lib/cross-os-release-checks/installed.ts index 5ef4c47b6053..b820a3288f6f 100644 --- a/scripts/lib/cross-os-release-checks/installed.ts +++ b/scripts/lib/cross-os-release-checks/installed.ts @@ -194,8 +194,10 @@ export function buildWindowsPathBootstrapScript( options: { includeCurrentProcessPath?: boolean } = {}, ) { const includeCurrentProcessPath = options.includeCurrentProcessPath !== false; + // setup-node provisions the supported runtime in the current process PATH. Keep it ahead of + // stale runner image entries while still merging newly persisted user and machine paths. const pathCandidates = includeCurrentProcessPath - ? "@($userPath, $machinePath, $env:Path)" + ? "@($env:Path, $userPath, $machinePath)" : "@($userPath, $machinePath)"; return ` $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') diff --git a/scripts/lib/cross-os-release-checks/lanes.ts b/scripts/lib/cross-os-release-checks/lanes.ts index 6dd92b261c00..daf27961d87b 100644 --- a/scripts/lib/cross-os-release-checks/lanes.ts +++ b/scripts/lib/cross-os-release-checks/lanes.ts @@ -1,5 +1,5 @@ -import { appendFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { appendFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir, userInfo } from "node:os"; import { join } from "node:path"; import type { CandidateBuild, @@ -397,8 +397,12 @@ export async function runInstallerFreshSuite( const usesManagedGateway = shouldUseManagedGatewayService(); const useManagedGatewayAfterInstall = shouldUseManagedGatewayForInstallerRuntime(); const manualGateway: { current: GatewayHandle | null } = { current: null }; - try { - const env = buildInstallerEnv(lane, params.providerConfig, params.providerSecretValue); + const managedHostLease: { current: ManagedGatewayInstallerHostLease | null } = { current: null }; + let managedHostOwned = false; + let managedHostEnv: NodeJS.ProcessEnv | null = null; + let managedHostCliPath = ""; + const run = async () => { + const installerEnv = buildInstallerEnv(lane, params.providerConfig, params.providerSecretValue); // Drive the public installer against the exact candidate artifact built from the requested ref. const candidateServer = await startStaticFileServer({ filePath: params.build.candidateTgz, @@ -411,7 +415,7 @@ export async function runInstallerFreshSuite( logLanePhase(lane, "installer-run"); await runInstallerSmoke({ lane, - env, + env: installerEnv, installerUrl, installTarget, logPath: join(params.logsDir, "installer-fresh-install.log"), @@ -420,7 +424,7 @@ export async function runInstallerFreshSuite( logLanePhase(lane, "fresh-shell"); const freshShell = await verifyFreshShellCommand({ lane, - env, + env: installerEnv, expectedNeedle: params.build.candidateVersion, logPath: join(params.logsDir, "installer-fresh-shell.log"), }); @@ -432,12 +436,47 @@ export async function runInstallerFreshSuite( logLanePhase(lane, "windows-browser-override-import"); browserOverrideImportStatus = await runInstalledBrowserOverrideImportSmoke({ lane, - env, + env: installerEnv, prefixDir: resolveInstalledPrefixDirFromCliPath(freshShell.cliPath), logPath: join(params.logsDir, "installer-fresh-windows-browser-override-import.log"), }); } + // Host services must use the runner account's real default identity. Keep the + // public installer isolated, then switch only the managed-service lifecycle. + const env = resolveManagedGatewayInstallerEnv({ + env: installerEnv, + enabled: usesManagedGateway, + }); + if (usesManagedGateway) { + const accountHome = env.HOME; + if (!accountHome) { + throw new Error("Managed installer service checks require the host account home."); + } + managedHostLease.current = acquireManagedGatewayInstallerHostLease(accountHome); + assertManagedGatewayInstallerHostAvailable({ + accountHome, + serviceInstalled: false, + }); + const serviceStatus = await runInstalledCli({ + cliPath: freshShell.cliPath, + args: ["gateway", "status", "--json", "--no-probe"], + env, + cwd: lane.homeDir, + logPath: join(params.logsDir, "installer-fresh-gateway-preflight.log"), + timeoutMs: 2 * 60 * 1000, + check: false, + }); + assertManagedGatewayInstallerHostAvailable({ + accountHome, + serviceInstalled: parseManagedGatewayServiceInstalled(serviceStatus), + pathExists: () => false, + }); + managedHostOwned = true; + managedHostEnv = env; + managedHostCliPath = freshShell.cliPath; + } + // Hold the configured port through onboarding and model setup so another runner process // cannot claim it before the manual gateway starts. Release immediately before spawn. const gatewayPortReservation = usesManagedGateway @@ -513,7 +552,9 @@ export async function runInstallerFreshSuite( logPath: join(params.logsDir, "installer-fresh-gateway.log"), }); manualGateway.current = gateway; - cleanup.push(() => stopGateway(manualGateway.current)); + if (!usesManagedGateway) { + cleanup.push(() => stopGateway(manualGateway.current)); + } logLanePhase(lane, "gateway-status"); await waitForInstalledGateway({ lane, @@ -563,9 +604,68 @@ export async function runInstallerFreshSuite( discordStatus, agentOutput: trimForSummary(agent.stdout), }; - } finally { - await runCleanup(cleanup); + }; + + let result: Awaited> | undefined; + let runError: Error | undefined; + try { + result = await run(); + } catch (error) { + runError = error instanceof Error ? error : new Error(formatError(error)); } + + let managedCleanupError: Error | undefined; + const acquiredManagedHostLease = managedHostLease.current; + if (acquiredManagedHostLease) { + let hostCleanupError: Error | undefined; + try { + if (managedHostOwned && managedHostEnv && managedHostCliPath) { + await cleanupManagedGatewayInstallerHost({ + accountHome: acquiredManagedHostLease.accountHome, + cliPath: managedHostCliPath, + env: managedHostEnv, + lane, + logsDir: params.logsDir, + manualGateway: manualGateway.current, + }); + } + } catch (error) { + hostCleanupError = error instanceof Error ? error : new Error(formatError(error)); + } + let leaseReleaseError: Error | undefined; + try { + acquiredManagedHostLease.release(); + } catch (error) { + leaseReleaseError = error instanceof Error ? error : new Error(formatError(error)); + } + if (hostCleanupError && leaseReleaseError) { + managedCleanupError = new AggregateError( + [hostCleanupError, leaseReleaseError], + "Managed-service cleanup and host-lease release both failed.", + { cause: leaseReleaseError }, + ); + } else { + managedCleanupError = hostCleanupError ?? leaseReleaseError; + } + } + await runCleanup(cleanup); + if (managedCleanupError && runError) { + throw new AggregateError( + [runError, managedCleanupError], + "Installer release check and managed-service cleanup both failed.", + { cause: managedCleanupError }, + ); + } + if (managedCleanupError) { + throw managedCleanupError; + } + if (runError) { + throw runError; + } + if (!result) { + throw new Error("Installer release check completed without a result."); + } + return result; } export async function runDevUpdateSuite( @@ -816,3 +916,193 @@ function buildInstallerEnv( [providerMeta.secretEnv]: providerSecretValue, }; } + +export function resolveManagedGatewayInstallerEnv(params: { + env: NodeJS.ProcessEnv; + enabled: boolean; + accountHome?: string; + hostEnv?: NodeJS.ProcessEnv; +}): NodeJS.ProcessEnv { + if (!params.enabled) { + return params.env; + } + const accountHome = params.accountHome ?? userInfo().homedir; + const hostEnv = params.hostEnv ?? process.env; + const env: NodeJS.ProcessEnv = { + ...params.env, + HOME: accountHome, + USERPROFILE: accountHome, + APPDATA: hostEnv.APPDATA, + LOCALAPPDATA: hostEnv.LOCALAPPDATA, + }; + const isolatedIdentityKeys = new Set( + [ + "OPENCLAW_HOME", + "OPENCLAW_PROFILE", + "OPENCLAW_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_WINDOWS_TASK_NAME", + "OPENCLAW_TASK_SCRIPT_NAME", + "OPENCLAW_TASK_SCRIPT", + "OPENCLAW_SERVICE_KIND", + ].map((key) => key.toUpperCase()), + ); + // Windows environment keys are case-insensitive. Remove every casing variant + // so the installed CLI cannot inherit the isolated lane identity. + for (const key of Object.keys(env)) { + if (isolatedIdentityKeys.has(key.toUpperCase())) { + delete env[key]; + } + } + return env; +} + +export function parseManagedGatewayServiceInstalled(result: CommandResult): boolean { + if (result.exitCode !== 0) { + throw new Error(`Managed gateway preflight failed with exit code ${result.exitCode}.`); + } + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout); + } catch { + throw new Error("Managed gateway preflight did not return JSON status."); + } + // The managed installer lane is Windows-only. Its status `loaded` field is backed by + // isScheduledTaskInstalled, which covers both the registered task and Startup fallback. + const installed = + parsed && typeof parsed === "object" && "service" in parsed + ? (parsed.service as { loaded?: unknown }).loaded + : undefined; + if (typeof installed !== "boolean") { + throw new Error("Managed gateway preflight omitted service.loaded."); + } + return installed; +} + +export function assertManagedGatewayInstallerHostAvailable(params: { + accountHome: string; + serviceInstalled: boolean; + pathExists?: (path: string) => boolean; +}): void { + const pathExists = params.pathExists ?? existsSync; + const occupiedStateDirs = [".openclaw", ".clawdbot"] + .map((name) => join(params.accountHome, name)) + .filter((path) => pathExists(path)); + if (params.serviceInstalled || occupiedStateDirs.length > 0) { + throw new Error( + "Managed installer service checks require a pristine host account with no OpenClaw service or state.", + ); + } +} + +type ManagedGatewayInstallerHostLease = { + accountHome: string; + release: () => void; +}; + +export function acquireManagedGatewayInstallerHostLease( + accountHome: string, +): ManagedGatewayInstallerHostLease { + const lockDir = join(accountHome, ".openclaw-release-check.lock"); + try { + mkdirSync(lockDir); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") { + throw new Error( + "Managed installer service checks require exclusive access to the host account; another check or stale lease is present.", + { cause: error }, + ); + } + throw error; + } + try { + writeFileSync( + join(lockDir, "owner.json"), + `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`, + "utf8", + ); + } catch (error) { + rmSync(lockDir, { recursive: true, force: true }); + throw error; + } + let released = false; + return { + accountHome, + release: () => { + if (released) { + return; + } + rmSync(lockDir, { recursive: true }); + released = true; + }, + }; +} + +async function cleanupManagedGatewayInstallerHost(params: { + accountHome: string; + cliPath: string; + env: NodeJS.ProcessEnv; + lane: LaneState; + logsDir: string; + manualGateway: GatewayHandle | null; +}): Promise { + const cleanupErrors: Error[] = []; + try { + await stopGateway(params.manualGateway); + } catch (error) { + cleanupErrors.push(error instanceof Error ? error : new Error(formatError(error))); + } + + let serviceRemoved = false; + try { + const uninstallResult = await runInstalledCli({ + cliPath: params.cliPath, + args: ["gateway", "uninstall"], + env: params.env, + cwd: params.lane.homeDir, + logPath: join(params.logsDir, "installer-fresh-gateway-uninstall.log"), + timeoutMs: 2 * 60 * 1000, + check: false, + }); + if (uninstallResult.exitCode === 0) { + serviceRemoved = true; + } else { + const statusResult = await runInstalledCli({ + cliPath: params.cliPath, + args: ["gateway", "status", "--json", "--no-probe"], + env: params.env, + cwd: params.lane.homeDir, + logPath: join(params.logsDir, "installer-fresh-gateway-cleanup-status.log"), + timeoutMs: 2 * 60 * 1000, + check: false, + }); + if (parseManagedGatewayServiceInstalled(statusResult)) { + throw new Error( + `Managed gateway uninstall failed with exit code ${uninstallResult.exitCode}; the service remains installed.`, + ); + } + serviceRemoved = true; + } + } catch (error) { + cleanupErrors.push(error instanceof Error ? error : new Error(formatError(error))); + } + + if (serviceRemoved) { + try { + rmSync(join(params.accountHome, ".openclaw"), { recursive: true, force: true }); + rmSync(join(params.accountHome, ".clawdbot"), { recursive: true, force: true }); + } catch (error) { + cleanupErrors.push(error instanceof Error ? error : new Error(formatError(error))); + } + } + + const firstCleanupError = cleanupErrors[0]; + if (cleanupErrors.length === 1 && firstCleanupError) { + throw firstCleanupError; + } + if (cleanupErrors.length > 1) { + throw new AggregateError(cleanupErrors, "Managed-service cleanup failed.", { + cause: cleanupErrors.at(-1), + }); + } +} diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index f5dd3a3c518c..8c612c746a80 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -2969,6 +2969,12 @@ grep -Fxq preserved "$TMPDIR/caller-fd" expectTextToIncludeAll(runner, [ "docker_e2e_print_log /tmp/openclaw-codex-plugin-pack.log", + "scripts/e2e/lib/plugins/npm-registry-server.mjs", + 'CODEX_PLUGIN_SPEC="npm:${CODEX_PLUGIN_REGISTRY_PACKAGE}@${CODEX_PLUGIN_REGISTRY_VERSION}"', + 'export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$registry_port_file")"', + "trap cleanup_scenario EXIT", + 'openclaw_e2e_stop_process "${registry_pid:-}"', + 'if [ "$status" -ne 0 ] && [ "$debug_logs_dumped" -eq 0 ]; then', "assert-agent-error", "assert-followthrough", "followthrough-turn.mjs", @@ -2979,10 +2985,22 @@ grep -Fxq preserved "$TMPDIR/caller-fd" '--timeout "$AGENT_TURN_TIMEOUT_SECONDS"', ]); expect(runner).not.toContain("cat /tmp/openclaw-codex-plugin-pack.log"); + expect(runner).not.toContain('CODEX_PLUGIN_SPEC="npm-pack:$container_path"'); + expect(runner).not.toContain("trap 'openclaw_e2e_stop_process \"${registry_pid:-}\"' EXIT"); expect(runner).not.toContain("final=false"); expect(runner).not.toContain("--timeout 420"); }); + it("prints the OpenAI chat-tools gateway log when startup exits early", () => { + const scenario = readFileSync(OPENAI_CHAT_TOOLS_SCENARIO_PATH, "utf8"); + + expectTextToIncludeAll(scenario, [ + 'if ! kill -0 "$gateway_pid" 2>/dev/null', + 'echo "gateway exited before listening" >&2', + 'openclaw_e2e_print_log "$GATEWAY_LOG" >&2', + ]); + }); + it("writes the packaged Codex follow-through result independently of stdout logs", () => { const workDir = tempDirs.make("openclaw-codex-followthrough-"); const packageRoot = join(workDir, "package"); @@ -4413,7 +4431,7 @@ heartbeat_elapsed="\${BASH_REMATCH[1]}" scenario.match(/unset OPENCLAW_HOME OPENCLAW_STATE_DIR OPENCLAW_CONFIG_PATH/gu), ).toHaveLength(1); expect(scenario.match(/export USERPROFILE="\$account_home"/gu)).toHaveLength(1); - expect(scenario.match(/^ use_default_service_identity$/gmu)).toHaveLength(3); + expect(scenario.match(/^ {2}use_default_service_identity$/gmu)).toHaveLength(3); expect(scenario).not.toMatch(/^\s*if ! timeout "\$command_timeout"/mu); }); diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index 8ff75d6c9dc0..a727bed568bd 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -19,6 +19,7 @@ import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; import { agentOutputHasExpectedOkMarker, + acquireManagedGatewayInstallerHostLease, buildCrossOsDiscordRoundtripNonces, buildCrossOsReleaseAgentSessionId, buildCrossOsReleaseSmokePluginAllowlist, @@ -31,6 +32,7 @@ import { buildInstalledBrowserOverrideImportProbeScript, buildNpmGlobalInstallArgs, appendLatestNpmDebugLogTail, + assertManagedGatewayInstallerHostAvailable, buildGatewayStopArgsFromHelpText, buildGatewayStatusArgsFromHelpText, buildInstallerSmokeScript, @@ -65,6 +67,7 @@ import { parsePositiveIntegerEnv, parseCrossOsSuiteFilter, parseArgs, + parseManagedGatewayServiceInstalled, packageHasScript, readInstalledVersion, readBoundedCrossOsResponseText, @@ -79,6 +82,7 @@ import { resolveInstalledPackageRootFromCliPath, resolveNpmPackTarballFileName, resolveNpmDebugLogDirs, + resolveManagedGatewayInstallerEnv, resolvePackDestinationTarball, resolvePackageCandidatePackCommand, resolveProviderConfig, @@ -178,6 +182,117 @@ async function withTempDirAsync(prefix: string, run: (dir: string) => Promise } describe("scripts/openclaw-cross-os-release-checks", () => { + it("uses the host account identity for managed installer services", () => { + const env = resolveManagedGatewayInstallerEnv({ + env: { + HOME: "C:\\temp\\lane", + USERPROFILE: "C:\\temp\\lane", + APPDATA: "C:\\temp\\lane\\AppData\\Roaming", + LOCALAPPDATA: "C:\\temp\\lane\\AppData\\Local", + OPENCLAW_HOME: "C:\\temp\\lane", + OPENCLAW_PROFILE: "work", + OPENCLAW_STATE_DIR: "C:\\temp\\lane\\.openclaw", + OPENCLAW_CONFIG_PATH: "C:\\temp\\lane\\.openclaw\\openclaw.json", + OPENCLAW_WINDOWS_TASK_NAME: "OpenClaw Gateway (work)", + OPENCLAW_TASK_SCRIPT_NAME: "work.cmd", + OPENCLAW_TASK_SCRIPT: "C:\\temp\\work.cmd", + OPENCLAW_SERVICE_KIND: "node", + OpenClaw_Home: "C:\\temp\\case-variant", + openclaw_config_path: "C:\\temp\\case-variant\\openclaw.json", + OPENAI_API_KEY: "secret", + }, + enabled: true, + accountHome: "C:\\Users\\runneradmin", + hostEnv: { + APPDATA: "C:\\Users\\runneradmin\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\runneradmin\\AppData\\Local", + }, + }); + + expect(env).toMatchObject({ + HOME: "C:\\Users\\runneradmin", + USERPROFILE: "C:\\Users\\runneradmin", + APPDATA: "C:\\Users\\runneradmin\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\runneradmin\\AppData\\Local", + OPENAI_API_KEY: "secret", + }); + expect(env.OPENCLAW_HOME).toBeUndefined(); + expect(env.OPENCLAW_PROFILE).toBeUndefined(); + expect(env.OPENCLAW_STATE_DIR).toBeUndefined(); + expect(env.OPENCLAW_CONFIG_PATH).toBeUndefined(); + expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBeUndefined(); + expect(env.OPENCLAW_TASK_SCRIPT_NAME).toBeUndefined(); + expect(env.OPENCLAW_TASK_SCRIPT).toBeUndefined(); + expect(env.OPENCLAW_SERVICE_KIND).toBeUndefined(); + expect( + Object.keys(env).filter((key) => + [ + "OPENCLAW_HOME", + "OPENCLAW_PROFILE", + "OPENCLAW_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "OPENCLAW_WINDOWS_TASK_NAME", + "OPENCLAW_TASK_SCRIPT_NAME", + "OPENCLAW_TASK_SCRIPT", + "OPENCLAW_SERVICE_KIND", + ].includes(key.toUpperCase()), + ), + ).toEqual([]); + }); + + it("keeps isolated installer state when no managed service is used", () => { + const env = { OPENCLAW_HOME: "/tmp/openclaw-installer" }; + + expect(resolveManagedGatewayInstallerEnv({ env, enabled: false })).toBe(env); + }); + + it("fails closed before borrowing an occupied managed-service account", () => { + expect(() => + assertManagedGatewayInstallerHostAvailable({ + accountHome: "C:\\Users\\runneradmin", + serviceInstalled: true, + pathExists: () => false, + }), + ).toThrow(/pristine host account/); + expect(() => + assertManagedGatewayInstallerHostAvailable({ + accountHome: "C:\\Users\\runneradmin", + serviceInstalled: false, + pathExists: (path) => path.endsWith(".openclaw"), + }), + ).toThrow(/pristine host account/); + }); + + it("requires a structured clean-service preflight result", () => { + expect( + parseManagedGatewayServiceInstalled({ + exitCode: 0, + stdout: JSON.stringify({ service: { loaded: false } }), + stderr: "", + }), + ).toBe(false); + expect(() => + parseManagedGatewayServiceInstalled({ + exitCode: 1, + stdout: "", + stderr: "status failed", + }), + ).toThrow(/exit code 1/); + }); + + it("holds an exclusive managed-service host lease until release", () => { + withTempDir("openclaw-managed-host-", (accountHome) => { + const lease = acquireManagedGatewayInstallerHostLease(accountHome); + + expect(() => acquireManagedGatewayInstallerHostLease(accountHome)).toThrow( + /exclusive access/, + ); + lease.release(); + const replacement = acquireManagedGatewayInstallerHostLease(accountHome); + replacement.release(); + }); + }); + it("keeps dashboard smoke patient enough for cold packaged gateway startup", () => { expect(CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS).toBeGreaterThanOrEqual(120_000); expect(CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS).toBeGreaterThanOrEqual(10_000); @@ -1000,12 +1115,12 @@ describe("scripts/openclaw-cross-os-release-checks", () => { }); it("can rebuild the Windows PATH with or without current-process entries", () => { - expect(buildWindowsPathBootstrapScript()).toContain("@($userPath, $machinePath, $env:Path)"); + expect(buildWindowsPathBootstrapScript()).toContain("@($env:Path, $userPath, $machinePath)"); const persistedOnlyScript = buildWindowsPathBootstrapScript({ includeCurrentProcessPath: false, }); expect(persistedOnlyScript).toContain("@($userPath, $machinePath)"); - expect(persistedOnlyScript).not.toContain("@($userPath, $machinePath, $env:Path)"); + expect(persistedOnlyScript).not.toContain("@($env:Path, $userPath, $machinePath)"); }); it("prefers the freshly installed Windows CLI under npm's prefix before PATH lookup", () => {