Compare commits

...

4 Commits

Author SHA1 Message Date
Josh Lehman
53ac244ec6 Merge branch 'main' into codex/plugin-command-scope-auth 2026-03-26 16:15:50 -07:00
Josh Lehman
c94f10b915 Plugins: harden dynamic scope resolution
Catch dynamic gateway-scope resolver failures in the dispatcher, narrow
forwarded gateway scope strings with an explicit operator-scope guard, add
regression coverage for admin bypass and resolver-throw behavior, and
refresh bundled plugin metadata after main-branch drift.

Regeneration-Prompt: |
  Follow up on review feedback for the centralized plugin command auth
  change. Keep the scope tightly limited to the three review items:
  catch exceptions from `resolveRequiredGatewayScopes`, replace the raw
  `GatewayClientScopes` cast with explicit operator-scope narrowing, and
  add dispatcher-level tests for the `operator.admin` bypass plus the safe
  failure path when dynamic scope resolution throws.

  While landing that patch, the repo hook may report stale bundled plugin
  metadata generated files because main advanced. Regenerate those standard
  outputs with the repo generator so the branch is consistent enough to
  rebase, but do not chase unrelated CI or Discord test failures here.
2026-03-26 16:11:09 -07:00
Josh Lehman
70b43319ff Plugin SDK: refresh API baselines for auth context change
Update the generated Plugin SDK API baseline files after extending plugin
command types for centralized owner and gateway-scope authorization.

Regeneration-Prompt: |
  The prior commit intentionally changed exported plugin SDK types in
  `src/plugins/types.ts` by adding richer plugin command auth context and
  declarative command requirement fields. CI reported plugin SDK API drift,
  which means the generated baseline files under `docs/.generated/` no
  longer matched the exported surface.

  Regenerate only the plugin SDK API baseline artifacts with the repo's
  standard generator, verify `pnpm plugin-sdk:api:check` passes, and keep
  this follow-up scoped to those generated files. Do not fold in unrelated
  failing tests from untouched surfaces.
2026-03-26 16:11:09 -07:00
Josh Lehman
487f752754 Plugins: centralize plugin command auth requirements
Move plugin command authorization toward the GHSA's long-term model by
preserving richer auth context, supporting declarative owner and gateway
scope requirements, and enforcing them in the shared dispatcher. Convert
`/pair approve` to use the centralized requirement path and add regression
coverage for dispatcher-level auth behavior.

Regeneration-Prompt: |
  This follow-up hardening is for the plugin command auth gap described in
  GHSA-9gwp-pxfh-w6r5. The immediate exploit path was already fixed by
  plumbing gateway scopes into the device-pair plugin and checking `/pair
  approve` inline, but the longer-term goal is to stop relying on lossy,
  plugin-specific auth checks.

  Preserve the existing plugin command flow and keep the change additive.
  Carry richer authorization context into plugin execution, including owner
  status and command surface, and let commands declare owner or internal
  gateway-scope requirements that the central dispatcher enforces. Internal
  callers should fail closed when required scopes are missing, with admin
  scope still satisfying narrower operator requirements, while non-internal
  chat surfaces should keep their current auth behavior.

  Because `/pair` mixes low-risk actions like `qr` and `status` with the
  privileged `approve` action, use a context-sensitive requirement instead
  of making the whole command require pairing scope. Add focused regression
  tests around dispatcher enforcement and update any command-context test
  helpers that now need the richer fields.
2026-03-26 16:10:38 -07:00
12 changed files with 445 additions and 72 deletions

View File

