mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-20 05:31:59 +08:00
Compare commits
3 Commits
vincentkoc
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b71d5609d9 | ||
|
|
6b47a95c6f | ||
|
|
190a6d3426 |
@@ -22,7 +22,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
|
||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
|
||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
|
||||
- Device pairing/setup codes: bind setup-code pairing to the intended node role and scope set so approval keeps the expected device profile. Thanks @vincentkoc.
|
||||
- Skills/tool policy: apply the full tool-policy pipeline to direct `/skill` dispatch so denied tools stay unavailable in inline actions too. Thanks @vincentkoc.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -407,12 +407,7 @@ export default function register(api: OpenClawPluginApi) {
|
||||
|
||||
const payload: SetupPayload = {
|
||||
url: urlResult.url,
|
||||
bootstrapToken: (
|
||||
await issueDeviceBootstrapToken({
|
||||
role: "node",
|
||||
scopes: [],
|
||||
})
|
||||
).token,
|
||||
bootstrapToken: (await issueDeviceBootstrapToken()).token,
|
||||
};
|
||||
|
||||
if (action === "qr") {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SkillCommandSpec } from "../../agents/skills.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import { clearInlineDirectives } from "./get-reply-directives-utils.js";
|
||||
import { buildTestCtx } from "./test-ctx.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
const handleCommandsMock = vi.fn();
|
||||
const gatewayExecuteMock = vi.fn();
|
||||
const readExecuteMock = vi.fn();
|
||||
|
||||
vi.mock("./commands.js", () => ({
|
||||
handleCommands: (...args: unknown[]) => handleCommandsMock(...args),
|
||||
buildStatusReply: vi.fn(),
|
||||
buildCommandContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/openclaw-tools.js", () => ({
|
||||
createOpenClawTools: () => [
|
||||
{
|
||||
name: "gateway",
|
||||
ownerOnly: true,
|
||||
execute: gatewayExecuteMock,
|
||||
},
|
||||
{
|
||||
name: "read",
|
||||
execute: readExecuteMock,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const { handleInlineActions } = await import("./get-reply-inline-actions.js");
|
||||
type HandleInlineActionsInput = Parameters<typeof handleInlineActions>[0];
|
||||
|
||||
const createTypingController = (): TypingController => ({
|
||||
onReplyStart: async () => {},
|
||||
startTypingLoop: async () => {},
|
||||
startTypingOnText: async () => {},
|
||||
refreshTypingTtl: () => {},
|
||||
isActive: () => false,
|
||||
markRunComplete: () => {},
|
||||
markDispatchIdle: () => {},
|
||||
cleanup: vi.fn(),
|
||||
});
|
||||
|
||||
const defaultSkillCommands: SkillCommandSpec[] = [
|
||||
{
|
||||
name: "danger-skill",
|
||||
skillName: "danger-skill",
|
||||
description: "Direct tool dispatch",
|
||||
dispatch: {
|
||||
kind: "tool",
|
||||
toolName: "gateway",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read-skill",
|
||||
skillName: "read-skill",
|
||||
description: "Allowed direct tool dispatch",
|
||||
dispatch: {
|
||||
kind: "tool",
|
||||
toolName: "read",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function createInput(overrides?: {
|
||||
body?: string;
|
||||
senderIsOwner?: boolean;
|
||||
skillCommands?: SkillCommandSpec[];
|
||||
}): HandleInlineActionsInput {
|
||||
const body = overrides?.body ?? "/danger-skill test";
|
||||
const ctx = buildTestCtx({
|
||||
Body: body,
|
||||
CommandBody: body,
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
From: "whatsapp:+123",
|
||||
To: "whatsapp:+123",
|
||||
});
|
||||
return {
|
||||
ctx,
|
||||
sessionCtx: ctx as unknown as TemplateContext,
|
||||
cfg: {
|
||||
commands: { text: true },
|
||||
tools: {
|
||||
deny: ["gateway"],
|
||||
},
|
||||
},
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: "/tmp",
|
||||
isGroup: false,
|
||||
typing: createTypingController(),
|
||||
allowTextCommands: true,
|
||||
inlineStatusRequested: false,
|
||||
command: {
|
||||
surface: "whatsapp",
|
||||
channel: "whatsapp",
|
||||
channelId: "whatsapp",
|
||||
ownerList: [],
|
||||
senderIsOwner: overrides?.senderIsOwner ?? true,
|
||||
isAuthorizedSender: true,
|
||||
senderId: "owner-1",
|
||||
abortKey: "whatsapp:+123",
|
||||
rawBodyNormalized: body,
|
||||
commandBodyNormalized: body,
|
||||
from: "whatsapp:+123",
|
||||
to: "whatsapp:+123",
|
||||
},
|
||||
directives: clearInlineDirectives(body),
|
||||
cleanedBody: body,
|
||||
elevatedEnabled: false,
|
||||
elevatedAllowed: false,
|
||||
elevatedFailures: [],
|
||||
defaultActivation: () => "always",
|
||||
resolvedThinkLevel: undefined,
|
||||
resolvedVerboseLevel: undefined,
|
||||
resolvedReasoningLevel: "off",
|
||||
resolvedElevatedLevel: "off",
|
||||
resolveDefaultThinkingLevel: async () => "off",
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
contextTokens: 0,
|
||||
abortedLastRun: false,
|
||||
sessionScope: "per-sender",
|
||||
skillCommands: overrides?.skillCommands ?? defaultSkillCommands,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleInlineActions skill tool dispatch", () => {
|
||||
beforeEach(() => {
|
||||
handleCommandsMock.mockReset();
|
||||
gatewayExecuteMock.mockReset().mockResolvedValue({ content: "EXECUTED" });
|
||||
readExecuteMock.mockReset().mockResolvedValue({ content: "READ" });
|
||||
});
|
||||
|
||||
it("applies the tool policy pipeline before direct /skill tool execution", async () => {
|
||||
const result = await handleInlineActions(createInput());
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "reply",
|
||||
reply: { text: "❌ Tool not available: gateway" },
|
||||
});
|
||||
expect(gatewayExecuteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("executes an allowed tool through direct /skill dispatch", async () => {
|
||||
const result = await handleInlineActions(createInput({ body: "/read-skill test" }));
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "reply",
|
||||
reply: { text: "READ" },
|
||||
});
|
||||
expect(readExecuteMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps owner-only tools blocked for non-owners before policy resolution", async () => {
|
||||
const result = await handleInlineActions(createInput({ senderIsOwner: false }));
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "reply",
|
||||
reply: { text: "❌ Tool not available: gateway" },
|
||||
});
|
||||
expect(gatewayExecuteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,26 @@
|
||||
import { collectTextContentBlocks } from "../../agents/content-blocks.js";
|
||||
import { createOpenClawTools } from "../../agents/openclaw-tools.js";
|
||||
import type { BlockReplyChunking } from "../../agents/pi-embedded-block-chunker.js";
|
||||
import {
|
||||
resolveEffectiveToolPolicy,
|
||||
resolveGroupToolPolicy,
|
||||
resolveSubagentToolPolicyForSession,
|
||||
} from "../../agents/pi-tools.policy.js";
|
||||
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js";
|
||||
import type { SkillCommandSpec } from "../../agents/skills.js";
|
||||
import { applyOwnerOnlyToolPolicy } from "../../agents/tool-policy.js";
|
||||
import {
|
||||
applyToolPolicyPipeline,
|
||||
buildDefaultToolPolicyPipelineSteps,
|
||||
} from "../../agents/tool-policy-pipeline.js";
|
||||
import { resolveToolProfilePolicy } from "../../agents/tool-policy-shared.js";
|
||||
import { applyOwnerOnlyToolPolicy, mergeAlsoAllowPolicy } from "../../agents/tool-policy.js";
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { generateSecureToken } from "../../infra/secure-random.js";
|
||||
import { getPluginToolMeta } from "../../plugins/tools.js";
|
||||
import { isSubagentSessionKey } from "../../routing/session-key.js";
|
||||
import { resolveGatewayMessageChannel } from "../../utils/message-channel.js";
|
||||
import {
|
||||
listReservedChatSlashCommandNames,
|
||||
@@ -85,6 +98,110 @@ function extractTextFromToolResult(result: any): string | null {
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function resolveSkillDispatchTools(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
workspaceDir: string;
|
||||
agentDir?: string;
|
||||
provider: string;
|
||||
senderIsOwner: boolean;
|
||||
senderId?: string;
|
||||
}) {
|
||||
const channel =
|
||||
resolveGatewayMessageChannel(params.ctx.Surface) ??
|
||||
resolveGatewayMessageChannel(params.ctx.Provider) ??
|
||||
undefined;
|
||||
const tools = createOpenClawTools({
|
||||
agentSessionKey: params.sessionKey,
|
||||
agentChannel: channel,
|
||||
agentAccountId: (params.ctx as { AccountId?: string }).AccountId,
|
||||
agentTo: params.ctx.OriginatingTo ?? params.ctx.To,
|
||||
agentThreadId: params.ctx.MessageThreadId ?? undefined,
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
config: params.cfg,
|
||||
requesterSenderId: params.senderId ?? undefined,
|
||||
senderIsOwner: params.senderIsOwner,
|
||||
});
|
||||
const toolsByAuthorization = applyOwnerOnlyToolPolicy(tools, params.senderIsOwner);
|
||||
const {
|
||||
agentId: resolvedAgentId,
|
||||
globalPolicy,
|
||||
globalProviderPolicy,
|
||||
agentPolicy,
|
||||
agentProviderPolicy,
|
||||
profile,
|
||||
providerProfile,
|
||||
profileAlsoAllow,
|
||||
providerProfileAlsoAllow,
|
||||
} = resolveEffectiveToolPolicy({
|
||||
config: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
modelProvider: params.provider,
|
||||
});
|
||||
const groupCtx = params.ctx as {
|
||||
AccountId?: string;
|
||||
GroupID?: string;
|
||||
GroupChannel?: string;
|
||||
GroupSpace?: string;
|
||||
SenderId?: string;
|
||||
SenderName?: string;
|
||||
SenderUsername?: string;
|
||||
SenderE164?: string;
|
||||
};
|
||||
const groupPolicy = resolveGroupToolPolicy({
|
||||
config: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider: channel,
|
||||
groupId: groupCtx.GroupID,
|
||||
groupChannel: groupCtx.GroupChannel,
|
||||
groupSpace: groupCtx.GroupSpace,
|
||||
accountId: groupCtx.AccountId,
|
||||
senderId: groupCtx.SenderId,
|
||||
senderName: groupCtx.SenderName,
|
||||
senderUsername: groupCtx.SenderUsername,
|
||||
senderE164: groupCtx.SenderE164,
|
||||
});
|
||||
const profilePolicy = mergeAlsoAllowPolicy(resolveToolProfilePolicy(profile), profileAlsoAllow);
|
||||
const providerProfilePolicy = mergeAlsoAllowPolicy(
|
||||
resolveToolProfilePolicy(providerProfile),
|
||||
providerProfileAlsoAllow,
|
||||
);
|
||||
const sandboxRuntime = resolveSandboxRuntimeStatus({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
const sandboxPolicy = sandboxRuntime.sandboxed ? sandboxRuntime.toolPolicy : undefined;
|
||||
const subagentPolicy = isSubagentSessionKey(params.sessionKey)
|
||||
? resolveSubagentToolPolicyForSession(params.cfg, params.sessionKey)
|
||||
: undefined;
|
||||
|
||||
return applyToolPolicyPipeline({
|
||||
tools: toolsByAuthorization,
|
||||
toolMeta: (tool) => getPluginToolMeta(tool),
|
||||
warn: logVerbose,
|
||||
steps: [
|
||||
...buildDefaultToolPolicyPipelineSteps({
|
||||
profilePolicy,
|
||||
profile,
|
||||
providerProfilePolicy,
|
||||
providerProfile,
|
||||
globalPolicy,
|
||||
globalProviderPolicy,
|
||||
agentPolicy,
|
||||
agentProviderPolicy,
|
||||
groupPolicy,
|
||||
agentId: resolvedAgentId,
|
||||
}),
|
||||
{ policy: sandboxPolicy, label: "sandbox tools.allow" },
|
||||
{ policy: subagentPolicy, label: "subagent tools.allow" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleInlineActions(params: {
|
||||
ctx: MsgContext;
|
||||
sessionCtx: TemplateContext;
|
||||
@@ -206,22 +323,17 @@ export async function handleInlineActions(params: {
|
||||
const dispatch = skillInvocation.command.dispatch;
|
||||
if (dispatch?.kind === "tool") {
|
||||
const rawArgs = (skillInvocation.args ?? "").trim();
|
||||
const channel =
|
||||
resolveGatewayMessageChannel(ctx.Surface) ??
|
||||
resolveGatewayMessageChannel(ctx.Provider) ??
|
||||
undefined;
|
||||
|
||||
const tools = createOpenClawTools({
|
||||
agentSessionKey: sessionKey,
|
||||
agentChannel: channel,
|
||||
agentAccountId: (ctx as { AccountId?: string }).AccountId,
|
||||
agentTo: ctx.OriginatingTo ?? ctx.To,
|
||||
agentThreadId: ctx.MessageThreadId ?? undefined,
|
||||
agentDir,
|
||||
const authorizedTools = resolveSkillDispatchTools({
|
||||
ctx,
|
||||
cfg,
|
||||
agentId,
|
||||
sessionKey,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
agentDir,
|
||||
provider,
|
||||
senderIsOwner: command.senderIsOwner,
|
||||
senderId: command.senderId,
|
||||
});
|
||||
const authorizedTools = applyOwnerOnlyToolPolicy(tools, command.senderIsOwner);
|
||||
|
||||
const tool = authorizedTools.find((candidate) => candidate.name === dispatch.toolName);
|
||||
if (!tool) {
|
||||
|
||||
@@ -43,22 +43,6 @@ describe("device bootstrap tokens", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("persists an intended role and scopes when requested", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
baseDir,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
});
|
||||
|
||||
const raw = await fs.readFile(resolveBootstrapPath(baseDir), "utf8");
|
||||
const parsed = JSON.parse(raw) as Record<string, { roles?: string[]; scopes?: string[] }>;
|
||||
expect(parsed[issued.token]).toMatchObject({
|
||||
roles: ["node"],
|
||||
scopes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies valid bootstrap tokens once and deletes them after success", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({ baseDir });
|
||||
@@ -217,64 +201,4 @@ describe("device bootstrap tokens", () => {
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||
});
|
||||
|
||||
it("rejects a role that does not match the issued pairing profile", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
baseDir,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyDeviceBootstrapToken({
|
||||
token: issued.token,
|
||||
deviceId: "device-123",
|
||||
publicKey: "public-key-123",
|
||||
role: "operator",
|
||||
scopes: [],
|
||||
baseDir,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||
});
|
||||
|
||||
it("accepts constrained tokens when the requested role and scopes match", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
baseDir,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyDeviceBootstrapToken({
|
||||
token: issued.token,
|
||||
deviceId: "device-123",
|
||||
publicKey: "public-key-123",
|
||||
role: "node",
|
||||
scopes: [],
|
||||
baseDir,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects scopes that do not match the issued pairing profile", async () => {
|
||||
const baseDir = await createTempDir();
|
||||
const issued = await issueDeviceBootstrapToken({
|
||||
baseDir,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyDeviceBootstrapToken({
|
||||
token: issued.token,
|
||||
deviceId: "device-123",
|
||||
publicKey: "public-key-123",
|
||||
role: "node",
|
||||
scopes: ["operator.admin"],
|
||||
baseDir,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false, reason: "bootstrap_token_invalid" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { normalizeDeviceAuthRole, normalizeDeviceAuthScopes } from "../shared/device-auth.js";
|
||||
import { resolvePairingPaths } from "./pairing-files.js";
|
||||
import {
|
||||
createAsyncLock,
|
||||
@@ -64,24 +63,16 @@ async function persistState(state: DeviceBootstrapStateFile, baseDir?: string):
|
||||
export async function issueDeviceBootstrapToken(
|
||||
params: {
|
||||
baseDir?: string;
|
||||
role?: string;
|
||||
scopes?: readonly string[];
|
||||
} = {},
|
||||
): Promise<{ token: string; expiresAtMs: number }> {
|
||||
return await withLock(async () => {
|
||||
const state = await loadState(params.baseDir);
|
||||
const token = generatePairingToken();
|
||||
const issuedAtMs = Date.now();
|
||||
const role = params.role?.trim();
|
||||
const scopes = normalizeDeviceAuthScopes(
|
||||
Array.isArray(params.scopes) ? [...params.scopes] : undefined,
|
||||
);
|
||||
state[token] = {
|
||||
token,
|
||||
ts: issuedAtMs,
|
||||
issuedAtMs,
|
||||
...(role ? { roles: [normalizeDeviceAuthRole(role)] } : {}),
|
||||
...(scopes.length > 0 || Array.isArray(params.scopes) ? { scopes } : {}),
|
||||
};
|
||||
await persistState(state, params.baseDir);
|
||||
return { token, expiresAtMs: issuedAtMs + DEVICE_BOOTSTRAP_TOKEN_TTL_MS };
|
||||
@@ -111,28 +102,10 @@ export async function verifyDeviceBootstrapToken(params: {
|
||||
|
||||
const deviceId = params.deviceId.trim();
|
||||
const publicKey = params.publicKey.trim();
|
||||
const role = normalizeDeviceAuthRole(params.role);
|
||||
const requestedScopes = normalizeDeviceAuthScopes([...params.scopes]);
|
||||
const role = params.role.trim();
|
||||
if (!deviceId || !publicKey || !role) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
const allowedRoles = Array.isArray(entry.roles)
|
||||
? entry.roles.map((value) => normalizeDeviceAuthRole(String(value))).filter(Boolean)
|
||||
: [];
|
||||
if (allowedRoles.length > 0 && !allowedRoles.includes(role)) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
if (Array.isArray(entry.scopes)) {
|
||||
const allowedScopes = normalizeDeviceAuthScopes(entry.scopes);
|
||||
// Both arrays are normalized through normalizeDeviceAuthScopes, which
|
||||
// sorts and deduplicates them before comparison.
|
||||
if (
|
||||
allowedScopes.length !== requestedScopes.length ||
|
||||
allowedScopes.some((value, index) => value !== requestedScopes[index])
|
||||
) {
|
||||
return { ok: false, reason: "bootstrap_token_invalid" };
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap setup codes are single-use. Consume the record before returning
|
||||
// success so the same token cannot be replayed to mutate a pending request.
|
||||
|
||||
@@ -400,8 +400,6 @@ export async function resolvePairingSetupFromConfig(
|
||||
bootstrapToken: (
|
||||
await issueDeviceBootstrapToken({
|
||||
baseDir: options.pairingBaseDir,
|
||||
role: "node",
|
||||
scopes: [],
|
||||
})
|
||||
).token,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user