diff --git a/CHANGELOG.md b/CHANGELOG.md index a515eca33b7..d2aad1dea42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/e2e/parallels/npm-update-scripts.ts b/scripts/e2e/parallels/npm-update-scripts.ts index 997a0e600dd..8fdb34652ff 100644 --- a/scripts/e2e/parallels/npm-update-scripts.ts +++ b/scripts/e2e/parallels/npm-update-scripts.ts @@ -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" } diff --git a/scripts/pr-lib/changelog.sh b/scripts/pr-lib/changelog.sh index 76e41efc003..68dbd2bdb25 100644 --- a/scripts/pr-lib/changelog.sh +++ b/scripts/pr-lib/changelog.sh @@ -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() { diff --git a/scripts/pr-lib/common.sh b/scripts/pr-lib/common.sh index d05440b6c30..ab0a26c186f 100644 --- a/scripts/pr-lib/common.sh +++ b/scripts/pr-lib/common.sh @@ -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 diff --git a/scripts/pr-lib/merge.sh b/scripts/pr-lib/merge.sh index 9b16b54e0d8..09f36109620 100644 --- a/scripts/pr-lib/merge.sh +++ b/scripts/pr-lib/merge.sh @@ -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 < -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 <&1 + ); then ok=1 break fi diff --git a/scripts/pr-lib/prepare-core.sh b/scripts/pr-lib/prepare-core.sh index 3e9e0af32c6..ac21111feaf 100644 --- a/scripts/pr-lib/prepare-core.sh +++ b/scripts/pr-lib/prepare-core.sh @@ -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 <> .local/prep.md <`. 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[@]}" diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts index e543dc209d2..ca7a2ca9cc8 100644 --- a/src/agents/tools/image-generate-tool.test.ts +++ b/src/agents/tools/image-generate-tool.test.ts @@ -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(() => { diff --git a/src/agents/tools/video-generate-tool.test.ts b/src/agents/tools/video-generate-tool.test.ts index c69ba01bc1c..b34dd3a622e 100644 --- a/src/agents/tools/video-generate-tool.test.ts +++ b/src/agents/tools/video-generate-tool.test.ts @@ -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(); diff --git a/test/scripts/check-changelog-attributions.test.ts b/test/scripts/check-changelog-attributions.test.ts index 0e5a04e764b..d90dd10883d 100644 --- a/test/scripts/check-changelog-attributions.test.ts +++ b/test/scripts/check-changelog-attributions.test.ts @@ -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"); }); }); diff --git a/test/scripts/parallels-npm-update-smoke.test.ts b/test/scripts/parallels-npm-update-smoke.test.ts index 46400820aa4..4507b61e71b 100644 --- a/test/scripts/parallels-npm-update-smoke.test.ts +++ b/test/scripts/parallels-npm-update-smoke.test.ts @@ -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); + }); });