fix(ci): restore beta release harnesses (#116596)

* test(release): model systemd ownership probes

* fix(ci): restore release Docker package probes

* fix(release): preserve Windows setup-node runtime

* fix(release): isolate Windows service validation

* fix(release): strip isolated Windows service identity
This commit is contained in:
Vincent Koc
2026-07-31 09:17:35 +08:00
committed by GitHub
parent 3a6ee79a72
commit 39156a99cd
8 changed files with 578 additions and 24 deletions
+88 -8
View File
@@ -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
@@ -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 \
+25 -1
View File
@@ -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
@@ -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
@@ -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')
+300 -10
View File
@@ -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<ReturnType<typeof run>> | 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<void> {
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),
});
}
}
+19 -1
View File
@@ -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);
});
@@ -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<T>(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", () => {