Merge remote-tracking branch 'origin/main' into release/2026.4.25

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Peter Steinberger
2026-04-26 16:24:13 +01:00
13 changed files with 425 additions and 39 deletions

View File

@@ -4,6 +4,31 @@ Docs: https://docs.openclaw.ai
## Unreleased
### Fixes
- Gateway/Bonjour: keep @homebridge/ciao cancellation handlers registered across advertiser restarts so late probing cancellations cannot crash Linux and other mDNS-churned gateways. Thanks @codex.
- Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`. Thanks @codex.
- Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet.
- Plugins/compat: inventory doctor-side deprecation migrations separately from runtime plugin compatibility so release sweeps preserve needed repairs while enforcing dated removal windows. Thanks @vincentkoc.
- Plugins/compat: add missing dated compatibility records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims. Thanks @vincentkoc.
- Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries. Thanks @codex.
- Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds. Thanks @codex.
- Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries. Thanks @codex.
- Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00.
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans. Thanks @codex.
## 2026.4.26
### Fixes
- Plugins/CLI: let flag-driven `openclaw channels add` install the selected channel plugin from its default source without opening an interactive prompt, fixing published npm Telegram setup in stdin-closed automation. Thanks @codex.
- Onboarding/setup: keep first-run config reads, plugin compatibility notices, and post-model sanity checks on cold metadata paths unless the user chooses to browse all models, avoiding full plugin/runtime catalog work between prompts. Thanks @shakkernerd.
- Onboarding/auth: run manifest-owned provider auth choices through scoped setup providers so selecting OpenAI Codex browser/device auth no longer loads every provider runtime before OAuth starts. Thanks @shakkernerd.
- Onboarding/auth: keep the post-auth default-model policy lookup on manifest/setup metadata so the next prompt appears without loading broad provider runtime. Thanks @shakkernerd.
- Onboarding/models: keep skip-auth and provider-scoped model picker prompts off the full global model catalog path, and cache provider catalog hook resolution so setup no longer stalls after auth on large plugin registries. Thanks @shakkernerd.
- Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create.
- Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse.
## 2026.4.25
### Highlights

View File

@@ -490,6 +490,8 @@ describe("gateway bonjour advertiser", () => {
const stateRef = { value: "announcing" };
const events: string[] = [];
const cleanupException = vi.fn();
const cleanupRejection = vi.fn();
let advertiseCount = 0;
const destroy = vi.fn().mockImplementation(async () => {
events.push("destroy");
@@ -505,6 +507,8 @@ describe("gateway bonjour advertiser", () => {
return Promise.resolve();
});
mockCiaoService({ advertise, destroy, stateRef });
registerUncaughtExceptionHandler.mockImplementation(() => cleanupException);
registerUnhandledRejectionHandler.mockImplementation(() => cleanupRejection);
const started = await startAdvertiser({
gatewayPort: 18789,
@@ -513,6 +517,8 @@ describe("gateway bonjour advertiser", () => {
expect(createService).toHaveBeenCalledTimes(1);
expect(advertise).toHaveBeenCalledTimes(1);
expect(registerUncaughtExceptionHandler).toHaveBeenCalledTimes(1);
expect(registerUnhandledRejectionHandler).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(15_000);
@@ -521,11 +527,15 @@ describe("gateway bonjour advertiser", () => {
expect(advertise).toHaveBeenCalledTimes(2);
expect(destroy).toHaveBeenCalledTimes(1);
expect(shutdown).not.toHaveBeenCalled();
expect(cleanupException).not.toHaveBeenCalled();
expect(cleanupRejection).not.toHaveBeenCalled();
expect(events).toEqual(["advertise:1", "destroy", "advertise:2"]);
await started.stop();
expect(destroy).toHaveBeenCalledTimes(2);
expect(shutdown).toHaveBeenCalledTimes(1);
expect(cleanupException).toHaveBeenCalledTimes(1);
expect(cleanupRejection).toHaveBeenCalledTimes(1);
});
it("treats probing-to-announcing churn as one unhealthy window", async () => {

View File

@@ -50,8 +50,6 @@ type CiaoModule = {
type BonjourCycle = {
responder: BonjourResponder;
services: Array<{ label: string; svc: BonjourService }>;
cleanupUncaughtException?: () => void;
cleanupUnhandledRejection?: () => void;
};
type ServiceStateTracker = {
@@ -179,6 +177,18 @@ export async function startGatewayBonjourAdvertiser(
const { getResponder, Protocol } = await loadCiaoModule();
const restoreConsoleLog = installCiaoConsoleNoiseFilter();
let requestCiaoRecovery: ((classification: CiaoProcessErrorClassification) => void) | undefined;
let cleanupUnhandledRejection: (() => void) | undefined;
let cleanupUncaughtException: (() => void) | undefined;
let processHandlersCleaned = false;
function cleanupProcessHandlers() {
if (processHandlersCleaned) {
return;
}
processHandlersCleaned = true;
cleanupUncaughtException?.();
cleanupUnhandledRejection?.();
}
const handleCiaoProcessError = (reason: unknown): boolean => {
const classification = classifyCiaoProcessError(reason);
@@ -196,6 +206,8 @@ export async function startGatewayBonjourAdvertiser(
}
return true;
};
cleanupUnhandledRejection = deps.registerUnhandledRejectionHandler?.(handleCiaoProcessError);
cleanupUncaughtException = deps.registerUncaughtExceptionHandler?.(handleCiaoProcessError);
try {
const hostnameRaw = process.env.OPENCLAW_MDNS_HOSTNAME?.trim() || "openclaw";
@@ -259,16 +271,7 @@ export async function startGatewayBonjourAdvertiser(
svc: gateway as unknown as BonjourService,
});
const cleanupUnhandledRejection =
services.length > 0 && deps.registerUnhandledRejectionHandler
? deps.registerUnhandledRejectionHandler(handleCiaoProcessError)
: undefined;
const cleanupUncaughtException =
services.length > 0 && deps.registerUncaughtExceptionHandler
? deps.registerUncaughtExceptionHandler(handleCiaoProcessError)
: undefined;
return { responder, services, cleanupUncaughtException, cleanupUnhandledRejection };
return { responder, services };
}
async function stopCycle(cycle: BonjourCycle | null, opts?: { shutdownResponder?: boolean }) {
@@ -288,9 +291,6 @@ export async function startGatewayBonjourAdvertiser(
}
} catch {
/* ignore */
} finally {
cycle.cleanupUncaughtException?.();
cycle.cleanupUnhandledRejection?.();
}
}
@@ -483,10 +483,12 @@ export async function startGatewayBonjourAdvertiser(
}
await stopCycle(cycle, { shutdownResponder: true });
restoreConsoleLog();
cleanupProcessHandlers();
},
};
} catch (err) {
restoreConsoleLog();
cleanupProcessHandlers();
throw err;
}
}

View File

@@ -42,6 +42,7 @@ TIMEOUT_ONBOARD_S=180
TIMEOUT_AGENT_S="${OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S:-300}"
TIMEOUT_GATEWAY_S=240
PHASE_STALE_WARN_S=60
DISABLE_BONJOUR_FOR_GATEWAY=0
FRESH_MAIN_STATUS="skip"
FRESH_MAIN_VERSION="skip"
@@ -230,6 +231,11 @@ esac
API_KEY_VALUE="${!API_KEY_ENV:-}"
[[ -n "$API_KEY_VALUE" ]] || die "$API_KEY_ENV is required"
case "${OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR:-}" in
1|true|TRUE|yes|YES|on|ON)
DISABLE_BONJOUR_FOR_GATEWAY=1
;;
esac
resolve_vm_name() {
local json requested explicit
@@ -460,7 +466,18 @@ restore_snapshot() {
wait_for_guest_ready || die "guest did not become ready in $VM_NAME"
}
sync_guest_clock() {
local host_now
host_now="@$(date -u '+%s')"
guest_exec date -u -s "$host_now" >/dev/null
guest_exec hwclock --systohc >/dev/null 2>&1 || true
guest_exec timedatectl set-ntp true >/dev/null 2>&1 || true
guest_exec systemctl restart systemd-timesyncd >/dev/null 2>&1 || true
guest_exec date -u
}
bootstrap_guest() {
sync_guest_clock
guest_exec apt-get -o Acquire::Check-Date=false update
guest_exec apt-get install -y curl ca-certificates
}
@@ -725,12 +742,16 @@ EOF
}
start_gateway_background() {
local cmd api_key_value_q
local cmd api_key_value_q bonjour_env
api_key_value_q="$(shell_quote "$API_KEY_VALUE")"
bonjour_env=""
if [[ "$DISABLE_BONJOUR_FOR_GATEWAY" -eq 1 ]]; then
bonjour_env=" OPENCLAW_DISABLE_BONJOUR=1"
fi
cmd="$(cat <<EOF
pkill -f "openclaw gateway run" >/dev/null 2>&1 || true
rm -f /tmp/openclaw-parallels-linux-gateway.log
setsid sh -lc 'exec env OPENCLAW_HOME=/root OPENCLAW_STATE_DIR=/root/.openclaw OPENCLAW_CONFIG_PATH=/root/.openclaw/openclaw.json ${API_KEY_ENV}=${api_key_value_q} openclaw gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-linux-gateway.log 2>&1' >/dev/null 2>&1 < /dev/null &
setsid sh -lc 'exec env OPENCLAW_HOME=/root OPENCLAW_STATE_DIR=/root/.openclaw OPENCLAW_CONFIG_PATH=/root/.openclaw/openclaw.json${bonjour_env} ${API_KEY_ENV}=${api_key_value_q} openclaw gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-linux-gateway.log 2>&1' >/dev/null 2>&1 < /dev/null &
EOF
)"
guest_exec bash -lc "$cmd"

View File

@@ -1149,6 +1149,7 @@ run_dev_channel_update() {
rm -rf $(shell_quote "$update_root")
export PATH=$(shell_quote "$bootstrap_bin:$GUEST_EXEC_PATH")
/usr/bin/env NODE_OPTIONS=--max-old-space-size=4096 \
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 \
$GUEST_NODE_BIN $GUEST_OPENCLAW_ENTRY update --channel dev --yes --json
EOF
)" "$update_log" "$update_done" "$TIMEOUT_UPDATE_DEV_S" "$update_runner"

View File

@@ -1944,7 +1944,7 @@ if platform_enabled windows; then
fi
if platform_enabled linux; then
bash "$ROOT_DIR/scripts/e2e/parallels-linux-smoke.sh" \
OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR=1 bash "$ROOT_DIR/scripts/e2e/parallels-linux-smoke.sh" \
--mode fresh \
--provider "$PROVIDER" \
--model "$MODEL_ID" \

View File

@@ -614,6 +614,150 @@ EOF
)"
}
guest_run_agent_turn_process() {
local env_name_q env_value_q runner_basename runner_script_path runner_url runner_url_q
local runner_name stdout_name stderr_name done_name
local start_seconds poll_deadline startup_checked state_rc log_rc done_rc
local agent_combined done_status launcher_state
env_name_q="$(ps_single_quote "$API_KEY_ENV")"
env_value_q="$(ps_single_quote "$API_KEY_VALUE")"
runner_basename="openclaw-parallels-agent-runner-$RANDOM-$RANDOM.ps1"
runner_script_path="$MAIN_TGZ_DIR/$runner_basename"
runner_url="http://$HOST_IP:$HOST_PORT/$runner_basename"
runner_url_q="$(ps_single_quote "$runner_url")"
runner_name="openclaw-parallels-agent-$RANDOM-$RANDOM.ps1"
stdout_name="openclaw-parallels-agent-$RANDOM-$RANDOM.out.log"
stderr_name="openclaw-parallels-agent-$RANDOM-$RANDOM.err.log"
done_name="openclaw-parallels-agent-$RANDOM-$RANDOM.done"
start_seconds="$SECONDS"
poll_deadline=$((SECONDS + TIMEOUT_AGENT_S + 60))
startup_checked=0
cat >"$runner_script_path" <<'EOF'
param(
[string]$StdoutPath,
[string]$StderrPath,
[string]$DonePath,
[string]$EnvName,
[string]$EnvValue
)
$ErrorActionPreference = 'Continue'
try {
if ($EnvName -ne '') {
Set-Item -Path ('Env:' + $EnvName) -Value $EnvValue
}
$node = Join-Path $env:ProgramFiles 'nodejs\node.exe'
if (-not (Test-Path $node)) {
$node = 'node'
}
$entry = Join-Path $env:APPDATA 'npm\node_modules\openclaw\openclaw.mjs'
& $node $entry agent --local --agent main --session-id 'parallels-windows-smoke' --message 'Reply with exact ASCII text OK only.' --json > $StdoutPath 2> $StderrPath
Set-Content -Path $DonePath -Value ([string]$LASTEXITCODE)
exit $LASTEXITCODE
} catch {
$_ | Out-String | Set-Content -Path $StderrPath
Set-Content -Path $DonePath -Value '1'
exit 1
}
EOF
guest_powershell_poll 20 "$(cat <<EOF
\$runner = Join-Path \$env:TEMP '$runner_name'
\$stdout = Join-Path \$env:TEMP '$stdout_name'
\$stderr = Join-Path \$env:TEMP '$stderr_name'
\$done = Join-Path \$env:TEMP '$done_name'
Remove-Item \$runner, \$stdout, \$stderr, \$done -Force -ErrorAction SilentlyContinue
curl.exe -fsSL '${runner_url_q}' -o \$runner
Start-Process powershell.exe -ArgumentList @(
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
\$runner,
'-StdoutPath',
\$stdout,
'-StderrPath',
\$stderr,
'-DonePath',
\$done,
'-EnvName',
'${env_name_q}',
'-EnvValue',
'${env_value_q}'
) -WindowStyle Hidden | Out-Null
EOF
)"
while true; do
set +e
agent_combined="$(
guest_powershell_poll 20 "\$stdout = Join-Path \$env:TEMP '$stdout_name'; \$stderr = Join-Path \$env:TEMP '$stderr_name'; \$out = ''; \$err = ''; if (Test-Path \$stdout) { \$out = Get-Content -Path \$stdout -Raw -ErrorAction SilentlyContinue }; if (Test-Path \$stderr) { \$err = Get-Content -Path \$stderr -Raw -ErrorAction SilentlyContinue }; \$out + [Environment]::NewLine + \$err"
)"
log_rc=$?
set -e
if [[ $log_rc -eq 0 ]] && printf '%s\n' "$agent_combined" | grep -Eq '"finalAssistant(Raw|Visible)Text":[[:space:]]*"OK"'; then
printf '%s\n' "$agent_combined"
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; Remove-Item \$runner, \$done -Force -ErrorAction SilentlyContinue"
return 0
fi
set +e
done_status="$(
guest_powershell_poll 20 "\$done = Join-Path \$env:TEMP '$done_name'; if (Test-Path \$done) { (Get-Content \$done -Raw).Trim() }"
)"
done_rc=$?
set -e
if [[ $done_rc -eq 0 && -n "$done_status" ]]; then
if [[ $log_rc -eq 0 && -n "$agent_combined" ]]; then
printf '%s\n' "$agent_combined"
fi
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; Remove-Item \$runner, \$done -Force -ErrorAction SilentlyContinue"
warn "openclaw agent finished without OK response (exit $done_status)"
return 1
fi
if [[ "$startup_checked" -eq 0 && $((SECONDS - start_seconds)) -ge 20 ]]; then
set +e
launcher_state="$(
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$stdout = Join-Path \$env:TEMP '$stdout_name'; \$stderr = Join-Path \$env:TEMP '$stderr_name'; \$done = Join-Path \$env:TEMP '$done_name'; \$currentPid = \$PID; \$process = Get-CimInstance Win32_Process | Where-Object { \$_.ProcessId -ne \$currentPid -and ((\$_.CommandLine -like '*$runner_name*') -or (\$_.CommandLine -like '*openclaw.mjs*agent*parallels-windows-smoke*')) } | Select-Object -First 1; 'runner=' + (Test-Path \$runner) + ' stdout=' + (Test-Path \$stdout) + ' stderr=' + (Test-Path \$stderr) + ' done=' + (Test-Path \$done) + ' process=' + [bool]\$process"
)"
state_rc=$?
set -e
launcher_state="${launcher_state//$'\r'/}"
startup_checked=1
if [[ $state_rc -eq 0 && "$launcher_state" == *"runner=False"* && "$launcher_state" == *"stdout=False"* && "$launcher_state" == *"stderr=False"* && "$launcher_state" == *"done=False"* && "$launcher_state" == *"process=False"* ]]; then
warn "windows agent helper failed to materialize guest files"
return 1
fi
fi
if (( SECONDS >= poll_deadline )); then
if [[ $log_rc -eq 0 && -n "$agent_combined" ]]; then
printf '%s\n' "$agent_combined"
fi
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; Remove-Item \$runner, \$done -Force -ErrorAction SilentlyContinue"
warn "openclaw agent timed out after $TIMEOUT_AGENT_S seconds"
return 1
fi
sleep 2
done
}
stop_gateway_processes_for_local_agent_turn() {
guest_powershell_poll 20 "$(cat <<'EOF'
$processes = Get-CimInstance Win32_Process | Where-Object {
$_.CommandLine -match 'openclaw(.cmd|.mjs|\\dist\\index.js)?.*gateway'
}
foreach ($process in $processes) {
Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue
}
if ($processes) {
"Stopped OpenClaw gateway processes for local agent turn"
}
EOF
)"
}
ensure_vm_running_for_retry() {
local status
status="$(prlctl status "$VM_NAME" 2>/dev/null || true)"
@@ -1335,6 +1479,7 @@ PY
}
install_latest_release() {
local expected_version="${1:-}"
local install_url_q version_flag_q
local script_url
local runner_name log_name done_name done_status launcher_state guest_log
@@ -1429,6 +1574,13 @@ PY
if ! stream_windows_latest_install_log; then
warn "windows latest install helper log drain failed after completion"
fi
if [[ "$done_status" != "0" ]] && recover_successful_published_install "$log_state_path" "$expected_version"; then
rm -f "$log_state_path"
return 0
fi
if [[ "$done_status" != "0" ]]; then
dump_latest_guest_npm_log_tail "windows latest install npm debug tail" || true
fi
rm -f "$log_state_path"
[[ "$done_status" == "0" ]]
return $?
@@ -1452,6 +1604,7 @@ PY
if ! stream_windows_latest_install_log; then
warn "windows latest install helper log drain failed after timeout"
fi
dump_latest_guest_npm_log_tail "windows latest install npm debug tail" || true
warn "windows latest install helper timed out waiting for done file"
rm -f "$log_state_path"
return 1
@@ -1460,6 +1613,34 @@ PY
done
}
recover_successful_published_install() {
local log_path="$1"
local expected_version="$2"
local installed_version rc
[[ -n "$expected_version" ]] || return 1
[[ -f "$log_path" ]] || return 1
grep -Fq "OpenClaw installed successfully" "$log_path" || return 1
grep -Fq "Cannot process argument transformation on parameter 'Succeeded'" "$log_path" || return 1
grep -Fq "System.Object[]" "$log_path" || return 1
grep -Fq "System.Boolean" "$log_path" || return 1
set +e
installed_version="$(guest_run_openclaw "" "" "--version")"
rc=$?
set -e
installed_version="${installed_version//$'\r'/}"
if [[ $rc -ne 0 || "$installed_version" != *"OpenClaw $expected_version "* ]]; then
warn "published installer reported known success-return bug, but installed version did not match $expected_version"
printf '%s\n' "$installed_version" >&2
return 1
fi
warn "published installer reported known success-return bug after installing $expected_version; continuing"
printf '%s\n' "$installed_version"
return 0
}
install_main_tgz() {
local host_ip="$1"
local temp_name="$2"
@@ -2397,9 +2578,9 @@ New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
'@ | Set-Content -Path (Join-Path $stateDir 'workspace-state.json') -Encoding UTF8
Remove-Item (Join-Path $workspace 'BOOTSTRAP.md') -Force -ErrorAction SilentlyContinue
EOF
)"
guest_run_openclaw "$API_KEY_ENV" "$API_KEY_VALUE" \
agent --agent main --session-id parallels-windows-smoke --message "Reply with exact ASCII text OK only." --json
)"
stop_gateway_processes_for_local_agent_turn
guest_run_agent_turn_process
}
capture_latest_ref_failure() {
@@ -2448,7 +2629,7 @@ run_fresh_main_lane() {
run_upgrade_lane() {
local snapshot_id="$1"
local host_ip="$2"
local baseline_version
local baseline_version baseline_install_phase
phase_run "upgrade.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id" || return $?
phase_run "upgrade.wait-for-user" "$TIMEOUT_SNAPSHOT_S" wait_for_guest_ready || return $?
if ! phase_run "upgrade.ensure-git" "$TIMEOUT_GIT_SETUP_S" ensure_guest_git "$host_ip"; then
@@ -2461,8 +2642,13 @@ run_upgrade_lane() {
phase_run "upgrade.verify-baseline-package-version" "$TIMEOUT_VERIFY_S" verify_target_version || return $?
else
baseline_version="$(baseline_install_version)"
phase_run "upgrade.install-baseline" "$TIMEOUT_INSTALL_S" install_latest_release || return $?
LATEST_INSTALLED_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-baseline)")"
baseline_install_phase="upgrade.install-baseline"
if ! phase_run "$baseline_install_phase" "$TIMEOUT_INSTALL_S" install_latest_release "$baseline_version"; then
phase_run "upgrade.wait-for-user-baseline-retry" "$TIMEOUT_SNAPSHOT_S" wait_for_guest_ready || return $?
phase_run "upgrade.install-baseline-retry" "$TIMEOUT_INSTALL_S" install_latest_release "$baseline_version" || return $?
baseline_install_phase="upgrade.install-baseline-retry"
fi
LATEST_INSTALLED_VERSION="$(extract_last_version "$(phase_log_path "$baseline_install_phase")")"
phase_run "upgrade.verify-baseline-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$baseline_version" || return $?
fi
if [[ "$CHECK_LATEST_REF" -eq 1 ]]; then

View File

@@ -288,10 +288,10 @@ function Install-OpenClawNpm {
"--no-audit"
)
if ($installResult.Stdout) {
Microsoft.PowerShell.Utility\Write-Output $installResult.Stdout
Microsoft.PowerShell.Utility\Write-Host $installResult.Stdout
}
if ($installResult.Stderr) {
Microsoft.PowerShell.Utility\Write-Output $installResult.Stderr
Microsoft.PowerShell.Utility\Write-Host $installResult.Stderr
}
if ($installResult.ExitCode -ne 0) {
Write-Host "npm install failed with exit code $($installResult.ExitCode)" -Level error

View File

@@ -710,6 +710,7 @@ describe("runGatewayUpdate", () => {
it("does not fail a good windows dev preflight only because worktree cleanup hit long paths", async () => {
await setupGitPackageManagerFixture();
const calls: string[] = [];
const cleanupTimeouts: Array<number | undefined> = [];
const upstreamSha = "upstream123";
const doctorNodePath = await resolveStableNodePath(process.execPath);
const doctorCommand = `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`;
@@ -718,7 +719,7 @@ describe("runGatewayUpdate", () => {
try {
const runCommand = async (
argv: string[],
_options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
) => {
const key = argv.join(" ");
calls.push(key);
@@ -772,6 +773,7 @@ describe("runGatewayUpdate", () => {
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
preflightPrefixPattern.test(key)
) {
cleanupTimeouts.push(options?.timeoutMs);
return {
stdout: "",
stderr: "error: failed to delete worktree: Filename too long",
@@ -798,6 +800,7 @@ describe("runGatewayUpdate", () => {
expect(result.status).toBe("ok");
const cleanupStep = result.steps.find((step) => step.name === "preflight cleanup");
expect(cleanupStep?.exitCode).toBe(0);
expect(cleanupTimeouts[0]).toBeLessThanOrEqual(60_000);
expect(cleanupStep?.stderrTail ?? "").toContain(
"windows fallback cleanup removed preflight tree",
);
@@ -806,6 +809,101 @@ describe("runGatewayUpdate", () => {
}
});
it("falls back when dev preflight worktree cleanup times out", async () => {
await setupGitPackageManagerFixture();
const calls: string[] = [];
const cleanupTimeouts: Array<number | undefined> = [];
const upstreamSha = "upstream123";
const doctorNodePath = await resolveStableNodePath(process.execPath);
const doctorCommand = `${doctorNodePath} ${path.join(tempDir, "openclaw.mjs")} doctor --non-interactive --fix`;
const runCommand = async (
argv: string[],
options?: { env?: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number },
) => {
const key = argv.join(" ");
calls.push(key);
if (key === `git -C ${tempDir} rev-parse --show-toplevel`) {
return { stdout: tempDir, stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} rev-parse HEAD`) {
return { stdout: "abc123", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} rev-parse --abbrev-ref HEAD`) {
return { stdout: "main", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} status --porcelain -- :!dist/control-ui/`) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`) {
return { stdout: "origin/main", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} fetch --all --prune --tags`) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} rev-parse @{upstream}`) {
return { stdout: upstreamSha, stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} rev-list --max-count=10 ${upstreamSha}`) {
return { stdout: `${upstreamSha}\n`, stderr: "", code: 0 };
}
if (key === "pnpm --version") {
return { stdout: "10.0.0", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree add --detach /tmp/`) &&
key.endsWith(` ${upstreamSha}`) &&
preflightPrefixPattern.test(key)
) {
return { stdout: `HEAD is now at ${upstreamSha}`, stderr: "", code: 0 };
}
if (
key.startsWith("git -C /tmp/") &&
preflightPrefixPattern.test(key) &&
key.includes(" checkout --detach ") &&
key.endsWith(upstreamSha)
) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === "pnpm install" || key === "pnpm build" || key === "pnpm lint") {
return { stdout: "", stderr: "", code: 0 };
}
if (
key.startsWith(`git -C ${tempDir} worktree remove --force `) &&
preflightPrefixPattern.test(key)
) {
cleanupTimeouts.push(options?.timeoutMs);
return {
stdout: "",
stderr: "Command timed out after 60000ms",
code: null,
};
}
if (key === `git -C ${tempDir} worktree prune`) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === `git -C ${tempDir} rebase ${upstreamSha}`) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === doctorCommand) {
return { stdout: "", stderr: "", code: 0 };
}
if (key === "pnpm ui:build") {
return { stdout: "", stderr: "", code: 0 };
}
return { stdout: "", stderr: "", code: 0 };
};
const result = await runWithCommand(runCommand, { channel: "dev" });
expect(result.status).toBe("ok");
const cleanupStep = result.steps.find((step) => step.name === "preflight cleanup");
expect(cleanupStep?.exitCode).toBe(0);
expect(cleanupTimeouts[0]).toBeLessThanOrEqual(60_000);
expect(cleanupStep?.stderrTail ?? "").toContain("fallback cleanup removed preflight tree");
});
it("adds heap headroom to windows pnpm build steps during dev updates", async () => {
await setupGitPackageManagerFixture();
const upstreamSha = "upstream123";

View File

@@ -138,6 +138,7 @@ const CORE_PACKAGE_NAMES = new Set([DEFAULT_PACKAGE_NAME]);
const PREFLIGHT_TEMP_PREFIX =
process.platform === "win32" ? "ocu-pf-" : "openclaw-update-preflight-";
const PREFLIGHT_WORKTREE_DIRNAME = process.platform === "win32" ? "wt" : "worktree";
const PREFLIGHT_CLEANUP_TIMEOUT_MS = 60_000;
const WINDOWS_PREFLIGHT_BASE_DIR = "ocu";
const WINDOWS_BUILD_MAX_OLD_SPACE_MB = 4096;
@@ -215,10 +216,7 @@ async function removePathRecursive(target: string) {
.catch(() => {});
}
async function repairWindowsPreflightCleanup(worktreeDir: string, preflightRoot: string) {
if (process.platform !== "win32") {
return false;
}
async function repairPreflightCleanup(worktreeDir: string, preflightRoot: string) {
try {
await fs.rm(worktreeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
await fs.rm(preflightRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
@@ -938,22 +936,25 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
break;
}
} finally {
const removeStep = await runStep(
step(
const removeStep = await runStep({
...step(
"preflight cleanup",
["git", "-C", gitRoot, "worktree", "remove", "--force", worktreeDir],
gitRoot,
),
);
timeoutMs: Math.min(timeoutMs, PREFLIGHT_CLEANUP_TIMEOUT_MS),
});
if (
removeStep.exitCode !== 0 &&
(await repairWindowsPreflightCleanup(worktreeDir, preflightRoot))
(await repairPreflightCleanup(worktreeDir, preflightRoot))
) {
removeStep.exitCode = 0;
const fallbackMessage =
process.platform === "win32"
? "windows fallback cleanup removed preflight tree"
: "fallback cleanup removed preflight tree";
removeStep.stderrTail = trimLogTail(
[removeStep.stderrTail, "windows fallback cleanup removed preflight tree"]
.filter(Boolean)
.join("\n"),
[removeStep.stderrTail, fallbackMessage].filter(Boolean).join("\n"),
MAX_LOG_CHARS,
);
}

View File

@@ -610,6 +610,9 @@ function buildInstalledPluginIndex(
if (record.setupSource) {
indexRecord.setupSource = record.setupSource;
}
if (record.syntheticAuthRefs && record.syntheticAuthRefs.length > 0) {
indexRecord.syntheticAuthRefs = record.syntheticAuthRefs;
}
if (candidate?.packageName) {
indexRecord.packageName = candidate.packageName;
}

View File

@@ -110,4 +110,43 @@ describe("install.ps1 failure handling", () => {
expect(result.stdout).toContain("caught=OpenClaw installation failed with exit code 1.");
expect(result.stdout).toContain("alive-after-install");
});
runIfPowerShell("keeps npm chatter out of Main's success return value", () => {
const tempDir = harness.createTempDir("openclaw-install-ps1-");
const scriptPath = join(tempDir, "install.ps1");
const scriptWithoutEntryPoint = source.replace(
/\r?\n\$installSucceeded = Main\r?\nComplete-Install -Succeeded:\$installSucceeded\s*$/m,
"",
);
writeFileSync(
scriptPath,
[
scriptWithoutEntryPoint,
"",
"function Write-Banner { }",
"function Ensure-ExecutionPolicy { return $true }",
"function Ensure-Node { return $true }",
"function Add-ToPath { param([string]$Path) }",
"function Invoke-NativeCommandCapture {",
" param([string]$FilePath, [string[]]$Arguments)",
" return @{ ExitCode = 0; Stdout = 'npm stdout'; Stderr = 'npm stderr' }",
"}",
"$NoOnboard = $true",
"$result = Main",
"if ($result -is [array]) { throw 'Main returned an array' }",
'if ($result -ne $true) { throw "Main returned $result" }',
"",
].join("\n"),
);
chmodSync(scriptPath, 0o755);
const result = spawnSync(
powershell!,
["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath],
{ encoding: "utf8" },
);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
});
});

View File

@@ -28,7 +28,7 @@ describe("Parallels smoke model selection", () => {
expect(script, scriptPath).toContain("workspace-state.json");
expect(script, scriptPath).toContain("IDENTITY.md");
expect(script, scriptPath).toContain("BOOTSTRAP.md");
expect(script, scriptPath).toContain("--session-id parallels-");
expect(script, scriptPath).toMatch(/--session-id\s+['"]?parallels-/);
expect(script, scriptPath).toContain("agents.defaults.skipBootstrap true --strict-json");
}
});