@@ -244,7 +244,7 @@
"exportName": "CliBackendPlugin",
"kind": "type",
"source": {
"line": 1316,
"line": 1346,
"path": "src/plugins/types.ts"
}
},
@@ -406,7 +406,7 @@
"exportName": "OpenClawPluginApi",
"kind": "type",
"source": {
"line": 1360,
"line": 1390,
"path": "src/plugins/types.ts"
}
},
@@ -3423,7 +3423,7 @@
"exportName": "OpenClawPluginApi",
"kind": "type",
"source": {
"line": 1360,
"line": 1390,
"path": "src/plugins/types.ts"
}
},
@@ -3432,7 +3432,7 @@
"exportName": "OpenClawPluginCommandDefinition",
"kind": "type",
"source": {
"line": 1086,
"line": 1108,
"path": "src/plugins/types.ts"
}
},
@@ -3450,7 +3450,7 @@
"exportName": "OpenClawPluginDefinition",
"kind": "type",
"source": {
"line": 1342,
"line": 1372,
"path": "src/plugins/types.ts"
}
},
@@ -3459,7 +3459,7 @@
"exportName": "OpenClawPluginService",
"kind": "type",
"source": {
"line": 1309,
"line": 1339,
"path": "src/plugins/types.ts"
}
},
@@ -3468,7 +3468,7 @@
"exportName": "OpenClawPluginServiceContext",
"kind": "type",
"source": {
"line": 1301,
"line": 1331,
"path": "src/plugins/types.ts"
}
},
@@ -3504,7 +3504,7 @@
"exportName": "PluginInteractiveTelegramHandlerContext",
"kind": "type",
"source": {
"line": 1115,
"line": 1145,
"path": "src/plugins/types.ts"
}
},
@@ -3929,7 +3929,7 @@
"exportName": "OpenClawPluginApi",
"kind": "type",
"source": {
"line": 1360,
"line": 1390,
"path": "src/plugins/types.ts"
}
},
@@ -3938,7 +3938,7 @@
"exportName": "OpenClawPluginCommandDefinition",
"kind": "type",
"source": {
"line": 1086,
"line": 1108,
"path": "src/plugins/types.ts"
}
},
@@ -3956,7 +3956,7 @@
"exportName": "OpenClawPluginDefinition",
"kind": "type",
"source": {
"line": 1342,
"line": 1372,
"path": "src/plugins/types.ts"
}
},
@@ -3965,7 +3965,7 @@
"exportName": "OpenClawPluginService",
"kind": "type",
"source": {
"line": 1309,
"line": 1339,
"path": "src/plugins/types.ts"
}
},
@@ -3974,7 +3974,7 @@
"exportName": "OpenClawPluginServiceContext",
"kind": "type",
"source": {
"line": 1301,
"line": 1331,
"path": "src/plugins/types.ts"
}
},
@@ -4010,7 +4010,7 @@
"exportName": "PluginInteractiveTelegramHandlerContext",
"kind": "type",
"source": {
"line": 1115,
"line": 1145,
"path": "src/plugins/types.ts"
}
},

View File

