Gateway/Plugins: device pairing + phone control plugins (#11755)

This commit is contained in:
Mariano Belinky
2026-02-08 18:07:13 +01:00
parent 2f91bf550f
commit 730f86dd5c
24 changed files with 1960 additions and 31 deletions

View File

@@ -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);
});
});

View File

@@ -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 {

View File

@@ -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 });
},