fix: harden source checkout plugin dependency handling

This commit is contained in:
Peter Steinberger
2026-05-02 02:42:45 +01:00
parent 5bdc901601
commit 04f1fd4d1f
11 changed files with 350 additions and 179 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
- Replies: strip legacy `[TOOL_CALL]{tool => ..., args => ...}[/TOOL_CALL]` pseudo-call text from user-facing replies and flag it in tool-call diagnostics instead of showing raw tool syntax in channels. Fixes #63610. Thanks @canh0chua.
- WhatsApp: close long-lived web sockets through Baileys `end(error)` before falling back to raw websocket close, so listener teardown runs Baileys cleanup instead of leaving zombie sockets. Fixes #52442. Thanks @essendigitalgroup-cyber.
- Twitch/plugins: emit a flat JSON Schema for Twitch channel config so single-account and multi-account configs validate before runtime load, and add source-checkout diagnostics for missing pnpm workspace dependencies. Thanks @vincentkoc.
- Gateway/sessions: move hot transcript reads and mirror appends onto async bounded IO with serialized parent-linked writes, keeping large session histories from stalling Gateway requests and channel replies. Fixes #75656. Thanks @DerFlash.
- macOS/Voice Wake: accept trigger-only phrases in the built-in Voice Wake test, matching the settings UI and runtime trigger-only path instead of requiring extra command text after the wake word. Fixes #64986. Thanks @zoiks65.
- Cron/TTS: run cron announce payloads through the normal TTS directive transform before outbound delivery, so scheduled `[[tts]]` replies generate voice payloads instead of leaking raw tags. Fixes #52125. Thanks @kenchen3000.

View File

@@ -99,6 +99,12 @@ workspace dependencies are available and edits are picked up directly. Source
checkout development is pnpm-only; plain `npm install` at the repository root is
not a supported way to prepare bundled plugin dependencies.
| Install shape | Bundled plugin location | Dependency owner |
| -------------------------------- | ------------------------------------- | -------------------------------------------------------------------- |
| `npm install -g openclaw` | Built runtime tree inside the package | OpenClaw package and explicit plugin install/update/doctor flows |
| Git checkout plus `pnpm install` | `extensions/<id>` workspace packages | The pnpm workspace, including each plugin package's own dependencies |
| `openclaw plugins install ...` | Managed npm/git/ClawHub plugin root | The plugin install/update flow |
## Legacy cleanup
Older OpenClaw versions generated bundled-plugin dependency roots at startup or

View File

@@ -0,0 +1,46 @@
import AjvPkg from "ajv";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import { describe, expect, it } from "vitest";
import { TwitchConfigSchema } from "./config-schema.js";
function validateTwitchConfig(value: unknown): boolean {
const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
const schema = buildChannelConfigSchema(TwitchConfigSchema).schema;
const validate = new Ajv({ allErrors: true, strict: false }).compile(schema);
const ok = validate(value);
if (!ok) {
throw new Error(`expected valid Twitch config: ${JSON.stringify(validate.errors)}`);
}
return true;
}
describe("TwitchConfigSchema JSON schema", () => {
it("accepts single-account channel config with base fields", () => {
expect(
validateTwitchConfig({
enabled: false,
username: "openclaw",
accessToken: "oauth:test",
clientId: "test-client-id",
channel: "openclaw-test",
}),
).toBe(true);
});
it("accepts multi-account channel config with defaultAccount", () => {
expect(
validateTwitchConfig({
enabled: true,
defaultAccount: "stream",
accounts: {
stream: {
username: "openclaw",
accessToken: "oauth:test",
clientId: "test-client-id",
channel: "openclaw-test",
},
},
}),
).toBe(true);
});
});

View File