@@ -25,7 +25,7 @@
{"declaration":"export type ChannelStatusIssue = ChannelStatusIssue;","entrypoint":"index","exportName":"ChannelStatusIssue","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":100,"sourcePath":"src/channels/plugins/types.core.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"index","exportName":"ClawdbotConfig","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type CliBackendConfig = CliBackendConfig;","entrypoint":"index","exportName":"CliBackendConfig","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":47,"sourcePath":"src/config/types.agent-defaults.ts"}
{"declaration":"export type CliBackendPlugin = CliBackendPlugin;","entrypoint":"index","exportName":"CliBackendPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1316,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type CliBackendPlugin = CliBackendPlugin;","entrypoint":"index","exportName":"CliBackendPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1346,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type CompiledConfiguredBinding = CompiledConfiguredBinding;","entrypoint":"index","exportName":"CompiledConfiguredBinding","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":38,"sourcePath":"src/channels/plugins/binding-types.ts"}
{"declaration":"export type ConfiguredBindingConversation = ConversationRef;","entrypoint":"index","exportName":"ConfiguredBindingConversation","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":13,"sourcePath":"src/channels/plugins/binding-types.ts"}
{"declaration":"export type ConfiguredBindingResolution = ConfiguredBindingResolution;","entrypoint":"index","exportName":"ConfiguredBindingResolution","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":49,"sourcePath":"src/channels/plugins/binding-types.ts"}
@@ -43,7 +43,7 @@
{"declaration":"export type ImageGenerationSourceImage = ImageGenerationSourceImage;","entrypoint":"index","exportName":"ImageGenerationSourceImage","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":14,"sourcePath":"src/image-generation/types.ts"}
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"index","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":969,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"index","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"index","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1360,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"index","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":1390,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"index","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":95,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginLogger = PluginLogger;","entrypoint":"index","exportName":"PluginLogger","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":66,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"index","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk","kind":"type","recordType":"export","sourceLine":54,"sourcePath":"src/plugins/runtime/types.ts"}
@@ -376,16 +376,16 @@
{"declaration":"export type GatewayRequestHandlerOptions = GatewayRequestHandlerOptions;","entrypoint":"core","exportName":"GatewayRequestHandlerOptions","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":112,"sourcePath":"src/gateway/server-methods/types.ts"}
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"core","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":969,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"core","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1360,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"core","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1086,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1390,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"core","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1108,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"core","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":95,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"core","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1342,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"core","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1309,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"core","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1301,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"core","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1372,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"core","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1339,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"core","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1331,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginToolContext = OpenClawPluginToolContext;","entrypoint":"core","exportName":"OpenClawPluginToolContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":110,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginToolFactory = OpenClawPluginToolFactory;","entrypoint":"core","exportName":"OpenClawPluginToolFactory","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":131,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"core","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":984,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"core","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1115,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"core","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":1145,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginLogger = PluginLogger;","entrypoint":"core","exportName":"PluginLogger","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":66,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"core","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":54,"sourcePath":"src/plugins/runtime/types.ts"}
{"declaration":"export type ProviderAugmentModelCatalogContext = ProviderAugmentModelCatalogContext;","entrypoint":"core","exportName":"ProviderAugmentModelCatalogContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export","sourceLine":582,"sourcePath":"src/plugins/types.ts"}
@@ -432,16 +432,16 @@
{"declaration":"export type AnyAgentTool = AnyAgentTool;","entrypoint":"plugin-entry","exportName":"AnyAgentTool","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":9,"sourcePath":"src/agents/tools/common.ts"}
{"declaration":"export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider;","entrypoint":"plugin-entry","exportName":"MediaUnderstandingProviderPlugin","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":969,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"plugin-entry","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":32,"sourcePath":"src/config/types.openclaw.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-entry","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1360,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1086,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-entry","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1390,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1108,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":95,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1342,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"plugin-entry","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1309,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"plugin-entry","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1301,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1372,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"plugin-entry","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1339,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginServiceContext = OpenClawPluginServiceContext;","entrypoint":"plugin-entry","exportName":"OpenClawPluginServiceContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1331,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginToolContext = OpenClawPluginToolContext;","entrypoint":"plugin-entry","exportName":"OpenClawPluginToolContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":110,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type OpenClawPluginToolFactory = OpenClawPluginToolFactory;","entrypoint":"plugin-entry","exportName":"OpenClawPluginToolFactory","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":131,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"plugin-entry","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":984,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"plugin-entry","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1115,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginInteractiveTelegramHandlerContext = PluginInteractiveTelegramHandlerContext;","entrypoint":"plugin-entry","exportName":"PluginInteractiveTelegramHandlerContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":1145,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type PluginLogger = PluginLogger;","entrypoint":"plugin-entry","exportName":"PluginLogger","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":66,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type ProviderAugmentModelCatalogContext = ProviderAugmentModelCatalogContext;","entrypoint":"plugin-entry","exportName":"ProviderAugmentModelCatalogContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":582,"sourcePath":"src/plugins/types.ts"}
{"declaration":"export type ProviderAuthContext = ProviderAuthContext;","entrypoint":"plugin-entry","exportName":"ProviderAuthContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export","sourceLine":166,"sourcePath":"src/plugins/types.ts"}

View File

@@ -6,6 +6,7 @@ import type {
PluginCommandContext,
} from "openclaw/plugin-sdk/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { executePluginCommand } from "../../src/plugins/commands.js";
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
import type { OpenClawPluginApi } from "./api.js";
import type { PendingPairingRequest } from "./notify.ts";
@@ -120,9 +121,12 @@ function createChannelRuntime(
}
function createCommandContext(params?: Partial<PluginCommandContext>): PluginCommandContext {
const surface = params?.surface ?? params?.channel ?? "webchat";
return {
channel: "webchat",
surface,
channel: surface,
isAuthorizedSender: true,
senderIsOwner: false,
commandBody: "/pair qr",
args: "qr",
config: {},
@@ -446,14 +450,20 @@ describe("device-pair /pair approve", () => {
});
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "approve latest",
commandBody: "/pair approve latest",
gatewayClientScopes: ["operator.write"],
}),
);
const result = await executePluginCommand({
command: {
...command,
pluginId: "device-pair",
},
senderId: "writer-1",
surface: "webchat",
channel: "webchat",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
args: "approve latest",
commandBody: "/pair approve latest",
config: {} as never,
});
expect(vi.mocked(approveDevicePairing)).not.toHaveBeenCalled();
expect(result).toEqual({
@@ -501,14 +511,20 @@ describe("device-pair /pair approve", () => {
});
const command = registerPairCommand();
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "approve latest",
commandBody: "/pair approve latest",
gatewayClientScopes: ["operator.write", "operator.pairing"],
}),
);
const result = await executePluginCommand({
command: {
...command,
pluginId: "device-pair",
},
senderId: "pairing-1",
surface: "webchat",
channel: "webchat",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write", "operator.pairing"],
args: "approve latest",
commandBody: "/pair approve latest",
config: {} as never,
});
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1");
expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." });

