fix(e2e): escape Windows stale update import regex (#75315) thanks @steipete

Fix the Windows stale-import guard regex used by the Parallels npm update smoke path, with related maintainer-flow and regression-test cleanup preserved on a verified branch.
This commit is contained in:
clawsweeper[bot]
2026-05-02 19:10:44 -05:00
committed by GitHub
parent f30dc0aeb4
commit afe42fc977
11 changed files with 220 additions and 55 deletions

View File

@@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai
- Plugins/Codex: allow the official npm Codex plugin to install without the unsafe-install override, keep `/codex` command ownership, and cover the real npm Docker live path through managed `.openclaw/npm` dependencies plus uninstall failure proof.
- Gateway/status: add concrete service, config, listener-owner, and log collection next steps when gateway probes fail and Bonjour finds no local gateway, so frozen or port-conflict reports include the data needed for root-cause triage. Refs #49012. Thanks @vincentkoc.
- Codex harness: forward OpenClaw workspace bootstrap files such as `SOUL.md` through native Codex config instructions while leaving `AGENTS.md` to Codex project-doc discovery. Fixes #76273. Thanks @zknicker.
- Parallels/Windows update smoke: escape the stale post-swap import regex in the generated PowerShell script so expected `ERR_MODULE_NOT_FOUND` update handoffs continue to post-update health checks. (#75315)
## 2026.5.2

View File

@@ -17,6 +17,8 @@ export interface NpmUpdateScriptInput {
updateTarget: string;
}
const windowsStalePostSwapImportRegex = String.raw`node_modules\\openclaw\\dist\\[^\\]+-[A-Za-z0-9_-]+\.js`;
function posixModelProviderConfigCommands(
command: string,
modelId: string,
@@ -180,7 +182,7 @@ $updateExit = $LASTEXITCODE
$updateOutput
if ($updateExit -ne 0) {
$updateText = $updateOutput | Out-String
$stalePostSwapImport = $updateText -match 'ERR_MODULE_NOT_FOUND' -and $updateText -match 'node_modules\\openclaw\\dist\\[^\\]+-[A-Za-z0-9_-]+\\.js'
$stalePostSwapImport = $updateText -match 'ERR_MODULE_NOT_FOUND' -and $updateText -match ${psSingleQuote(windowsStalePostSwapImportRegex)}
if (-not $stalePostSwapImport) { throw "openclaw update failed with exit code $updateExit" }
Write-Host "openclaw update returned a stale post-swap module import; continuing to post-update health checks"
}

View File

@@ -159,6 +159,20 @@ validate_changelog_attribution_policy() {
node scripts/check-changelog-attributions.mjs CHANGELOG.md
}
changelog_thanks_required_for_contributor() {
local contrib="${1:-}"
local normalized
normalized=$(printf '%s' "$contrib" | tr '[:upper:]' '[:lower:]')
case "$normalized" in
""|"null"|"app/"*|"codex"|"openclaw"|"clawsweeper"|"openclaw-clawsweeper"|"clawsweeper[bot]"|"openclaw-clawsweeper[bot]"|"steipete")
return 1
;;
esac
return 0
}
validate_changelog_entry_for_pr() {
local pr="$1"
local contrib="$2"
@@ -314,7 +328,7 @@ END {
rm -f "$diff_file"
echo "changelog placement validated: PR-linked entries are appended at section tail"
if [ -n "$contrib" ] && [ "$contrib" != "null" ]; then
if changelog_thanks_required_for_contributor "$contrib"; then
local with_pr_and_thanks
with_pr_and_thanks=$(printf '%s\n' "$added_lines" | rg -in "$pr_pattern" | rg -i "thanks @$contrib" || true)
if [ -z "$with_pr_and_thanks" ]; then
@@ -325,7 +339,7 @@ END {
return 0
fi
echo "changelog validated: found PR #$pr (contributor handle unavailable, skipping thanks check)"
echo "changelog validated: found PR #$pr (no eligible human contributor handle, skipping thanks check)"
}
validate_changelog_merge_hygiene() {

View File

@@ -191,6 +191,32 @@ merge_author_email_candidates() {
"${reviewer}@users.noreply.github.com" | awk 'NF && !seen[$0]++'
}
pr_contributor_allows_human_trailers() {
local contrib="${1:-}"
local normalized
normalized=$(printf '%s' "$contrib" | tr '[:upper:]' '[:lower:]')
case "$normalized" in
""|"null"|"app/"*|"codex"|"openclaw"|"clawsweeper"|"openclaw-clawsweeper"|"clawsweeper[bot]"|"openclaw-clawsweeper[bot]"|"steipete")
return 1
;;
esac
return 0
}
resolve_contributor_coauthor_email() {
local contrib="${1:-}"
if ! pr_contributor_allows_human_trailers "$contrib"; then
return 1
fi
local contrib_id
contrib_id=$(gh api "users/$contrib" --jq .id) || return 1
printf '%s+%s@users.noreply.github.com\n' "$contrib_id" "$contrib"
}
common_repo_root() {
if command -v repo_root >/dev/null 2>&1; then
repo_root

View File

@@ -199,9 +199,11 @@ merge_run() {
local contrib_coauthor_email="${COAUTHOR_EMAIL:-}"
if [ -z "$contrib_coauthor_email" ] || [ "$contrib_coauthor_email" = "null" ]; then
local contrib_id
contrib_id=$(gh api "users/$contrib" --jq .id)
contrib_coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com"
if contrib_coauthor_email=$(resolve_contributor_coauthor_email "$contrib"); then
:
else
contrib_coauthor_email=""
fi
fi
local reviewer_email_candidates=()
@@ -218,14 +220,16 @@ merge_run() {
local reviewer_email="${reviewer_email_candidates[0]}"
local reviewer_coauthor_email="${reviewer_id}+${reviewer}@users.noreply.github.com"
cat > .local/merge-body.txt <<EOF_BODY
Merged via squash.
Prepared head SHA: $PREP_HEAD_SHA
Co-authored-by: $contrib <$contrib_coauthor_email>
Co-authored-by: $reviewer <$reviewer_coauthor_email>
Reviewed-by: @$reviewer
EOF_BODY
{
echo "Merged via squash."
echo
echo "Prepared head SHA: $PREP_HEAD_SHA"
if [ -n "$contrib_coauthor_email" ]; then
echo "Co-authored-by: $contrib <$contrib_coauthor_email>"
fi
echo "Co-authored-by: $reviewer <$reviewer_coauthor_email>"
echo "Reviewed-by: @$reviewer"
} > .local/merge-body.txt
delete_remote_pr_head_branch_after_merge() {
local head_json
@@ -347,22 +351,29 @@ EOF_BODY
local commit_body
commit_body=$(gh api repos/:owner/:repo/commits/"$merge_sha" --jq .commit.message)
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $contrib <" || { echo "Missing PR author co-author trailer"; exit 1; }
if [ -n "$contrib_coauthor_email" ]; then
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $contrib <" || { echo "Missing PR author co-author trailer"; exit 1; }
else
echo "Skipping PR author co-author trailer check for bot/app author $contrib."
fi
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $reviewer <" || { echo "Missing reviewer co-author trailer"; exit 1; }
local ok=0
local comment_output=""
local attempt
for attempt in 1 2 3; do
if comment_output=$(gh pr comment "$pr" -F - 2>&1 <<EOF_COMMENT
Merged via squash.
- Prepared head SHA: [$PREP_HEAD_SHA]($prep_sha_url)
- Merge commit: [$merge_sha]($merge_sha_url)
Thanks @$contrib!
EOF_COMMENT
); then
if comment_output=$(
{
echo "Merged via squash."
echo
echo "- Prepared head SHA: [$PREP_HEAD_SHA]($prep_sha_url)"
echo "- Merge commit: [$merge_sha]($merge_sha_url)"
if pr_contributor_allows_human_trailers "$contrib"; then
echo
echo "Thanks @$contrib!"
fi
} | gh pr comment "$pr" -F - 2>&1
); then
ok=1
break
fi

View File

@@ -163,9 +163,12 @@ prepare_push() {
if [ -z "$contrib" ]; then
contrib=$(gh pr view "$pr" --json author --jq .author.login)
fi
local contrib_id
contrib_id=$(gh api "users/$contrib" --jq .id)
local coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com"
local coauthor_email=""
if coauthor_email=$(resolve_contributor_coauthor_email "$contrib"); then
:
else
coauthor_email=""
fi
cat >> .local/prep.md <<EOF_PREP
- Gates passed and push succeeded to branch $PR_HEAD.
@@ -237,9 +240,12 @@ prepare_sync_head() {
if [ -z "$contrib" ]; then
contrib=$(gh pr view "$pr" --json author --jq .author.login)
fi
local contrib_id
contrib_id=$(gh api "users/$contrib" --jq .id)
local coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com"
local coauthor_email=""
if coauthor_email=$(resolve_contributor_coauthor_email "$contrib"); then
:
else
coauthor_email=""
fi
cat >> .local/prep.md <<EOF_PREP
- Prep head sync completed to branch $PR_HEAD.

View File

@@ -112,14 +112,20 @@ fi
# Default scan paths match CI. Override by passing `-- <paths...>`.
if (( PATHS_PASSED == 0 )); then
if (( CHANGED_ONLY )); then
mapfile -t SCAN_PATHS < <(
SCAN_PATHS=()
while IFS= read -r path; do
SCAN_PATHS+=( "$path" )
done < <(
{
git diff --name-only --diff-filter=ACMRTUXB "${OPENCLAW_OPENGREP_BASE_REF:-origin/main...HEAD}" 2>/dev/null || true
git diff --name-only --diff-filter=ACMRTUXB -- 2>/dev/null || true
git ls-files --others --exclude-standard
} | awk '/^(src|extensions|apps|packages|scripts)\// { print }' | sort -u
)
mapfile -t RULEPACK_CHANGED_PATHS < <(
RULEPACK_CHANGED_PATHS=()
while IFS= read -r path; do
RULEPACK_CHANGED_PATHS+=( "$path" )
done < <(
{
git diff --name-only --diff-filter=ACMRTUXB "${OPENCLAW_OPENGREP_BASE_REF:-origin/main...HEAD}" 2>/dev/null || true
git diff --name-only --diff-filter=ACMRTUXB -- 2>/dev/null || true
@@ -148,9 +154,11 @@ fi
echo "→ Running opengrep ($BUCKET) against $(IFS=' '; echo "${SCAN_PATHS[*]:-overridden}")" >&2
echo " Using exclusions from .semgrepignore" >&2
exec opengrep scan \
--no-strict \
--config "$CONFIG" \
--no-git-ignore \
"${EXTRA_ARGS[@]}" \
"${SCAN_PATHS[@]}"
OPENGREP_ARGS=( scan --no-strict --config "$CONFIG" --no-git-ignore )
if (( ${#EXTRA_ARGS[@]} > 0 )); then
OPENGREP_ARGS+=( "${EXTRA_ARGS[@]}" )
fi
if (( ${#SCAN_PATHS[@]} > 0 )); then
OPENGREP_ARGS+=( "${SCAN_PATHS[@]}" )
fi
exec opengrep "${OPENGREP_ARGS[@]}"

View File

@@ -8,31 +8,39 @@ let webMedia: typeof import("../../media/web-media.js");
let createImageGenerateTool: typeof import("./image-generate-tool.js").createImageGenerateTool;
let resolveImageGenerationModelConfigForTool: typeof import("./image-generate-tool.js").resolveImageGenerationModelConfigForTool;
const IMAGE_GENERATION_PROVIDER_AUTH_ENV_VARS = [
"OPENAI_API_KEY",
"OPENAI_API_KEYS",
const GENERATION_PROVIDER_ENV_VARS = [
"BYTEPLUS_API_KEY",
"COMFY_API_KEY",
"COMFY_CLOUD_API_KEY",
"DASHSCOPE_API_KEY",
"DEEPINFRA_API_KEY",
"FAL_API_KEY",
"FAL_KEY",
"GCLOUD_PROJECT",
"GEMINI_API_KEY",
"GEMINI_API_KEYS",
"GOOGLE_API_KEY",
"GOOGLE_API_KEYS",
"DEEPINFRA_API_KEY",
"FAL_KEY",
"FAL_API_KEY",
"GOOGLE_APPLICATION_CREDENTIALS",
"GOOGLE_CLOUD_API_KEY",
"GOOGLE_CLOUD_LOCATION",
"GOOGLE_CLOUD_PROJECT",
"LITELLM_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_CODE_PLAN_KEY",
"MINIMAX_CODING_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_OAUTH_TOKEN",
"MODELSTUDIO_API_KEY",
"OPENAI_API_KEY",
"OPENAI_API_KEYS",
"OPENROUTER_API_KEY",
"XAI_API_KEY",
"QWEN_API_KEY",
"RUNWAY_API_KEY",
"RUNWAYML_API_SECRET",
"TOGETHER_API_KEY",
"VYDRA_API_KEY",
] as const;
function clearImageGenerationProviderAuthEnv() {
for (const key of IMAGE_GENERATION_PROVIDER_AUTH_ENV_VARS) {
vi.stubEnv(key, "");
}
}
"XAI_API_KEY",
];
function hasStubbedImageProviderAuth(providerId: string): boolean {
if (providerId === "openai") {
@@ -243,7 +251,9 @@ describe("createImageGenerateTool", () => {
});
beforeEach(() => {
clearImageGenerationProviderAuthEnv();
for (const envVar of GENERATION_PROVIDER_ENV_VARS) {
vi.stubEnv(envVar, "");
}
});
afterEach(() => {

View File

@@ -52,6 +52,40 @@ const VIDEO_GENERATION_PROVIDER_AUTH_ENV_VARS = [
vi.mock("../../tasks/runtime-internal.js", () => taskRuntimeInternalMocks);
vi.mock("../../tasks/detached-task-runtime.js", () => taskExecutorMocks);
const GENERATION_PROVIDER_ENV_VARS = [
"BYTEPLUS_API_KEY",
"COMFY_API_KEY",
"COMFY_CLOUD_API_KEY",
"DASHSCOPE_API_KEY",
"DEEPINFRA_API_KEY",
"FAL_API_KEY",
"FAL_KEY",
"GCLOUD_PROJECT",
"GEMINI_API_KEY",
"GEMINI_API_KEYS",
"GOOGLE_API_KEY",
"GOOGLE_API_KEYS",
"GOOGLE_APPLICATION_CREDENTIALS",
"GOOGLE_CLOUD_API_KEY",
"GOOGLE_CLOUD_LOCATION",
"GOOGLE_CLOUD_PROJECT",
"LITELLM_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_CODE_PLAN_KEY",
"MINIMAX_CODING_API_KEY",
"MINIMAX_OAUTH_TOKEN",
"MODELSTUDIO_API_KEY",
"OPENAI_API_KEY",
"OPENAI_API_KEYS",
"OPENROUTER_API_KEY",
"QWEN_API_KEY",
"RUNWAY_API_KEY",
"RUNWAYML_API_SECRET",
"TOGETHER_API_KEY",
"VYDRA_API_KEY",
"XAI_API_KEY",
];
function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
@@ -118,7 +152,12 @@ function resetVideoGenerateMocks() {
}
describe("createVideoGenerateTool", () => {
beforeEach(resetVideoGenerateMocks);
beforeEach(() => {
resetVideoGenerateMocks();
for (const envVar of GENERATION_PROVIDER_ENV_VARS) {
vi.stubEnv(envVar, "");
}
});
afterEach(() => {
vi.unstubAllEnvs();

View File

@@ -28,10 +28,21 @@ describe("check-changelog-attributions", () => {
});
it("keeps PR changelog gates on the same attribution policy", () => {
const commonLib = readFileSync("scripts/pr-lib/common.sh", "utf8");
const changelogLib = readFileSync("scripts/pr-lib/changelog.sh", "utf8");
const gates = readFileSync("scripts/pr-lib/gates.sh", "utf8");
const mergeLib = readFileSync("scripts/pr-lib/merge.sh", "utf8");
const prepareCore = readFileSync("scripts/pr-lib/prepare-core.sh", "utf8");
expect(commonLib).toContain("pr_contributor_allows_human_trailers");
expect(commonLib).toContain("resolve_contributor_coauthor_email");
expect(changelogLib).toContain("node scripts/check-changelog-attributions.mjs CHANGELOG.md");
expect(changelogLib).toContain("changelog_thanks_required_for_contributor");
expect(changelogLib).toContain('"app/"*');
expect(changelogLib).toContain('"clawsweeper"');
expect(gates).toContain("validate_changelog_attribution_policy");
expect(prepareCore).toContain("resolve_contributor_coauthor_email");
expect(mergeLib).toContain("pr_contributor_allows_human_trailers");
expect(mergeLib).toContain("Skipping PR author co-author trailer check for bot/app author");
});
});

View File

@@ -1,8 +1,16 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { windowsUpdateScript } from "../../scripts/e2e/parallels/npm-update-scripts.ts";
const SCRIPT_PATH = "scripts/e2e/parallels/npm-update-smoke.ts";
const UPDATE_SCRIPTS_PATH = "scripts/e2e/parallels/npm-update-scripts.ts";
const TEST_AUTH = {
authChoice: "openai",
authKeyFlag: "--openai-api-key",
apiKeyEnv: "OPENAI_API_KEY",
apiKeyValue: "test-key",
modelId: "gpt-5.4",
};
describe("parallels npm update smoke", () => {
it("does not leave guard/server children attached to the wrapper", () => {
@@ -49,4 +57,33 @@ describe("parallels npm update smoke", () => {
);
expect(script).toContain("OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw gateway stop");
});
it("generates a .NET-safe Windows stale import regex in the update-failure guard", () => {
const script = windowsUpdateScript({
auth: TEST_AUTH,
expectedNeedle: "2026.4.30",
updateTarget: "latest",
});
const staleImportLine = script.match(/\$stalePostSwapImport = [^\n]+/)?.[0];
const staleImportMatch = script.match(/\$updateText -match '(node_modules[^']+)'/);
const staleImportPattern = staleImportMatch?.[1];
if (!staleImportLine) {
throw new Error("missing generated Windows stale import guard");
}
if (!staleImportPattern) {
throw new Error("missing generated Windows stale import regex");
}
expect(staleImportLine).toContain("$updateText -match 'ERR_MODULE_NOT_FOUND'");
expect(staleImportLine).toContain(`$updateText -match '${staleImportPattern}'`);
expect(staleImportPattern).toBe(
String.raw`node_modules\\openclaw\\dist\\[^\\]+-[A-Za-z0-9_-]+\.js`,
);
expect(staleImportPattern).not.toContain("node_modules\\openclaw\\dist\\");
expect(staleImportPattern.match(/\\\\/g)).toHaveLength(4);
const representativeUpdateFailure = String.raw`Error [ERR_MODULE_NOT_FOUND]: Cannot find module 'C:\Users\runner\AppData\Roaming\npm\node_modules\openclaw\dist\main-a1_B2.js' imported from C:\Users\runner\AppData\Roaming\npm\node_modules\openclaw\dist\cli.js`;
const generatedRegex = new RegExp(staleImportPattern);
expect(generatedRegex.test(representativeUpdateFailure)).toBe(true);
expect(generatedRegex.test(String.raw`node_modules\openclaw\dist\main.js`)).toBe(false);
});
});