diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d94c6beeca9..9ae595a9c644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3133,6 +3133,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Skills/OpenAI Whisper: restore executable bits for bundled Whisper and video-frame shell helpers and add a release check for non-executable bundled skill shell scripts, so packaged installs no longer fail with permission-denied errors. Fixes #9303. Thanks @nikolasdehor. - Agents/tools: skip unavailable media generation and PDF tool factories from the live reply path when Gateway metadata and the active auth store prove no configured provider can back them, while keeping explicit config and auth-backed providers on the normal factory path. Thanks @shakkernerd. - Agents/runtime: reuse the Gateway metadata startup plan when ensuring reply runtime plugins are loaded, so live agent turns do not broad-load plugin runtimes after the Gateway already scoped startup activation. Thanks @shakkernerd. - Agents/runtime: delegate scoped reply runtime registry reuse to the plugin loader cache-key compatibility checks, so config changes with the same startup plugin ids cannot keep stale runtime hooks or tools active. Thanks @shakkernerd. diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 28518ece753e..f9854637d1f5 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -9,8 +9,10 @@ import { readdirSync, readFileSync, rmSync, + statSync, writeFileSync, } from "node:fs"; +import type { Dirent } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -144,6 +146,47 @@ export const PACKED_COMPLETION_SMOKE_ARGS = [ "zsh", ] as const; +export function collectSkillShellScriptExecutableErrors(rootDir = resolve(".")): string[] { + if (process.platform === "win32") { + return []; + } + + const skillsDir = join(rootDir, "skills"); + const errors: string[] = []; + let entries: Dirent[]; + try { + entries = readdirSync(skillsDir, { withFileTypes: true }); + } catch { + return []; + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const scriptsDir = join(skillsDir, entry.name, "scripts"); + let scriptEntries: Dirent[]; + try { + scriptEntries = readdirSync(scriptsDir, { withFileTypes: true }); + } catch { + continue; + } + for (const scriptEntry of scriptEntries) { + if (!scriptEntry.isFile() || !scriptEntry.name.endsWith(".sh")) { + continue; + } + const scriptPath = join(scriptsDir, scriptEntry.name); + if ((statSync(scriptPath).mode & 0o111) === 0) { + errors.push( + `skill shell script is not executable: skills/${entry.name}/scripts/${scriptEntry.name}`, + ); + } + } + } + + return errors; +} + function collectBundledExtensions(): BundledExtension[] { const extensionsDir = resolve("extensions"); const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) => @@ -189,6 +232,17 @@ function checkBundledExtensionMetadata() { } } +function checkSkillShellScriptsExecutable() { + const errors = collectSkillShellScriptExecutableErrors(); + if (errors.length > 0) { + console.error("release-check: skill shell script permission validation failed:"); + for (const error of errors) { + console.error(` - ${error}`); + } + process.exit(1); + } +} + function runPackDry(): PackResult[] { const raw = execSync("npm pack --dry-run --json --ignore-scripts", { encoding: "utf8", @@ -865,6 +919,7 @@ function runCriticalPluginSdkEntrypointImportSmoke() { async function main() { checkAppcastSparkleVersions(); + checkSkillShellScriptsExecutable(); checkCliBootstrapExternalImports({ logger: { error: (message: string) => console.error(`release-check: ${message}`), diff --git a/skills/openai-whisper-api/scripts/transcribe.sh b/skills/openai-whisper-api/scripts/transcribe.sh old mode 100644 new mode 100755 diff --git a/skills/video-frames/scripts/frame.sh b/skills/video-frames/scripts/frame.sh old mode 100644 new mode 100755 diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 2ba3483bebca..22b753c8f8c7 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { bundledDistPluginFile, bundledPluginFile } from "openclaw/plugin-sdk/test-fixtures"; @@ -17,6 +17,7 @@ import { collectForbiddenPackContentPaths, collectForbiddenPackPaths, collectMissingPackPaths, + collectSkillShellScriptExecutableErrors, collectPackUnpackedSizeErrors, collectPackedInstalledPackageVerificationErrors, createPackedCompletionSmokeEnv, @@ -338,6 +339,41 @@ describe("bundled plugin package dependency checks", () => { }); }); +// This suite exists both as regression coverage and as an intentional CI touchpoint for executable-bit fixes. +// Windows doesn't support Unix permission bits; chmod 0o755 is a no-op and +// statSync().mode never reports execute bits, so these tests are meaningless there. +describe.skipIf(process.platform === "win32")("collectSkillShellScriptExecutableErrors", () => { + it("flags non-executable shell scripts under skills/*/scripts", () => { + const root = mkdtempSync(join(tmpdir(), "openclaw-release-check-")); + const scriptPath = join(root, "skills", "openai-whisper-api", "scripts", "transcribe.sh"); + mkdirSync(join(root, "skills", "openai-whisper-api", "scripts"), { recursive: true }); + writeFileSync(scriptPath, "#!/usr/bin/env bash\necho test\n", "utf8"); + chmodSync(scriptPath, 0o644); + + try { + expect(collectSkillShellScriptExecutableErrors(root)).toEqual([ + "skill shell script is not executable: skills/openai-whisper-api/scripts/transcribe.sh", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("accepts executable shell scripts", () => { + const root = mkdtempSync(join(tmpdir(), "openclaw-release-check-")); + const scriptPath = join(root, "skills", "openai-whisper-api", "scripts", "transcribe.sh"); + mkdirSync(join(root, "skills", "openai-whisper-api", "scripts"), { recursive: true }); + writeFileSync(scriptPath, "#!/usr/bin/env bash\necho test\n", "utf8"); + chmodSync(scriptPath, 0o755); + + try { + expect(collectSkillShellScriptExecutableErrors(root)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe("collectForbiddenPackPaths", () => { it("blocks all packaged node_modules payloads", () => { expect(