View File

@@ -546,13 +546,14 @@ export default definePluginEntry({
name: "pair",
description: "Generate setup codes and approve device pairing requests.",
acceptsArgs: true,
resolveRequiredGatewayScopes: (ctx) => {
const action = ctx.args?.trim().split(/\s+/, 1)[0]?.toLowerCase();
return action === "approve" ? ["operator.pairing"] : undefined;
},
handler: async (ctx) => {
const args = ctx.args?.trim() ?? "";
const tokens = args.split(/\s+/).filter(Boolean);
const action = tokens[0]?.toLowerCase() ?? "";
const gatewayClientScopes = Array.isArray(ctx.gatewayClientScopes)
? ctx.gatewayClientScopes
: null;
api.logger.info?.(
`device-pair: /pair invoked channel=${ctx.channel} sender=${ctx.senderId ?? "unknown"} action=${
action || "new"
@@ -574,15 +575,6 @@ export default definePluginEntry({
}
if (action === "approve") {
if (
gatewayClientScopes &&
!gatewayClientScopes.includes("operator.pairing") &&
!gatewayClientScopes.includes("operator.admin")
) {
return {
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
};
}
const requested = tokens[1]?.trim();
const list = await listDevicePairing();
if (list.pending.length === 0) {

View File

@@ -40,8 +40,10 @@ function createApi(params: {
function createCommandContext(args: string): PluginCommandContext {
return {
surface: "test",
channel: "test",
isAuthorizedSender: true,
senderIsOwner: false,
commandBody: `/phone ${args}`,
args,
config: {},

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { OperatorScope } from "../../src/gateway/method-scopes.js";
import type { OpenClawPluginCommandDefinition } from "../../test/helpers/extensions/plugin-command.js";
import { createPluginRuntimeMock } from "../../test/helpers/extensions/plugin-runtime-mock.js";
import register from "./index.js";
@@ -30,13 +31,15 @@ function createHarness(config: Record<string, unknown>) {
function createCommandContext(
args: string,
channel: string = "discord",
gatewayClientScopes?: string[],
gatewayClientScopes?: OperatorScope[],
) {
return {
args,
surface: channel,
channel,
channelId: channel,
isAuthorizedSender: true,
senderIsOwner: false,
gatewayClientScopes,
commandBody: args ? `/voice ${args}` : "/voice",
config: {},
@@ -47,7 +50,7 @@ function createCommandContext(
}
describe("talk-voice plugin", () => {
function createElevenlabsVoiceSetHarness(channel = "webchat", scopes?: string[]) {
function createElevenlabsVoiceSetHarness(channel = "webchat", scopes?: OperatorScope[]) {
const { command, runtime } = createHarness({
talk: {
provider: "elevenlabs",

View File

@@ -5,9 +5,24 @@
* This handler is called before built-in command handlers.
*/
import { isOperatorScope, type OperatorScope } from "../../gateway/method-scopes.js";
import { logVerbose } from "../../globals.js";
import { matchPluginCommand, executePluginCommand } from "../../plugins/commands.js";
import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
function narrowGatewayClientScopes(
scopes: readonly string[] | undefined,
): OperatorScope[] | undefined {
if (!scopes) {
return undefined;
}
const narrowed = scopes.filter((scope) => isOperatorScope(scope));
if (narrowed.length !== scopes.length) {
logVerbose("Plugin command handler ignored unknown gateway scope values");
}
return narrowed.length > 0 ? narrowed : undefined;
}
/**
* Handle plugin-registered commands.
* Returns a result if a plugin command was matched and executed,
@@ -34,10 +49,12 @@ export const handlePluginCommand: CommandHandler = async (
command: match.command,
args: match.args,
senderId: command.senderId,
surface: command.surface,
channel: command.channel,
channelId: command.channelId,
isAuthorizedSender: command.isAuthorizedSender,
gatewayClientScopes: params.ctx.GatewayClientScopes,
senderIsOwner: command.senderIsOwner,
gatewayClientScopes: narrowGatewayClientScopes(params.ctx.GatewayClientScopes),
commandBody: command.commandBodyNormalized,
config: cfg,
from: command.from,

View File

@@ -6,6 +6,14 @@ export const WRITE_SCOPE = "operator.write" as const;
export const APPROVALS_SCOPE = "operator.approvals" as const;
export const PAIRING_SCOPE = "operator.pairing" as const;
const ALL_OPERATOR_SCOPES = [
ADMIN_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
APPROVALS_SCOPE,
PAIRING_SCOPE,
] as const;
export type OperatorScope =
| typeof ADMIN_SCOPE
| typeof READ_SCOPE
@@ -13,13 +21,14 @@ export type OperatorScope =
| typeof APPROVALS_SCOPE
| typeof PAIRING_SCOPE;
export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [
ADMIN_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
APPROVALS_SCOPE,
PAIRING_SCOPE,
];
const OPERATOR_SCOPE_SET = new Set<string>(ALL_OPERATOR_SCOPES);
export const CLI_DEFAULT_OPERATOR_SCOPES: OperatorScope[] = [...ALL_OPERATOR_SCOPES];
/** Narrow an arbitrary scope string to a known operator scope. */
export function isOperatorScope(scope: string): scope is OperatorScope {
return OPERATOR_SCOPE_SET.has(scope);
}
const NODE_ROLE_METHODS = new Set([
"node.invoke.result",

View File

@@ -1,3 +1,10 @@
import {
ADMIN_SCOPE,
APPROVALS_SCOPE,
PAIRING_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
} from "../gateway/method-scopes.js";
import { logVerbose } from "../globals.js";
import {
clearPluginCommands,
@@ -87,6 +94,13 @@ export function validateCommandName(name: string): string | null {
export function validatePluginCommandDefinition(
command: OpenClawPluginCommandDefinition,
): string | null {
const knownOperatorScopes = new Set([
ADMIN_SCOPE,
APPROVALS_SCOPE,
PAIRING_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
]);
if (typeof command.handler !== "function") {
return "Command handler must be a function";
}
@@ -112,6 +126,23 @@ export function validatePluginCommandDefinition(
return `Native command alias "${label}" invalid: ${aliasError}`;
}
}
if (
command.requiredGatewayScopes !== undefined &&
!Array.isArray(command.requiredGatewayScopes)
) {
return "Command requiredGatewayScopes must be an array";
}
if (
command.resolveRequiredGatewayScopes !== undefined &&
typeof command.resolveRequiredGatewayScopes !== "function"
) {
return "Command resolveRequiredGatewayScopes must be a function";
}
for (const scope of command.requiredGatewayScopes ?? []) {
if (!knownOperatorScopes.has(scope)) {
return `Command requiredGatewayScopes contains unknown scope "${scope}"`;
}
}
return null;
}

View File

@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import {
__testing,
@@ -52,6 +52,17 @@ describe("registerPluginCommand", () => {
ok: false,
error: "Command description must be a string",
});
const invalidRequiredGatewayScopes = registerPluginCommand("demo-plugin", {
name: "secure",
description: "Secure command",
requiredGatewayScopes: ["operator.nope" as never],
handler: async () => ({ text: "ok" }),
});
expect(invalidRequiredGatewayScopes).toEqual({
ok: false,
error: 'Command requiredGatewayScopes contains unknown scope "operator.nope"',
});
});
it("normalizes command metadata for downstream consumers", () => {
@@ -331,3 +342,171 @@ describe("registerPluginCommand", () => {
);
});
});
describe("executePluginCommand", () => {
it("enforces owner requirements before running the handler", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const result = await executePluginCommand({
command: {
name: "owneronly",
description: "Owner-only command",
requireOwner: true,
handler,
pluginId: "demo-plugin",
},
channel: "discord",
senderId: "U123",
isAuthorizedSender: true,
senderIsOwner: false,
commandBody: "/owneronly",
config: {} as never,
});
expect(handler).not.toHaveBeenCalled();
expect(result).toEqual({ text: "⚠️ This command requires owner authorization." });
});
it("blocks internal callers missing required gateway scopes", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const result = await executePluginCommand({
command: {
name: "pairing",
description: "Pairing command",
requiredGatewayScopes: ["operator.pairing"],
handler,
pluginId: "demo-plugin",
},
surface: "webchat",
channel: "webchat",
senderId: "writer-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
commandBody: "/pairing",
config: {} as never,
});
expect(handler).not.toHaveBeenCalled();
expect(result).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
});
});
it("allows admin-scoped internal callers to bypass narrower scope requirements", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const result = await executePluginCommand({
command: {
name: "pairing",
description: "Pairing command",
requiredGatewayScopes: ["operator.pairing"],
handler,
pluginId: "demo-plugin",
},
surface: "webchat",
channel: "webchat",
senderId: "admin-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.admin"],
commandBody: "/pairing",
config: {} as never,
});
expect(handler).toHaveBeenCalledTimes(1);
expect(result).toEqual({ text: "ok" });
});
it("allows external callers to bypass gateway scope requirements", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const result = await executePluginCommand({
command: {
name: "pairing",
description: "Pairing command",
requiredGatewayScopes: ["operator.pairing"],
handler,
pluginId: "demo-plugin",
},
surface: "telegram",
channel: "telegram",
senderId: "123",
isAuthorizedSender: true,
commandBody: "/pairing",
config: {} as never,
});
expect(handler).toHaveBeenCalledTimes(1);
expect(result).toEqual({ text: "ok" });
});
it("supports context-sensitive gateway scope requirements", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const command: Parameters<typeof executePluginCommand>[0]["command"] = {
name: "pair",
description: "Pair command",
resolveRequiredGatewayScopes: (ctx) => {
const action = ctx.args?.trim().split(/\s+/, 1)[0]?.toLowerCase();
return action === "approve" ? ["operator.pairing"] : undefined;
},
handler,
pluginId: "demo-plugin",
};
const denied = await executePluginCommand({
command,
surface: "webchat",
channel: "webchat",
senderId: "writer-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
args: "approve latest",
commandBody: "/pair approve latest",
config: {} as never,
});
const allowed = await executePluginCommand({
command,
surface: "webchat",
channel: "webchat",
senderId: "writer-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
args: "qr",
commandBody: "/pair qr",
config: {} as never,
});
expect(handler).toHaveBeenCalledTimes(1);
expect(denied).toEqual({
text: "⚠️ This command requires operator.pairing for internal gateway callers.",
});
expect(allowed).toEqual({ text: "ok" });
});
it("returns a safe error reply when dynamic scope resolution throws", async () => {
const handler = vi.fn(async () => ({ text: "ok" }));
const result = await executePluginCommand({
command: {
name: "pair",
description: "Pair command",
resolveRequiredGatewayScopes: () => {
throw new Error("resolver exploded");
},
handler,
pluginId: "demo-plugin",
},
surface: "webchat",
channel: "webchat",
senderId: "writer-1",
isAuthorizedSender: true,
gatewayClientScopes: ["operator.write"],
commandBody: "/pair",
config: {} as never,
});
expect(handler).not.toHaveBeenCalled();
expect(result).toEqual({ text: "⚠️ Command failed. Please try again later." });
});
});

View File

@@ -7,7 +7,9 @@
import { parseExplicitTargetForChannel } from "../channels/plugins/target-parsing.js";
import type { OpenClawConfig } from "../config/config.js";
import { ADMIN_SCOPE, isOperatorScope, type OperatorScope } from "../gateway/method-scopes.js";
import { logVerbose } from "../globals.js";
import { isInternalMessageChannel } from "../utils/message-channel.js";
import {
clearPluginCommands,
clearPluginCommandsForPlugin,
@@ -28,6 +30,7 @@ import {
requestPluginConversationBinding,
} from "./conversation-binding.js";
import type {
PluginCommandAuthorizationContext,
OpenClawPluginCommandDefinition,
PluginCommandContext,
PluginCommandResult,
@@ -36,6 +39,25 @@ import type {
// Maximum allowed length for command arguments (defense in depth)
const MAX_ARGS_LENGTH = 4096;
function formatRequiredGatewayScopes(scopes: readonly string[]): string {
if (scopes.length === 0) {
return "gateway authorization";
}
if (scopes.length === 1) {
return scopes[0];
}
if (scopes.length === 2) {
return `${scopes[0]} and ${scopes[1]}`;
}
return `${scopes.slice(0, -1).join(", ")}, and ${scopes[scopes.length - 1]}`;
}
function buildMissingGatewayScopeReply(scopes: readonly string[]): PluginCommandResult {
return {
text: `⚠️ This command requires ${formatRequiredGatewayScopes(scopes)} for internal gateway callers.`,
};
}
export {
clearPluginCommands,
clearPluginCommandsForPlugin,
@@ -181,9 +203,11 @@ export async function executePluginCommand(params: {
command: RegisteredPluginCommand;
args?: string;
senderId?: string;
surface?: PluginCommandContext["surface"];
channel: string;
channelId?: PluginCommandContext["channelId"];
isAuthorizedSender: boolean;
senderIsOwner?: PluginCommandContext["senderIsOwner"];
gatewayClientScopes?: PluginCommandContext["gatewayClientScopes"];
commandBody: string;
config: OpenClawConfig;
@@ -192,7 +216,17 @@ export async function executePluginCommand(params: {
accountId?: PluginCommandContext["accountId"];
messageThreadId?: PluginCommandContext["messageThreadId"];
}): Promise<PluginCommandResult> {
const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params;
const {
command,
args,
senderId,
channel,
isAuthorizedSender,
commandBody,
config,
senderIsOwner = false,
} = params;
const surface = params.surface ?? channel;
// Check authorization
const requireAuth = command.requireAuth !== false; // Default to true
@@ -202,9 +236,67 @@ export async function executePluginCommand(params: {
);
return { text: "⚠️ This command requires authorization." };
}
// Sanitize args before passing to handler
if (command.requireOwner && !senderIsOwner) {
logVerbose(
`Plugin command /${command.name} blocked: non-owner sender ${senderId || "<unknown>"}`,
);
return { text: "⚠️ This command requires owner authorization." };
}
const sanitizedArgs = sanitizeArgs(args);
const authContext: PluginCommandAuthorizationContext = {
senderId,
surface,
channel,
channelId: params.channelId,
isAuthorizedSender,
senderIsOwner,
gatewayClientScopes: params.gatewayClientScopes,
args: sanitizedArgs,
commandBody,
config,
from: params.from,
to: params.to,
accountId: params.accountId,
messageThreadId: params.messageThreadId,
};
let dynamicRequiredGatewayScopes: OperatorScope[] = [];
if (command.resolveRequiredGatewayScopes) {
try {
const resolvedScopes = command.resolveRequiredGatewayScopes(authContext) as
| readonly string[]
| undefined;
dynamicRequiredGatewayScopes = (resolvedScopes ?? []).filter(
(scope): scope is OperatorScope => {
if (isOperatorScope(scope)) {
return true;
}
logVerbose(`Plugin command /${command.name} ignored unknown dynamic scope "${scope}"`);
return false;
},
);
} catch (err) {
const error = err as Error;
logVerbose(`Plugin command /${command.name} scope resolver error: ${error.message}`);
return { text: "⚠️ Command failed. Please try again later." };
}
}
const requiredGatewayScopes = Array.from(
new Set([...(command.requiredGatewayScopes ?? []), ...dynamicRequiredGatewayScopes]),
);
if (
requiredGatewayScopes.length > 0 &&
isInternalMessageChannel(surface) &&
!requiredGatewayScopes.every(
(scope) =>
params.gatewayClientScopes?.includes(scope) ||
params.gatewayClientScopes?.includes(ADMIN_SCOPE),
)
) {
logVerbose(
`Plugin command /${command.name} blocked: gateway caller missing scope ${requiredGatewayScopes.join(", ")}`,
);
return buildMissingGatewayScopeReply(requiredGatewayScopes);
}
const bindingConversation = resolveBindingConversationFromCommand({
channel,
from: params.from,
@@ -215,9 +307,11 @@ export async function executePluginCommand(params: {
const ctx: PluginCommandContext = {
senderId,
surface,
channel,
channelId: params.channelId,
isAuthorizedSender,
senderIsOwner,
gatewayClientScopes: params.gatewayClientScopes,
args: sanitizedArgs,
commandBody,

View File

@@ -984,14 +984,18 @@ export type OpenClawPluginGatewayMethod = {
export type PluginCommandContext = {
/** The sender's identifier (e.g., Telegram user ID) */
senderId?: string;
/** The inbound command surface (e.g., "webchat", "telegram") */
surface: string;
/** The channel/surface (e.g., "telegram", "discord") */
channel: string;
/** Provider channel id (e.g., "telegram") */
channelId?: ChannelId;
/** Whether the sender is on the allowlist */
isAuthorizedSender: boolean;
/** Whether the sender is treated as an owner-level caller */
senderIsOwner: boolean;
/** Gateway client scopes for internal control-plane callers */
gatewayClientScopes?: string[];
gatewayClientScopes?: OperatorScope[];
/** Raw command arguments after the command name */
args?: string;
/** The full normalized command body */
@@ -1068,6 +1072,24 @@ export type PluginConversationBindingResolvedEvent = {
};
};
export type PluginCommandAuthorizationContext = Pick<
PluginCommandContext,
| "senderId"
| "surface"
| "channel"
| "channelId"
| "isAuthorizedSender"
| "senderIsOwner"
| "gatewayClientScopes"
| "args"
| "commandBody"
| "config"
| "from"
| "to"
| "accountId"
| "messageThreadId"
>;
/**
* Result returned by a plugin command handler.
*/
@@ -1098,6 +1120,14 @@ export type OpenClawPluginCommandDefinition = {
acceptsArgs?: boolean;
/** Whether only authorized senders can use this command (default: true) */
requireAuth?: boolean;
/** Whether only owner-level callers can use this command (default: false) */
requireOwner?: boolean;
/** Gateway scopes required for internal control-plane callers */
requiredGatewayScopes?: readonly OperatorScope[];
/** Context-sensitive gateway scopes required for internal control-plane callers */
resolveRequiredGatewayScopes?: (
ctx: PluginCommandAuthorizationContext,
) => readonly OperatorScope[] | undefined;
/** The handler function */
handler: PluginCommandHandler;
};