mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 03:01:50 +03:00
Gateway/Plugins: device pairing + phone control plugins (#11755)
This commit is contained in:
@@ -35,9 +35,15 @@ export const handlePluginCommand: CommandHandler = async (
|
||||
args: match.args,
|
||||
senderId: command.senderId,
|
||||
channel: command.channel,
|
||||
channelId: command.channelId,
|
||||
isAuthorizedSender: command.isAuthorizedSender,
|
||||
commandBody: command.commandBodyNormalized,
|
||||
config: cfg,
|
||||
from: command.from,
|
||||
to: command.to,
|
||||
accountId: params.ctx.AccountId ?? undefined,
|
||||
messageThreadId:
|
||||
typeof params.ctx.MessageThreadId === "number" ? params.ctx.MessageThreadId : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_DANGEROUS_NODE_COMMANDS,
|
||||
resolveNodeCommandAllowlist,
|
||||
} from "./node-command-policy.js";
|
||||
|
||||
describe("resolveNodeCommandAllowlist", () => {
|
||||
it("includes iOS service commands by default", () => {
|
||||
const allow = resolveNodeCommandAllowlist(
|
||||
{},
|
||||
{
|
||||
platform: "ios 26.0",
|
||||
deviceFamily: "iPhone",
|
||||
},
|
||||
);
|
||||
|
||||
expect(allow.has("device.info")).toBe(true);
|
||||
expect(allow.has("device.status")).toBe(true);
|
||||
expect(allow.has("system.notify")).toBe(true);
|
||||
expect(allow.has("contacts.search")).toBe(true);
|
||||
expect(allow.has("calendar.events")).toBe(true);
|
||||
expect(allow.has("reminders.list")).toBe(true);
|
||||
expect(allow.has("photos.latest")).toBe(true);
|
||||
expect(allow.has("motion.activity")).toBe(true);
|
||||
|
||||
for (const cmd of DEFAULT_DANGEROUS_NODE_COMMANDS) {
|
||||
expect(allow.has(cmd)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("can explicitly allow dangerous commands via allowCommands", () => {
|
||||
const allow = resolveNodeCommandAllowlist(
|
||||
{
|
||||
gateway: {
|
||||
nodes: {
|
||||
allowCommands: ["camera.snap", "screen.record"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ platform: "ios", deviceFamily: "iPhone" },
|
||||
);
|
||||
expect(allow.has("camera.snap")).toBe(true);
|
||||
expect(allow.has("screen.record")).toBe(true);
|
||||
expect(allow.has("camera.clip")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -12,13 +12,32 @@ const CANVAS_COMMANDS = [
|
||||
"canvas.a2ui.reset",
|
||||
];
|
||||
|
||||
const CAMERA_COMMANDS = ["camera.list", "camera.snap", "camera.clip"];
|
||||
const CAMERA_COMMANDS = ["camera.list"];
|
||||
const CAMERA_DANGEROUS_COMMANDS = ["camera.snap", "camera.clip"];
|
||||
|
||||
const SCREEN_COMMANDS = ["screen.record"];
|
||||
const SCREEN_DANGEROUS_COMMANDS = ["screen.record"];
|
||||
|
||||
const LOCATION_COMMANDS = ["location.get"];
|
||||
|
||||
const SMS_COMMANDS = ["sms.send"];
|
||||
const DEVICE_COMMANDS = ["device.info", "device.status"];
|
||||
|
||||
const CONTACTS_COMMANDS = ["contacts.search"];
|
||||
const CONTACTS_DANGEROUS_COMMANDS = ["contacts.add"];
|
||||
|
||||
const CALENDAR_COMMANDS = ["calendar.events"];
|
||||
const CALENDAR_DANGEROUS_COMMANDS = ["calendar.add"];
|
||||
|
||||
const REMINDERS_COMMANDS = ["reminders.list"];
|
||||
const REMINDERS_DANGEROUS_COMMANDS = ["reminders.add"];
|
||||
|
||||
const PHOTOS_COMMANDS = ["photos.latest"];
|
||||
|
||||
const MOTION_COMMANDS = ["motion.activity", "motion.pedometer"];
|
||||
|
||||
const SMS_DANGEROUS_COMMANDS = ["sms.send"];
|
||||
|
||||
// iOS nodes don't implement system.run/which, but they do support notifications.
|
||||
const IOS_SYSTEM_COMMANDS = ["system.notify"];
|
||||
|
||||
const SYSTEM_COMMANDS = [
|
||||
"system.run",
|
||||
@@ -29,32 +48,56 @@ const SYSTEM_COMMANDS = [
|
||||
"browser.proxy",
|
||||
];
|
||||
|
||||
// "High risk" node commands. These can be enabled by explicitly adding them to
|
||||
// `gateway.nodes.allowCommands` (and ensuring they're not blocked by denyCommands).
|
||||
export const DEFAULT_DANGEROUS_NODE_COMMANDS = [
|
||||
...CAMERA_DANGEROUS_COMMANDS,
|
||||
...SCREEN_DANGEROUS_COMMANDS,
|
||||
...CONTACTS_DANGEROUS_COMMANDS,
|
||||
...CALENDAR_DANGEROUS_COMMANDS,
|
||||
...REMINDERS_DANGEROUS_COMMANDS,
|
||||
...SMS_DANGEROUS_COMMANDS,
|
||||
];
|
||||
|
||||
const PLATFORM_DEFAULTS: Record<string, string[]> = {
|
||||
ios: [...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...SCREEN_COMMANDS, ...LOCATION_COMMANDS],
|
||||
ios: [
|
||||
...CANVAS_COMMANDS,
|
||||
...CAMERA_COMMANDS,
|
||||
...LOCATION_COMMANDS,
|
||||
...DEVICE_COMMANDS,
|
||||
...CONTACTS_COMMANDS,
|
||||
...CALENDAR_COMMANDS,
|
||||
...REMINDERS_COMMANDS,
|
||||
...PHOTOS_COMMANDS,
|
||||
...MOTION_COMMANDS,
|
||||
...IOS_SYSTEM_COMMANDS,
|
||||
],
|
||||
android: [
|
||||
...CANVAS_COMMANDS,
|
||||
...CAMERA_COMMANDS,
|
||||
...SCREEN_COMMANDS,
|
||||
...LOCATION_COMMANDS,
|
||||
...SMS_COMMANDS,
|
||||
...DEVICE_COMMANDS,
|
||||
...CONTACTS_COMMANDS,
|
||||
...CALENDAR_COMMANDS,
|
||||
...REMINDERS_COMMANDS,
|
||||
...PHOTOS_COMMANDS,
|
||||
...MOTION_COMMANDS,
|
||||
],
|
||||
macos: [
|
||||
...CANVAS_COMMANDS,
|
||||
...CAMERA_COMMANDS,
|
||||
...SCREEN_COMMANDS,
|
||||
...LOCATION_COMMANDS,
|
||||
...DEVICE_COMMANDS,
|
||||
...CONTACTS_COMMANDS,
|
||||
...CALENDAR_COMMANDS,
|
||||
...REMINDERS_COMMANDS,
|
||||
...PHOTOS_COMMANDS,
|
||||
...MOTION_COMMANDS,
|
||||
...SYSTEM_COMMANDS,
|
||||
],
|
||||
linux: [...SYSTEM_COMMANDS],
|
||||
windows: [...SYSTEM_COMMANDS],
|
||||
unknown: [
|
||||
...CANVAS_COMMANDS,
|
||||
...CAMERA_COMMANDS,
|
||||
...SCREEN_COMMANDS,
|
||||
...LOCATION_COMMANDS,
|
||||
...SMS_COMMANDS,
|
||||
...SYSTEM_COMMANDS,
|
||||
],
|
||||
unknown: [...CANVAS_COMMANDS, ...CAMERA_COMMANDS, ...LOCATION_COMMANDS, ...SYSTEM_COMMANDS],
|
||||
};
|
||||
|
||||
function normalizePlatformId(platform?: string, deviceFamily?: string): string {
|
||||
|
||||
@@ -368,7 +368,8 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { cfg, entry } = loadSessionEntry(p.sessionKey);
|
||||
const rawSessionKey = p.sessionKey;
|
||||
const { cfg, entry, canonicalKey: sessionKey } = loadSessionEntry(rawSessionKey);
|
||||
const timeoutMs = resolveAgentTimeoutMs({
|
||||
cfg,
|
||||
overrideMs: p.timeoutMs,
|
||||
@@ -379,7 +380,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
const sendPolicy = resolveSendPolicy({
|
||||
cfg,
|
||||
entry,
|
||||
sessionKey: p.sessionKey,
|
||||
sessionKey,
|
||||
channel: entry?.channel,
|
||||
chatType: entry?.chatType,
|
||||
});
|
||||
@@ -404,7 +405,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
broadcast: context.broadcast,
|
||||
nodeSendToSession: context.nodeSendToSession,
|
||||
},
|
||||
{ sessionKey: p.sessionKey, stopReason: "stop" },
|
||||
{ sessionKey: rawSessionKey, stopReason: "stop" },
|
||||
);
|
||||
respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds });
|
||||
return;
|
||||
@@ -432,7 +433,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
context.chatAbortControllers.set(clientRunId, {
|
||||
controller: abortController,
|
||||
sessionId: entry?.sessionId ?? clientRunId,
|
||||
sessionKey: p.sessionKey,
|
||||
sessionKey: rawSessionKey,
|
||||
startedAtMs: now,
|
||||
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
|
||||
});
|
||||
@@ -459,7 +460,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
BodyForCommands: commandBody,
|
||||
RawBody: parsedMessage,
|
||||
CommandBody: commandBody,
|
||||
SessionKey: p.sessionKey,
|
||||
SessionKey: sessionKey,
|
||||
Provider: INTERNAL_MESSAGE_CHANNEL,
|
||||
Surface: INTERNAL_MESSAGE_CHANNEL,
|
||||
OriginatingChannel: INTERNAL_MESSAGE_CHANNEL,
|
||||
@@ -473,7 +474,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
};
|
||||
|
||||
const agentId = resolveSessionAgentId({
|
||||
sessionKey: p.sessionKey,
|
||||
sessionKey,
|
||||
config: cfg,
|
||||
});
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
@@ -532,9 +533,8 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
.trim();
|
||||
let message: Record<string, unknown> | undefined;
|
||||
if (combinedReply) {
|
||||
const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(
|
||||
p.sessionKey,
|
||||
);
|
||||
const { storePath: latestStorePath, entry: latestEntry } =
|
||||
loadSessionEntry(sessionKey);
|
||||
const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId;
|
||||
const appended = appendAssistantTranscriptMessage({
|
||||
message: combinedReply,
|
||||
@@ -562,7 +562,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
broadcastChatFinal({
|
||||
context,
|
||||
runId: clientRunId,
|
||||
sessionKey: p.sessionKey,
|
||||
sessionKey: rawSessionKey,
|
||||
message,
|
||||
});
|
||||
}
|
||||
@@ -587,7 +587,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
broadcastChatError({
|
||||
context,
|
||||
runId: clientRunId,
|
||||
sessionKey: p.sessionKey,
|
||||
sessionKey: rawSessionKey,
|
||||
errorMessage: String(err),
|
||||
});
|
||||
})
|
||||
@@ -632,7 +632,8 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
};
|
||||
|
||||
// Load session to find transcript file
|
||||
const { storePath, entry } = loadSessionEntry(p.sessionKey);
|
||||
const rawSessionKey = p.sessionKey;
|
||||
const { storePath, entry } = loadSessionEntry(rawSessionKey);
|
||||
const sessionId = entry?.sessionId;
|
||||
if (!sessionId || !storePath) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "session not found"));
|
||||
@@ -687,13 +688,13 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
// Broadcast to webchat for immediate UI update
|
||||
const chatPayload = {
|
||||
runId: `inject-${messageId}`,
|
||||
sessionKey: p.sessionKey,
|
||||
sessionKey: rawSessionKey,
|
||||
seq: 0,
|
||||
state: "final" as const,
|
||||
message: transcriptEntry.message,
|
||||
};
|
||||
context.broadcast("chat", chatPayload);
|
||||
context.nodeSendToSession(p.sessionKey, "chat", chatPayload);
|
||||
context.nodeSendToSession(rawSessionKey, "chat", chatPayload);
|
||||
|
||||
respond(true, { ok: true, messageId });
|
||||
},
|
||||
|
||||
@@ -122,6 +122,11 @@ export { resolveAckReaction } from "../agents/identity.js";
|
||||
export type { ReplyPayload } from "../auto-reply/types.js";
|
||||
export type { ChunkMode } from "../auto-reply/chunk.js";
|
||||
export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js";
|
||||
export {
|
||||
approveDevicePairing,
|
||||
listDevicePairing,
|
||||
rejectDevicePairing,
|
||||
} from "../infra/device-pairing.js";
|
||||
export { resolveToolsBySender } from "../config/group-policy.js";
|
||||
export {
|
||||
buildPendingHistoryContextFromMap,
|
||||
|
||||
@@ -229,9 +229,14 @@ export async function executePluginCommand(params: {
|
||||
args?: string;
|
||||
senderId?: string;
|
||||
channel: string;
|
||||
channelId?: PluginCommandContext["channelId"];
|
||||
isAuthorizedSender: boolean;
|
||||
commandBody: string;
|
||||
config: OpenClawConfig;
|
||||
from?: PluginCommandContext["from"];
|
||||
to?: PluginCommandContext["to"];
|
||||
accountId?: PluginCommandContext["accountId"];
|
||||
messageThreadId?: PluginCommandContext["messageThreadId"];
|
||||
}): Promise<PluginCommandResult> {
|
||||
const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params;
|
||||
|
||||
@@ -250,10 +255,15 @@ export async function executePluginCommand(params: {
|
||||
const ctx: PluginCommandContext = {
|
||||
senderId,
|
||||
channel,
|
||||
channelId: params.channelId,
|
||||
isAuthorizedSender,
|
||||
args: sanitizedArgs,
|
||||
commandBody,
|
||||
config,
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
accountId: params.accountId,
|
||||
messageThreadId: params.messageThreadId,
|
||||
};
|
||||
|
||||
// Lock registry during execution to prevent concurrent modifications
|
||||
|
||||
@@ -13,7 +13,11 @@ export type NormalizedPluginsConfig = {
|
||||
entries: Record<string, { enabled?: boolean; config?: unknown }>;
|
||||
};
|
||||
|
||||
export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>();
|
||||
export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
|
||||
"device-pair",
|
||||
"phone-control",
|
||||
"talk-voice",
|
||||
]);
|
||||
|
||||
const normalizeList = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
|
||||
+11
-1
@@ -5,7 +5,7 @@ import type { AuthProfileCredential, OAuthCredential } from "../agents/auth-prof
|
||||
import type { AnyAgentTool } from "../agents/tools/common.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type { ChannelDock } from "../channels/dock.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { createVpsAwareOAuthHandlers } from "../commands/oauth-flow.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { ModelProviderConfig } from "../config/types.js";
|
||||
@@ -140,6 +140,8 @@ export type PluginCommandContext = {
|
||||
senderId?: 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;
|
||||
/** Raw command arguments after the command name */
|
||||
@@ -148,6 +150,14 @@ export type PluginCommandContext = {
|
||||
commandBody: string;
|
||||
/** Current OpenClaw configuration */
|
||||
config: OpenClawConfig;
|
||||
/** Raw "From" value (channel-scoped id) */
|
||||
from?: string;
|
||||
/** Raw "To" value (channel-scoped id) */
|
||||
to?: string;
|
||||
/** Account id for multi-account channels */
|
||||
accountId?: string;
|
||||
/** Thread/topic id if available */
|
||||
messageThreadId?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -675,6 +675,10 @@ export const registerTelegramNativeCommands = ({
|
||||
isForum,
|
||||
messageThreadId,
|
||||
});
|
||||
const from = isGroup
|
||||
? buildTelegramGroupFrom(chatId, threadSpec.id)
|
||||
: `telegram:${chatId}`;
|
||||
const to = `telegram:${chatId}`;
|
||||
|
||||
const result = await executePluginCommand({
|
||||
command: match.command,
|
||||
@@ -684,6 +688,10 @@ export const registerTelegramNativeCommands = ({
|
||||
isAuthorizedSender: commandAuthorized,
|
||||
commandBody,
|
||||
config: cfg,
|
||||
from,
|
||||
to,
|
||||
accountId,
|
||||
messageThreadId: threadSpec.id,
|
||||
});
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
|
||||
@@ -64,5 +64,13 @@ describe("configureGatewayForOnboarding", () => {
|
||||
});
|
||||
|
||||
expect(result.settings.gatewayToken).toBe("generated-token");
|
||||
expect(result.nextConfig.gateway?.nodes?.denyCommands).toEqual([
|
||||
"camera.snap",
|
||||
"camera.clip",
|
||||
"screen.record",
|
||||
"calendar.add",
|
||||
"contacts.add",
|
||||
"reminders.add",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,20 @@ import type { WizardPrompter } from "./prompts.js";
|
||||
import { normalizeGatewayTokenInput, randomToken } from "../commands/onboard-helpers.js";
|
||||
import { findTailscaleBinary } from "../infra/tailscale.js";
|
||||
|
||||
// These commands are "high risk" (privacy writes/recording) and should be
|
||||
// explicitly armed by the user when they want to use them.
|
||||
//
|
||||
// This only affects what the gateway will accept via node.invoke; the iOS app
|
||||
// still prompts for OS permissions (camera/photos/contacts/etc) on first use.
|
||||
const DEFAULT_DANGEROUS_NODE_DENY_COMMANDS = [
|
||||
"camera.snap",
|
||||
"camera.clip",
|
||||
"screen.record",
|
||||
"calendar.add",
|
||||
"contacts.add",
|
||||
"reminders.add",
|
||||
];
|
||||
|
||||
type ConfigureGatewayOptions = {
|
||||
flow: WizardFlow;
|
||||
baseConfig: OpenClawConfig;
|
||||
@@ -236,6 +250,27 @@ export async function configureGatewayForOnboarding(
|
||||
},
|
||||
};
|
||||
|
||||
// If this is a new gateway setup (no existing gateway settings), start with a
|
||||
// denylist for high-risk node commands. Users can arm these temporarily via
|
||||
// /phone arm ... (phone-control plugin).
|
||||
if (
|
||||
!quickstartGateway.hasExisting &&
|
||||
nextConfig.gateway?.nodes?.denyCommands === undefined &&
|
||||
nextConfig.gateway?.nodes?.allowCommands === undefined &&
|
||||
nextConfig.gateway?.nodes?.browser === undefined
|
||||
) {
|
||||
nextConfig = {
|
||||
...nextConfig,
|
||||
gateway: {
|
||||
...nextConfig.gateway,
|
||||
nodes: {
|
||||
...nextConfig.gateway?.nodes,
|
||||
denyCommands: [...DEFAULT_DANGEROUS_NODE_DENY_COMMANDS],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
nextConfig,
|
||||
settings: {
|
||||
|
||||
Reference in New Issue
Block a user