diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d8a04095aa..1a8cf1aa6ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Maintainer tooling: fail CI when pull requests add package patch files or pnpm patched dependencies, preserving the upstream-and-bump dependency workflow. - Amazon Bedrock: externalize the Bedrock and Bedrock Mantle provider packages so core installs no longer pull AWS SDK dependencies unless those providers are installed. - Plugins: externalize Slack, OpenShell sandbox, and Anthropic Vertex so their runtime dependency cones install only when those plugins are installed. - Control UI/WebChat: add a persisted auto-scroll mode selector so users can keep the current near-bottom behavior, always follow streaming output, or turn automatic streaming scroll off and use the New messages button manually. Fixes #7648 and #81287. Thanks @BunsDev. diff --git a/package.json b/package.json index 2c50088ca28..2eac6dec3bb 100644 --- a/package.json +++ b/package.json @@ -1416,6 +1416,7 @@ "deps:root-ownership": "node scripts/root-dependency-ownership-audit.mjs", "deps:root-ownership:check": "node scripts/root-dependency-ownership-audit.mjs --check", "deps:changes:report": "node scripts/dependency-changes-report.mjs", + "deps:patches:check": "node scripts/check-package-patches.mjs", "deps:pins:check": "node scripts/check-dependency-pins.mjs", "deps:ownership-surface:check": "node scripts/dependency-ownership-surface-report.mjs --check", "deps:ownership-surface:report": "node scripts/dependency-ownership-surface-report.mjs", diff --git a/scripts/check-changed.mjs b/scripts/check-changed.mjs index 3bd516b2e98..f537b0cc33d 100644 --- a/scripts/check-changed.mjs +++ b/scripts/check-changed.mjs @@ -127,6 +127,7 @@ export function createChangedCheckPlan(result, options = {}) { add("plugin-sdk wildcard re-exports", ["lint:extensions:no-plugin-sdk-wildcard-reexports"]); add("duplicate scan target coverage", ["dup:check:coverage"]); add("dependency pin guard", ["deps:pins:check"]); + add("package patch guard", ["deps:patches:check"]); if (result.docsOnly) { return { diff --git a/scripts/check-package-patches.mjs b/scripts/check-package-patches.mjs new file mode 100644 index 00000000000..76814647ba1 --- /dev/null +++ b/scripts/check-package-patches.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import YAML from "yaml"; + +const ALLOWED_PATCHED_DEPENDENCIES = new Map([ + [ + "@agentclientprotocol/claude-agent-acp@0.33.1", + "patches/@agentclientprotocol__claude-agent-acp@0.33.1.patch", + ], + ["baileys@7.0.0-rc11", "patches/baileys@7.0.0-rc11.patch"], +]); + +const ALLOWED_PATCH_FILES = new Set(["patches/.gitkeep", ...ALLOWED_PATCHED_DEPENDENCIES.values()]); + +function listTrackedFiles(cwd, patterns) { + return execFileSync("git", ["ls-files", "-z", "--", ...patterns], { + cwd, + encoding: "utf8", + }) + .split("\0") + .filter(Boolean) + .toSorted((left, right) => left.localeCompare(right)); +} + +function readYamlFile(cwd, relativePath) { + const filePath = path.join(cwd, relativePath); + if (!fs.existsSync(filePath)) { + return {}; + } + return YAML.parse(fs.readFileSync(filePath, "utf8")) ?? {}; +} + +function readJsonFile(cwd, relativePath) { + const filePath = path.join(cwd, relativePath); + if (!fs.existsSync(filePath)) { + return undefined; + } + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function collectPatchedDependencyViolations(file, patchedDependencies, violations, options = {}) { + for (const [specifier, patchPathOrHash] of Object.entries(patchedDependencies ?? {})) { + if ( + options.allowAnyValueForLegacy === true + ? ALLOWED_PATCHED_DEPENDENCIES.has(specifier) + : ALLOWED_PATCHED_DEPENDENCIES.get(specifier) === patchPathOrHash + ) { + continue; + } + violations.push({ + file, + kind: "patchedDependency", + detail: `${specifier} -> ${String(patchPathOrHash)}`, + }); + } +} + +function collectWorkspacePatchViolations(cwd, violations) { + const workspace = readYamlFile(cwd, "pnpm-workspace.yaml"); + collectPatchedDependencyViolations( + "pnpm-workspace.yaml", + workspace?.patchedDependencies, + violations, + ); +} + +function collectLockfilePatchViolations(cwd, violations) { + const lockfile = readYamlFile(cwd, "pnpm-lock.yaml"); + collectPatchedDependencyViolations("pnpm-lock.yaml", lockfile?.patchedDependencies, violations, { + allowAnyValueForLegacy: true, + }); +} + +function collectPackageJsonPatchViolations(cwd, violations) { + for (const relativePath of listTrackedFiles(cwd, ["*package.json"])) { + const packageJson = readJsonFile(cwd, relativePath); + const patchedDependencies = packageJson?.pnpm?.patchedDependencies; + for (const [specifier, patchPath] of Object.entries(patchedDependencies ?? {})) { + violations.push({ + file: relativePath, + kind: "packageJsonPatchedDependency", + detail: `${specifier} -> ${String(patchPath)}`, + }); + } + } +} + +function collectPatchFileViolations(cwd, violations) { + for (const relativePath of listTrackedFiles(cwd, ["*.patch"])) { + if (ALLOWED_PATCH_FILES.has(relativePath)) { + continue; + } + violations.push({ + file: relativePath, + kind: "patchFile", + detail: "new package patch file", + }); + } +} + +export function collectPackagePatchViolations(cwd = process.cwd()) { + const violations = []; + collectWorkspacePatchViolations(cwd, violations); + collectLockfilePatchViolations(cwd, violations); + collectPackageJsonPatchViolations(cwd, violations); + collectPatchFileViolations(cwd, violations); + return violations; +} + +export async function main() { + const violations = collectPackagePatchViolations(); + if (violations.length === 0) { + process.stdout.write( + `PASS package patch guard: no new pnpm patches; ${ALLOWED_PATCHED_DEPENDENCIES.size} legacy patches allowlisted.\n`, + ); + return; + } + + console.error( + "FAIL package patch guard: new pnpm package patches are not allowed. Upstream the fix, publish a new package version, then bump the dependency instead.", + ); + for (const violation of violations) { + console.error(`- ${violation.file}: ${violation.kind}: ${violation.detail}`); + } + process.exitCode = 1; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} diff --git a/scripts/check.mjs b/scripts/check.mjs index c5537d85f81..b6804c69caa 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -49,6 +49,7 @@ export async function main(argv = process.argv.slice(2)) { { name: "host env policy", args: ["check:host-env-policy:swift"] }, { name: "opengrep rule metadata", args: ["check:opengrep-rule-metadata"] }, { name: "duplicate scan target coverage", args: ["dup:check:coverage"] }, + { name: "package patch guard", args: ["deps:patches:check"] }, ], }, { diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 96c788504e7..9587eba6f48 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -474,6 +474,7 @@ describe("scripts/changed-lanes", () => { "plugin-sdk wildcard re-exports", "duplicate scan target coverage", "dependency pin guard", + "package patch guard", "typecheck core tests", "lint core", "lint scripts", @@ -755,6 +756,7 @@ describe("scripts/changed-lanes", () => { "lint:extensions:no-plugin-sdk-wildcard-reexports", "dup:check:coverage", "deps:pins:check", + "deps:patches:check", "release-metadata:check", "ios:version:check", "config:schema:check", @@ -956,6 +958,7 @@ describe("scripts/changed-lanes", () => { }, { name: "duplicate scan target coverage", args: ["dup:check:coverage"] }, { name: "dependency pin guard", args: ["deps:pins:check"] }, + { name: "package patch guard", args: ["deps:patches:check"] }, ]); }); @@ -977,6 +980,7 @@ describe("scripts/changed-lanes", () => { }, { name: "duplicate scan target coverage", args: ["dup:check:coverage"] }, { name: "dependency pin guard", args: ["deps:pins:check"] }, + { name: "package patch guard", args: ["deps:patches:check"] }, ]); }); }); diff --git a/test/scripts/check-package-patches.test.ts b/test/scripts/check-package-patches.test.ts new file mode 100644 index 00000000000..93288868d7c --- /dev/null +++ b/test/scripts/check-package-patches.test.ts @@ -0,0 +1,173 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { collectPackagePatchViolations } from "../../scripts/check-package-patches.mjs"; +import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "../helpers/temp-repo.js"; + +const tempDirs: string[] = []; + +const nestedGitEnvKeys = [ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_DIR", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_QUARANTINE_PATH", + "GIT_WORK_TREE", +] as const; + +function createNestedGitEnv(): NodeJS.ProcessEnv { + const env = { + ...process.env, + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + }; + for (const key of nestedGitEnvKeys) { + delete env[key]; + } + return env; +} + +function git(cwd: string, args: string[]) { + execFileSync("git", args, { + cwd, + encoding: "utf8", + env: createNestedGitEnv(), + }); +} + +function makeRepo() { + const dir = makeTempRepoRoot(tempDirs, "openclaw-package-patches-"); + git(dir, ["init", "-q", "--initial-branch=main"]); + writeJsonFile(path.join(dir, "package.json"), { name: "fixture" }); + writeFileSync(path.join(dir, "pnpm-workspace.yaml"), "packages:\n - .\n", "utf8"); + writeFileSync(path.join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n", "utf8"); + git(dir, ["add", "package.json", "pnpm-workspace.yaml", "pnpm-lock.yaml"]); + return dir; +} + +afterEach(() => { + cleanupTempDirs(tempDirs); +}); + +describe("check-package-patches", () => { + it("allows the existing legacy pnpm patches", () => { + const dir = makeRepo(); + mkdirSync(path.join(dir, "patches"), { recursive: true }); + writeFileSync( + path.join(dir, "pnpm-workspace.yaml"), + `packages: + - . +patchedDependencies: + "baileys@7.0.0-rc11": "patches/baileys@7.0.0-rc11.patch" + "@agentclientprotocol/claude-agent-acp@0.33.1": "patches/@agentclientprotocol__claude-agent-acp@0.33.1.patch" +`, + "utf8", + ); + writeFileSync( + path.join(dir, "pnpm-lock.yaml"), + `lockfileVersion: '9.0' +patchedDependencies: + '@agentclientprotocol/claude-agent-acp@0.33.1': 3995624bb834cc60fea1461c7ef33f1fcdd8fb58b8f43f2f1490bc689f6e1be2 + baileys@7.0.0-rc11: a9aea1790d2c65b1ae543c77faca4119bbfb91ee3b6ca6c38d1cad4f5702ada2 +`, + "utf8", + ); + writeFileSync(path.join(dir, "patches", "baileys@7.0.0-rc11.patch"), "diff\n", "utf8"); + writeFileSync( + path.join(dir, "patches", "@agentclientprotocol__claude-agent-acp@0.33.1.patch"), + "diff\n", + "utf8", + ); + git(dir, ["add", "pnpm-workspace.yaml", "pnpm-lock.yaml", "patches"]); + + expect(collectPackagePatchViolations(dir)).toEqual([]); + }); + + it("rejects new workspace patchedDependencies and patch files", () => { + const dir = makeRepo(); + mkdirSync(path.join(dir, "patches"), { recursive: true }); + mkdirSync(path.join(dir, "fixtures"), { recursive: true }); + writeFileSync( + path.join(dir, "pnpm-workspace.yaml"), + `packages: + - . +patchedDependencies: + "left-pad@1.3.0": "patches/left-pad@1.3.0.patch" +`, + "utf8", + ); + writeFileSync(path.join(dir, "patches", "left-pad@1.3.0.patch"), "diff\n", "utf8"); + writeFileSync(path.join(dir, "fixtures", "fixture.patch"), "diff\n", "utf8"); + git(dir, ["add", "pnpm-workspace.yaml", "patches", "fixtures"]); + + expect(collectPackagePatchViolations(dir)).toEqual([ + { + file: "pnpm-workspace.yaml", + kind: "patchedDependency", + detail: "left-pad@1.3.0 -> patches/left-pad@1.3.0.patch", + }, + { + file: "fixtures/fixture.patch", + kind: "patchFile", + detail: "new package patch file", + }, + { + file: "patches/left-pad@1.3.0.patch", + kind: "patchFile", + detail: "new package patch file", + }, + ]); + }); + + it("rejects lockfile-only and package-local patch declarations", () => { + const dir = makeRepo(); + writeJsonFile(path.join(dir, "package.json"), { + name: "fixture", + pnpm: { + patchedDependencies: { + "nested@1.0.0": "patches/nested.patch", + }, + }, + }); + writeFileSync( + path.join(dir, "pnpm-lock.yaml"), + `lockfileVersion: '9.0' +patchedDependencies: + hidden@1.0.0: abc123 +`, + "utf8", + ); + git(dir, ["add", "package.json", "pnpm-lock.yaml"]); + + expect(collectPackagePatchViolations(dir)).toEqual([ + { + file: "pnpm-lock.yaml", + kind: "patchedDependency", + detail: "hidden@1.0.0 -> abc123", + }, + { + file: "package.json", + kind: "packageJsonPatchedDependency", + detail: "nested@1.0.0 -> patches/nested.patch", + }, + ]); + }); + + it("skips tracked package manifests deleted in the worktree", () => { + const dir = makeRepo(); + mkdirSync(path.join(dir, "packages", "deleted"), { recursive: true }); + writeJsonFile(path.join(dir, "packages", "deleted", "package.json"), { + name: "deleted", + pnpm: { + patchedDependencies: { + "deleted-only@1.0.0": "patches/deleted-only.patch", + }, + }, + }); + git(dir, ["add", "packages/deleted/package.json"]); + rmSync(path.join(dir, "packages", "deleted", "package.json")); + + expect(collectPackagePatchViolations(dir)).toEqual([]); + }); +});