mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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 \
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user