diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b7b76b5af2..34d05e06c4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/plugins/dependency-resolution.md b/docs/plugins/dependency-resolution.md index 5d095641ebb..e08d272c5ca 100644 --- a/docs/plugins/dependency-resolution.md +++ b/docs/plugins/dependency-resolution.md @@ -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/` 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 diff --git a/extensions/twitch/src/config-schema.test.ts b/extensions/twitch/src/config-schema.test.ts new file mode 100644 index 00000000000..9462fadc06b --- /dev/null +++ b/extensions/twitch/src/config-schema.test.ts @@ -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); + }); +}); diff --git a/extensions/twitch/src/config-schema.ts b/extensions/twitch/src/config-schema.ts index 7bd74e137a5..90f0262d5fb 100644 --- a/extensions/twitch/src/config-schema.ts +++ b/extensions/twitch/src/config-schema.ts @@ -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 diff --git a/src/commands/doctor-install.ts b/src/commands/doctor-install.ts index 4b83c61447e..2b2a3db301b 100644 --- a/src/commands/doctor-install.ts +++ b/src/commands/doctor-install.ts @@ -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) { diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 66c503f5a10..0200fffc75f 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -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, }, ], }, diff --git a/src/plugins/bundled-dir.test.ts b/src/plugins/bundled-dir.test.ts index 6ac8a3ee597..a41fcafe1a2 100644 --- a/src/plugins/bundled-dir.test.ts +++ b/src/plugins/bundled-dir.test.ts @@ -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-", diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index e28641e2f04..c4122a58a32 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -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) { diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 9f197ba1480..32780711ddb 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -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"); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index bab8971d007..842bba214dc 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -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, diff --git a/src/plugins/source-checkout-runtime.test.ts b/src/plugins/source-checkout-runtime.test.ts new file mode 100644 index 00000000000..6f839915e2c --- /dev/null +++ b/src/plugins/source-checkout-runtime.test.ts @@ -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`); + }); +});