@@ -6,10 +6,7 @@ import { z } from "openclaw/plugin-sdk/zod";
*/
const TwitchRoleSchema = z.enum(["moderator", "owner", "vip", "subscriber", "all"]);
/**
* Twitch account configuration schema
*/
const TwitchAccountSchema = z.object({
const TwitchAccountShape = {
/** Twitch username */
username: z.string(),
/** Twitch OAuth access token (requires chat:read and chat:write scopes) */
@@ -36,16 +33,22 @@ const TwitchAccountSchema = z.object({
expiresIn: z.number().nullable().optional(),
/** Timestamp when token was obtained (optional, for token refresh tracking) */
obtainmentTimestamp: z.number().optional(),
});
};
/**
* Twitch account configuration schema
*/
const TwitchAccountSchema = z.object(TwitchAccountShape);
/**
* Base configuration properties shared by both single and multi-account modes
*/
const TwitchConfigBaseSchema = z.object({
const TwitchConfigBaseShape = {
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema.optional(),
});
defaultAccount: z.string().optional(),
};
/**
* Simplified single-account configuration schema
@@ -53,24 +56,25 @@ const TwitchConfigBaseSchema = z.object({
* Use this for single-account setups. Properties are at the top level,
* creating an implicit "default" account.
*/
const SimplifiedSchema = z.intersection(TwitchConfigBaseSchema, TwitchAccountSchema);
const SimplifiedSchema = z.object({
...TwitchConfigBaseShape,
...TwitchAccountShape,
});
/**
* Multi-account configuration schema
*
* Use this for multi-account setups. Each key is an account ID (e.g., "default", "secondary").
*/
const MultiAccountSchema = z.intersection(
TwitchConfigBaseSchema,
z
.object({
/** Per-account configuration (for multi-account setups) */
accounts: z.record(z.string(), TwitchAccountSchema),
})
.refine((val) => Object.keys(val.accounts || {}).length > 0, {
message: "accounts must contain at least one entry",
}),
);
const MultiAccountSchema = z
.object({
...TwitchConfigBaseShape,
/** Per-account configuration (for multi-account setups) */
accounts: z.record(z.string(), TwitchAccountSchema),
})
.refine((val) => Object.keys(val.accounts || {}).length > 0, {
message: "accounts must contain at least one entry",
});
/**
* Twitch plugin configuration schema

View File

@@ -20,7 +20,7 @@ export function noteSourceInstallIssues(root: string | null) {
if (fs.existsSync(nodeModules) && !fs.existsSync(pnpmStore)) {
warnings.push(
"- node_modules was not installed by pnpm (missing node_modules/.pnpm). Run: pnpm install",
"- node_modules was not installed by pnpm (missing node_modules/.pnpm). Run: pnpm install so bundled plugins can load package-local dependencies.",
);
}
@@ -31,7 +31,7 @@ export function noteSourceInstallIssues(root: string | null) {
}
if (fs.existsSync(srcEntry) && !fs.existsSync(tsxBin)) {
warnings.push("- tsx binary is missing for source runs. Run: pnpm install");
warnings.push("- tsx binary is missing for source runs. Run: pnpm install.");
}
if (warnings.length > 0) {

View File

@@ -15103,191 +15103,174 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
$schema: "http://json-schema.org/draft-07/schema#",
anyOf: [
{
allOf: [
{
type: "object",
properties: {
name: {
type: "string",
},
enabled: {
type: "boolean",
},
markdown: {
type: "object",
properties: {
name: {
tables: {
type: "string",
},
enabled: {
type: "boolean",
},
markdown: {
type: "object",
properties: {
tables: {
type: "string",
enum: ["off", "bullets", "code", "block"],
},
},
additionalProperties: false,
enum: ["off", "bullets", "code", "block"],
},
},
additionalProperties: false,
},
{
type: "object",
properties: {
username: {
type: "string",
},
accessToken: {
type: "string",
},
clientId: {
type: "string",
},
channel: {
type: "string",
minLength: 1,
},
enabled: {
type: "boolean",
},
allowFrom: {
type: "array",
items: {
type: "string",
},
},
allowedRoles: {
type: "array",
items: {
type: "string",
enum: ["moderator", "owner", "vip", "subscriber", "all"],
},
},
requireMention: {
type: "boolean",
},
responsePrefix: {
type: "string",
},
clientSecret: {
type: "string",
},
refreshToken: {
type: "string",
},
expiresIn: {
anyOf: [
{
type: "number",
},
{
type: "null",
},
],
},
obtainmentTimestamp: {
defaultAccount: {
type: "string",
},
username: {
type: "string",
},
accessToken: {
type: "string",
},
clientId: {
type: "string",
},
channel: {
type: "string",
minLength: 1,
},
allowFrom: {
type: "array",
items: {
type: "string",
},
},
allowedRoles: {
type: "array",
items: {
type: "string",
enum: ["moderator", "owner", "vip", "subscriber", "all"],
},
},
requireMention: {
type: "boolean",
},
responsePrefix: {
type: "string",
},
clientSecret: {
type: "string",
},
refreshToken: {
type: "string",
},
expiresIn: {
anyOf: [
{
type: "number",
},
},
required: ["username", "accessToken", "channel"],
additionalProperties: false,
{
type: "null",
},
],
},
],
obtainmentTimestamp: {
type: "number",
},
},
required: ["username", "accessToken", "channel"],
additionalProperties: false,
},
{
allOf: [
{
type: "object",
properties: {
name: {
type: "string",
},
enabled: {
type: "boolean",
},
markdown: {
type: "object",
properties: {
name: {
tables: {
type: "string",
},
enabled: {
type: "boolean",
},
markdown: {
type: "object",
properties: {
tables: {
type: "string",
enum: ["off", "bullets", "code", "block"],
},
},
additionalProperties: false,
enum: ["off", "bullets", "code", "block"],
},
},
additionalProperties: false,
},
{
defaultAccount: {
type: "string",
},
accounts: {
type: "object",
properties: {
accounts: {
type: "object",
propertyNames: {
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
username: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
username: {
type: "string",
},
accessToken: {
type: "string",
},
clientId: {
type: "string",
},
channel: {
type: "string",
minLength: 1,
},
enabled: {
type: "boolean",
},
allowFrom: {
type: "array",
items: {
type: "string",
},
},
allowedRoles: {
type: "array",
items: {
type: "string",
enum: ["moderator", "owner", "vip", "subscriber", "all"],
},
},
requireMention: {
type: "boolean",
},
responsePrefix: {
type: "string",
},
clientSecret: {
type: "string",
},
refreshToken: {
type: "string",
},
expiresIn: {
anyOf: [
{
type: "number",
},
{
type: "null",
},
],
},
obtainmentTimestamp: {
accessToken: {
type: "string",
},
clientId: {
type: "string",
},
channel: {
type: "string",
minLength: 1,
},
enabled: {
type: "boolean",
},
allowFrom: {
type: "array",
items: {
type: "string",
},
},
allowedRoles: {
type: "array",
items: {
type: "string",
enum: ["moderator", "owner", "vip", "subscriber", "all"],
},
},
requireMention: {
type: "boolean",
},
responsePrefix: {
type: "string",
},
clientSecret: {
type: "string",
},
refreshToken: {
type: "string",
},
expiresIn: {
anyOf: [
{
type: "number",
},
},
required: ["username", "accessToken", "channel"],
additionalProperties: false,
{
type: "null",
},
],
},
obtainmentTimestamp: {
type: "number",
},
},
required: ["username", "accessToken", "channel"],
additionalProperties: false,
},
required: ["accounts"],
additionalProperties: false,
},
],
},
required: ["accounts"],
additionalProperties: false,
},
],
},

View File

@@ -1,7 +1,10 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import {
resolveBundledPluginsDir,
resolveSourceCheckoutDependencyDiagnostic,
} from "./bundled-dir.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
const tempDirs: string[] = [];
@@ -311,6 +314,31 @@ describe("resolveBundledPluginsDir", () => {
});
});
it("reports missing pnpm workspace deps for source checkouts", () => {
const repoRoot = createOpenClawRoot({
prefix: "openclaw-bundled-dir-source-deps-",
hasExtensions: true,
hasSrc: true,
hasGitCheckout: true,
hasPnpmWorkspace: true,
});
seedBundledPluginTree(repoRoot, "extensions", "twitch");
vi.spyOn(process, "cwd").mockReturnValue(repoRoot);
process.argv[1] = path.join(repoRoot, "openclaw.mjs");
expect(resolveSourceCheckoutDependencyDiagnostic()).toEqual({
source: repoRoot,
message: expect.stringContaining("run `pnpm install`"),
});
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1";
expect(resolveSourceCheckoutDependencyDiagnostic()).toBeNull();
delete process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
fs.mkdirSync(path.join(repoRoot, "node_modules", ".pnpm"), { recursive: true });
expect(resolveSourceCheckoutDependencyDiagnostic()).toBeNull();
});
it("returns a stable empty bundled plugin directory when bundled plugins are disabled", () => {
const repoRoot = createOpenClawRoot({
prefix: "openclaw-bundled-dir-disabled-",

View File

@@ -10,6 +10,11 @@ const DISABLED_BUNDLED_PLUGINS_DIR = path.join(os.tmpdir(), "openclaw-empty-bund
const TEST_TRUST_BUNDLED_PLUGINS_DIR_ENV = "OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR";
let bundledPluginsDirOverrideForTest: string | undefined;
export type SourceCheckoutDependencyDiagnostic = {
source: string;
message: string;
};
export function areBundledPluginsDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
const raw = normalizeOptionalLowercaseString(env.OPENCLAW_DISABLE_BUNDLED_PLUGINS);
return raw === "1" || raw === "true";
@@ -87,6 +92,40 @@ function trustedBundledPluginRootsForPackageRoot(packageRoot: string): string[]
return roots;
}
function resolvePackageRootsForBundledPlugins(): string[] {
const argvRoot = resolveOpenClawPackageRootSync({ argv1: process.argv[1] });
const moduleRoot = resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url });
return [argvRoot, moduleRoot].filter(
(entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index,
);
}
export function resolveSourceCheckoutDependencyDiagnostic(
env: NodeJS.ProcessEnv = process.env,
): SourceCheckoutDependencyDiagnostic | null {
if (areBundledPluginsDisabled(env)) {
return null;
}
for (const packageRoot of resolvePackageRootsForBundledPlugins()) {
if (!isSourceCheckoutRoot(packageRoot)) {
continue;
}
const extensionsDir = path.join(packageRoot, "extensions");
if (!hasUsableBundledPluginTree(extensionsDir)) {
continue;
}
if (fs.existsSync(path.join(packageRoot, "node_modules", ".pnpm"))) {
continue;
}
return {
source: packageRoot,
message:
"OpenClaw source checkout detected without pnpm workspace dependencies; run `pnpm install` from the repo root so bundled plugins can load package-local dependencies.",
};
}
return null;
}
function resolveTrustedExistingOverride(resolvedOverride: string): string | null {
const realOverride = safeRealpathSync(resolvedOverride);
if (!realOverride) {

View File

@@ -488,6 +488,34 @@ describe("discoverOpenClawPlugins", () => {
});
});
it("does not warn about source checkout deps when bundled plugins are disabled", () => {
const stateDir = makeTempDir();
const packageRoot = makeTempDir();
mkdirSafe(path.join(packageRoot, "src"));
const extensionDir = path.join(packageRoot, "extensions", "twitch");
mkdirSafe(extensionDir);
fs.writeFileSync(path.join(packageRoot, ".git"), "gitdir: /tmp/fake.git\n", "utf-8");
fs.writeFileSync(
path.join(packageRoot, "pnpm-workspace.yaml"),
"packages:\n - .\n - extensions/*\n",
"utf-8",
);
fs.writeFileSync(
path.join(extensionDir, "package.json"),
'{"name":"@openclaw/twitch"}\n',
"utf-8",
);
fs.writeFileSync(path.join(extensionDir, "openclaw.plugin.json"), '{"id":"twitch"}\n', "utf-8");
const result = withOpenClawPackageArgv(packageRoot, () =>
discoverOpenClawPlugins({ env: buildDiscoveryEnv(stateDir) }),
);
expect(result.diagnostics.map((entry) => entry.message)).not.toContainEqual(
expect.stringContaining("pnpm install"),
);
});
it("does not treat repo-level live or test files as plugin entrypoints", () => {
const stateDir = makeTempDir();
const packageRoot = path.join(stateDir, "node_modules", "openclaw");

View File

@@ -8,6 +8,7 @@ import {
} from "../shared/string-coerce.js";
import { resolveUserPath } from "../utils.js";
import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js";
import { resolveSourceCheckoutDependencyDiagnostic } from "./bundled-dir.js";
import { resolvePackagedBundledLoadPathAlias } from "./bundled-load-path-aliases.js";
import { listBundledSourceOverlayDirs } from "./bundled-source-overlays.js";
import type { PluginBundleFormat, PluginDiagnostic, PluginFormat } from "./manifest-types.js";
@@ -971,6 +972,14 @@ export function discoverOpenClawPlugins(params: {
"using bind-mounted bundled plugin source overlay; this source overrides the packaged dist bundle for the same plugin id",
});
}
const sourceCheckoutDependencyDiagnostic = resolveSourceCheckoutDependencyDiagnostic(env);
if (sourceCheckoutDependencyDiagnostic) {
result.diagnostics.push({
level: "warn",
source: sourceCheckoutDependencyDiagnostic.source,
message: sourceCheckoutDependencyDiagnostic.message,
});
}
if (roots.stock) {
discoverInDirectory({
dir: roots.stock,

View File

@@ -0,0 +1,27 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { loadOpenClawPlugins } from "./loader.js";
describe("source checkout bundled plugin runtime", () => {
it("loads enabled bundled plugins from the pnpm workspace source tree", () => {
const registry = loadOpenClawPlugins({
cache: false,
onlyPluginIds: ["twitch"],
config: {
plugins: {
entries: {
twitch: { enabled: true },
},
},
},
});
const twitch = registry.plugins.find((plugin) => plugin.id === "twitch");
expect(twitch).toMatchObject({
status: "loaded",
origin: "bundled",
});
expect(twitch?.source).toContain(`${path.sep}extensions${path.sep}twitch${path.sep}index.ts`);
expect(twitch?.rootDir).toContain(`${path.sep}extensions${path.sep}twitch`);
});
});