Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
// Device Pair API module exposes the plugin public contract.
export {
approveDevicePairing,
clearDeviceBootstrapTokens,
issueDeviceBootstrapToken,
PAIRING_SETUP_BOOTSTRAP_PROFILE,
listDevicePairing,
revokeDeviceBootstrapToken,
type DeviceBootstrapProfile,
} from "openclaw/plugin-sdk/device-bootstrap";
export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
export {
resolveGatewayBindUrl,
resolveGatewayPort,
resolveTailnetHostWithRunner,
} from "openclaw/plugin-sdk/core";
export { resolveAdvertisedLanHost } from "openclaw/plugin-sdk/gateway-runtime";
export {
resolvePreferredOpenClawTmpDir,
runPluginCommandWithTimeout,
} from "openclaw/plugin-sdk/sandbox";
export { renderQrPngBase64, renderQrPngDataUrl, writeQrPngTempFile } from "./qr-image.js";

View File

@@ -0,0 +1,120 @@
// Device Pair tests cover doctor migration of legacy notify state.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import type {
OpenKeyedStoreOptions,
PluginDoctorStateMigrationContext,
} from "openclaw/plugin-sdk/runtime-doctor";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { stateMigrations } from "./doctor-contract-api.js";
import {
DEVICE_PAIR_NOTIFY_LEGACY_STATE_FILE,
DEVICE_PAIR_NOTIFY_SUBSCRIBER_MAX_ENTRIES,
DEVICE_PAIR_NOTIFY_SUBSCRIBER_NAMESPACE,
notifySubscriberStoreKey,
type NotifySubscription,
} from "./notify-state.js";
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
return {
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
return createPluginStateKeyedStoreForTests<T>("device-pair", {
...options,
env: options.env ?? env,
});
},
};
}
describe("device-pair doctor notify migration", () => {
let stateDir = "";
let env: NodeJS.ProcessEnv;
beforeEach(async () => {
resetPluginStateStoreForTests();
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-device-pair-doctor-"));
env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
});
afterEach(async () => {
await fs.rm(stateDir, { recursive: true, force: true });
});
function migrationParams() {
return {
config: {},
env,
stateDir,
oauthDir: path.join(stateDir, "oauth"),
context: createDoctorContext(env),
};
}
it("imports legacy notify subscribers into plugin state", async () => {
const sourcePath = path.join(stateDir, DEVICE_PAIR_NOTIFY_LEGACY_STATE_FILE);
const subscriber: NotifySubscription = {
to: "chat-123",
accountId: "telegram-default",
messageThreadId: 271,
mode: "persistent",
addedAtMs: 1,
};
await fs.writeFile(
sourcePath,
JSON.stringify({
subscribers: [subscriber],
notifiedRequestIds: { stale: Date.now() },
}),
"utf8",
);
const migration = stateMigrations[0];
await expect(migration.detectLegacyState(migrationParams())).resolves.toMatchObject({
preview: [expect.stringContaining("Device Pair notify subscribers")],
});
const result = await migration.migrateLegacyState(migrationParams());
expect(result.warnings).toEqual([]);
expect(result.changes).toEqual([
"Migrated Device Pair notify subscribers -> plugin state (1 imported, 0 already present)",
expect.stringContaining("Archived Device Pair notify-state legacy source"),
]);
await expect(fs.access(sourcePath)).rejects.toThrow();
await expect(fs.access(`${sourcePath}.migrated`)).resolves.toBeUndefined();
await expect(
createDoctorContext(env)
.openPluginStateKeyedStore<NotifySubscription>({
namespace: DEVICE_PAIR_NOTIFY_SUBSCRIBER_NAMESPACE,
maxEntries: DEVICE_PAIR_NOTIFY_SUBSCRIBER_MAX_ENTRIES,
})
.lookup(notifySubscriberStoreKey(subscriber)),
).resolves.toEqual(subscriber);
});
it("ignores legacy notify files that only contain cache state", async () => {
const sourcePath = path.join(stateDir, DEVICE_PAIR_NOTIFY_LEGACY_STATE_FILE);
await fs.writeFile(
sourcePath,
JSON.stringify({
subscribers: [],
notifiedRequestIds: { cached: Date.now() },
}),
"utf8",
);
const migration = stateMigrations[0];
await expect(migration.detectLegacyState(migrationParams())).resolves.toBeNull();
await expect(migration.migrateLegacyState(migrationParams())).resolves.toEqual({
changes: [],
warnings: [],
});
await expect(fs.access(sourcePath)).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,85 @@
// Device Pair doctor contract migrates shipped plugin-owned state.
import fs from "node:fs/promises";
import path from "node:path";
import {
archiveLegacyStateSource,
type PluginDoctorStateMigration,
} from "openclaw/plugin-sdk/runtime-doctor";
import {
DEVICE_PAIR_NOTIFY_LEGACY_STATE_FILE,
DEVICE_PAIR_NOTIFY_SUBSCRIBER_MAX_ENTRIES,
DEVICE_PAIR_NOTIFY_SUBSCRIBER_NAMESPACE,
normalizeLegacyNotifyState,
notifySubscriberStoreKey,
type LegacyNotifyStateFile,
type NotifySubscription,
} from "./notify-state.js";
function resolveLegacyNotifyStatePath(stateDir: string): string {
return path.join(stateDir, DEVICE_PAIR_NOTIFY_LEGACY_STATE_FILE);
}
async function readLegacyNotifyState(filePath: string): Promise<LegacyNotifyStateFile | null> {
try {
return normalizeLegacyNotifyState(JSON.parse(await fs.readFile(filePath, "utf8")) as unknown);
} catch {
return null;
}
}
export const stateMigrations: PluginDoctorStateMigration[] = [
{
id: "device-pair-notify-json-to-plugin-state",
label: "Device Pair notify subscribers",
async detectLegacyState(params) {
const filePath = resolveLegacyNotifyStatePath(params.stateDir);
const state = await readLegacyNotifyState(filePath);
if (!state || state.subscribers.length === 0) {
return null;
}
return {
preview: [
`- Device Pair notify subscribers: ${filePath} -> plugin state (${DEVICE_PAIR_NOTIFY_SUBSCRIBER_NAMESPACE}, ${state.subscribers.length} subscriber(s))`,
],
};
},
async migrateLegacyState(params) {
const changes: string[] = [];
const warnings: string[] = [];
const filePath = resolveLegacyNotifyStatePath(params.stateDir);
const state = await readLegacyNotifyState(filePath);
if (!state || state.subscribers.length === 0) {
return { changes, warnings };
}
const store = params.context.openPluginStateKeyedStore<NotifySubscription>({
namespace: DEVICE_PAIR_NOTIFY_SUBSCRIBER_NAMESPACE,
maxEntries: DEVICE_PAIR_NOTIFY_SUBSCRIBER_MAX_ENTRIES,
});
let imported = 0;
let alreadyPresent = 0;
for (const subscriber of state.subscribers) {
const inserted = await store.registerIfAbsent(
notifySubscriberStoreKey(subscriber),
subscriber,
);
if (inserted) {
imported++;
} else {
alreadyPresent++;
}
}
changes.push(
`Migrated Device Pair notify subscribers -> plugin state (${imported} imported, ${alreadyPresent} already present)`,
);
await archiveLegacyStateSource({
filePath,
label: "Device Pair notify-state",
changes,
warnings,
});
return { changes, warnings };
},
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,910 @@
// Device Pair plugin entrypoint registers its OpenClaw integration.
import { rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { buildDevicePairPairingQrChannelData } from "./pairing-qr-channel-data.js";
type NotifyModule = typeof import("./notify.js");
const loadDevicePairApiModule = createLazyRuntimeModule(() => import("./api.js"));
const loadNotifyModule = createLazyRuntimeModule(() => import("./notify.js"));
const loadPairCommandApproveModule = createLazyRuntimeModule(
() => import("./pair-command-approve.js"),
);
const loadPairCommandAuthModule = createLazyRuntimeModule(() => import("./pair-command-auth.js"));
function formatDurationMinutes(expiresAtMs: number): string {
const msRemaining = Math.max(0, expiresAtMs - Date.now());
const minutes = Math.max(1, Math.ceil(msRemaining / 60_000));
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
}
type DevicePairPluginConfig = {
publicUrl?: string;
};
type SetupPayload = {
url: string;
bootstrapToken: string;
expiresAtMs: number;
};
type ResolveUrlResult = {
url?: string;
source?: string;
error?: string;
};
type ResolveAuthLabelResult = {
label?: "token" | "password";
error?: string;
};
type QrCommandContext = {
channel: string;
senderId?: string;
from?: string;
to?: string;
accountId?: string;
messageThreadId?: string | number;
};
type QrChannelSender = {
createOpts: (params: {
ctx: QrCommandContext;
qrFilePath: string;
mediaLocalRoots: string[];
accountId?: string;
}) => Record<string, unknown>;
};
const QR_CHANNEL_SENDERS: Record<string, QrChannelSender> = {
telegram: {
createOpts: ({ ctx, qrFilePath, mediaLocalRoots, accountId }) => ({
mediaUrl: qrFilePath,
mediaLocalRoots,
...(ctx.messageThreadId != null ? { threadId: ctx.messageThreadId } : {}),
...(accountId ? { accountId } : {}),
}),
},
discord: {
createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({
mediaUrl: qrFilePath,
mediaLocalRoots,
...(accountId ? { accountId } : {}),
}),
},
slack: {
createOpts: ({ ctx, qrFilePath, mediaLocalRoots, accountId }) => ({
mediaUrl: qrFilePath,
mediaLocalRoots,
...(ctx.messageThreadId != null ? { threadId: String(ctx.messageThreadId) } : {}),
...(accountId ? { accountId } : {}),
}),
},
signal: {
createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({
mediaUrl: qrFilePath,
mediaLocalRoots,
...(accountId ? { accountId } : {}),
}),
},
imessage: {
createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({
mediaUrl: qrFilePath,
mediaLocalRoots,
...(accountId ? { accountId } : {}),
}),
},
whatsapp: {
createOpts: ({ qrFilePath, mediaLocalRoots, accountId }) => ({
verbose: false,
mediaUrl: qrFilePath,
mediaLocalRoots,
...(accountId ? { accountId } : {}),
}),
},
};
const GATEWAY_SCHEME_WITHOUT_AUTHORITY_RE = /^(?:https?|wss?):(?!\/\/)/i;
const SCHEME_LIKE_PATH_RE = /^[A-Za-z][A-Za-z0-9+.-]*:\//;
function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null {
const candidate = normalizeOptionalString(raw);
if (!candidate) {
return null;
}
if (GATEWAY_SCHEME_WITHOUT_AUTHORITY_RE.test(candidate)) {
return null;
}
const parsedUrl = parseNormalizedGatewayUrl(candidate);
if (parsedUrl) {
return parsedUrl;
}
if (candidate.includes("://") || SCHEME_LIKE_PATH_RE.test(candidate)) {
return null;
}
const hostPort = normalizeOptionalString(candidate.split("/", 1)[0]) ?? "";
return hostPort ? parseNormalizedGatewayUrl(`${schemeFallback}://${hostPort}`) : null;
}
function parseNormalizedGatewayUrl(raw: string): string | null {
try {
const parsed = new URL(raw);
if (parsed.username || parsed.password) {
return null;
}
const scheme = parsed.protocol.slice(0, -1);
const normalizedScheme = scheme === "http" ? "ws" : scheme === "https" ? "wss" : scheme;
if (!(normalizedScheme === "ws" || normalizedScheme === "wss")) {
return null;
}
if (!parsed.hostname) {
return null;
}
return `${normalizedScheme}://${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`;
} catch {
return null;
}
}
function describeSecureMobilePairingFix(source?: string): string {
const sourceNote = source ? ` Resolved source: ${source}.` : "";
return (
"Tailscale and public mobile pairing require a secure gateway URL (wss://) or Tailscale Serve/Funnel." +
sourceNote +
" Fix: use a private LAN address, prefer gateway.tailscale.mode=serve, or set " +
"gateway.remote.url / plugins.entries.device-pair.config.publicUrl to a wss:// URL. " +
"ws:// setup codes are only valid for localhost/loopback, private LAN addresses, .local hosts, or the Android emulator."
);
}
function normalizeHostForIpCheck(host: string): string {
let normalized = normalizeLowercaseStringOrEmpty(host);
if (normalized.startsWith("[") && normalized.endsWith("]")) {
normalized = normalized.slice(1, -1);
}
if (normalized.endsWith(".")) {
normalized = normalized.slice(0, -1);
}
const zoneIndex = normalized.indexOf("%");
if (zoneIndex >= 0) {
normalized = normalized.slice(0, zoneIndex);
}
return normalized;
}
function isLoopbackHost(host: string): boolean {
const normalized = normalizeHostForIpCheck(host);
if (!normalized) {
return false;
}
if (normalized === "localhost" || normalized === "0.0.0.0" || normalized === "::") {
return true;
}
const octets = parseIPv4Octets(normalized);
if (octets) {
return octets[0] === 127;
}
return normalized === "::1" || normalized === "0:0:0:0:0:0:0:1";
}
function resolveScheme(
cfg: OpenClawPluginApi["config"],
opts?: { forceSecure?: boolean },
): "ws" | "wss" {
if (opts?.forceSecure) {
return "wss";
}
return cfg.gateway?.tls?.enabled === true ? "wss" : "ws";
}
function parseIPv4Octets(address: string): [number, number, number, number] | null {
const parts = address.split(".");
if (parts.length !== 4) {
return null;
}
if (parts.some((part) => !/^\d+$/.test(part))) {
return null;
}
const octets = parts.map((part) => Number.parseInt(part, 10));
if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) {
return null;
}
return octets as [number, number, number, number];
}
function isPrivateIPv4(address: string): boolean {
const octets = parseIPv4Octets(address);
if (!octets) {
return false;
}
const [a, b] = octets;
if (a === 10) {
return true;
}
if (a === 172 && b >= 16 && b <= 31) {
return true;
}
if (a === 192 && b === 168) {
return true;
}
return false;
}
function isPrivateLanCleartextHost(host: string): boolean {
const normalized = normalizeHostForIpCheck(host);
if (normalized.endsWith(".local")) {
return true;
}
if (isPrivateIPv4(normalized)) {
return true;
}
const octets = parseIPv4Octets(normalized);
if (!octets) {
return false;
}
return octets[0] === 169 && octets[1] === 254;
}
function isTailnetIPv4(address: string): boolean {
const octets = parseIPv4Octets(address);
if (!octets) {
return false;
}
const [a, b] = octets;
return a === 100 && b >= 64 && b <= 127;
}
function isMobilePairingCleartextAllowedHost(host: string): boolean {
const normalized = normalizeHostForIpCheck(host);
return (
isLoopbackHost(normalized) || normalized === "10.0.2.2" || isPrivateLanCleartextHost(normalized)
);
}
function validateMobilePairingUrl(url: string, source?: string): string | null {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return "Resolved mobile pairing URL is invalid.";
}
const protocol =
parsed.protocol === "https:" ? "wss:" : parsed.protocol === "http:" ? "ws:" : parsed.protocol;
if (protocol === "wss:") {
return null;
}
if (protocol !== "ws:" || isMobilePairingCleartextAllowedHost(parsed.hostname)) {
return null;
}
return describeSecureMobilePairingFix(source);
}
function pickMatchingIPv4(predicate: (address: string) => boolean): string | null {
const nets = os.networkInterfaces();
for (const entries of Object.values(nets)) {
if (!entries) {
continue;
}
for (const entry of entries) {
const family = entry?.family;
// Keep the numeric check for older Node runtimes that reported family as 4.
const isIpv4 = family === "IPv4" || (family as unknown) === 4;
if (!entry || entry.internal || !isIpv4) {
continue;
}
const address = normalizeOptionalString(entry.address) ?? "";
if (!address) {
continue;
}
if (predicate(address)) {
return address;
}
}
}
return null;
}
function pickTailnetIPv4(): string | null {
return pickMatchingIPv4(isTailnetIPv4);
}
async function resolveTailnetHost(): Promise<string | null> {
const { resolveTailnetHostWithRunner, runPluginCommandWithTimeout } =
await loadDevicePairApiModule();
return await resolveTailnetHostWithRunner((argv, opts) =>
runPluginCommandWithTimeout({
argv,
timeoutMs: opts.timeoutMs,
}),
);
}
function resolveAuthLabel(cfg: OpenClawPluginApi["config"]): ResolveAuthLabelResult {
const mode = cfg.gateway?.auth?.mode;
const token =
pickFirstDefined([process.env.OPENCLAW_GATEWAY_TOKEN, cfg.gateway?.auth?.token]) ?? undefined;
const password =
pickFirstDefined([process.env.OPENCLAW_GATEWAY_PASSWORD, cfg.gateway?.auth?.password]) ??
undefined;
if (mode === "token" || mode === "password") {
return resolveRequiredAuthLabel(mode, { token, password });
}
if (token) {
return { label: "token" };
}
if (password) {
return { label: "password" };
}
return { error: "Gateway auth is not configured (no token or password)." };
}
function pickFirstDefined(candidates: Array<unknown>): string | null {
for (const value of candidates) {
const trimmed = normalizeOptionalString(value);
if (trimmed) {
return trimmed;
}
}
return null;
}
function resolveRequiredAuthLabel(
mode: "token" | "password",
values: { token?: string; password?: string },
): ResolveAuthLabelResult {
if (mode === "token") {
return values.token
? { label: "token" }
: { error: "Gateway auth is set to token, but no token is configured." };
}
return values.password
? { label: "password" }
: { error: "Gateway auth is set to password, but no password is configured." };
}
async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResult> {
const { resolveAdvertisedLanHost, resolveGatewayBindUrl, resolveGatewayPort } =
await loadDevicePairApiModule();
const cfg = api.config;
const pluginCfg = (api.pluginConfig ?? {}) as DevicePairPluginConfig;
const scheme = resolveScheme(cfg);
const port = resolveGatewayPort(cfg);
const configuredPublicUrl = normalizeOptionalString(pluginCfg.publicUrl);
if (configuredPublicUrl) {
const url = normalizeUrl(configuredPublicUrl, scheme);
if (url) {
return { url, source: "plugins.entries.device-pair.config.publicUrl" };
}
return { error: "Configured publicUrl is invalid." };
}
const configuredRemoteUrl = normalizeOptionalString(cfg.gateway?.remote?.url);
const remoteUrl = configuredRemoteUrl ? normalizeUrl(configuredRemoteUrl, scheme) : null;
if (configuredRemoteUrl && !remoteUrl) {
return { error: "Configured gateway.remote.url is invalid." };
}
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
if (tailscaleMode === "serve" || tailscaleMode === "funnel") {
const host = await resolveTailnetHost();
if (!host) {
return { error: "Tailscale Serve is enabled, but MagicDNS could not be resolved." };
}
return { url: `wss://${host}`, source: `gateway.tailscale.mode=${tailscaleMode}` };
}
if (remoteUrl) {
return { url: remoteUrl, source: "gateway.remote.url" };
}
const advertisedLanHost = cfg.gateway?.bind === "lan" ? await resolveAdvertisedLanHost() : null;
const bindResult = resolveGatewayBindUrl({
bind: cfg.gateway?.bind,
customBindHost: cfg.gateway?.customBindHost,
scheme,
port,
pickTailnetHost: pickTailnetIPv4,
pickLanHost: () => advertisedLanHost,
});
if (bindResult) {
return bindResult;
}
return {
error:
"Gateway is only bound to loopback. Set gateway.bind=lan, enable tailscale serve, or configure plugins.entries.device-pair.config.publicUrl.",
};
}
async function resolveMobilePairingGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResult> {
const result = await resolveGatewayUrl(api);
if (!result.url) {
return result;
}
const mobilePairingUrlError = validateMobilePairingUrl(result.url, result.source);
if (mobilePairingUrlError) {
return { error: mobilePairingUrlError };
}
return result;
}
function encodeSetupCode(payload: SetupPayload): string {
const json = JSON.stringify(payload);
const base64 = Buffer.from(json, "utf8").toString("base64");
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function buildPairingFlowLines(stepTwo: string): string[] {
return [
"1) Open the iOS app → Settings → Gateway",
`2) ${stepTwo}`,
"3) Back here, run /pair approve",
"4) If this code leaks or you are done, run /pair cleanup",
];
}
function buildSecurityNoticeLines(params: {
kind: "setup code" | "QR code";
expiresAtMs: number;
markdown?: boolean;
}): string[] {
const cleanupCommand = params.markdown ? "`/pair cleanup`" : "/pair cleanup";
const securityPrefix = params.markdown ? "- " : "";
const importantLine = params.markdown
? `**Important:** Run ${cleanupCommand} after pairing finishes.`
: `IMPORTANT: After pairing finishes, run ${cleanupCommand}.`;
return [
`${securityPrefix}Security: single-use bootstrap token`,
`${securityPrefix}Expires: ${formatDurationMinutes(params.expiresAtMs)}`,
"",
importantLine,
`If this ${params.kind} leaks, run ${cleanupCommand} immediately.`,
];
}
function buildQrFollowUpLines(autoNotifyArmed: boolean): string[] {
return autoNotifyArmed
? [
"After scanning, wait here for the pairing request ping.",
"Ill auto-ping here when the pairing request arrives, then auto-disable.",
"If the ping does not arrive, run `/pair approve latest` manually.",
]
: ["After scanning, run `/pair approve` to complete pairing."];
}
function formatSetupReply(payload: SetupPayload, authLabel: string): string {
const setupCode = encodeSetupCode(payload);
return [
"Pairing setup code generated.",
"",
...buildPairingFlowLines("Paste the setup code below and tap Connect"),
"",
"Setup code:",
setupCode,
"",
`Gateway: ${payload.url}`,
`Auth: ${authLabel}`,
...buildSecurityNoticeLines({
kind: "setup code",
expiresAtMs: payload.expiresAtMs,
}),
].join("\n");
}
function formatSetupInstructions(expiresAtMs: number): string {
return [
"Pairing setup code generated.",
"",
...buildPairingFlowLines("Paste the setup code from my next message and tap Connect"),
"",
...buildSecurityNoticeLines({
kind: "setup code",
expiresAtMs,
}),
].join("\n");
}
function buildQrInfoLines(params: {
payload: SetupPayload;
authLabel: string;
autoNotifyArmed: boolean;
expiresAtMs: number;
}): string[] {
return [
`Gateway: ${params.payload.url}`,
`Auth: ${params.authLabel}`,
...buildSecurityNoticeLines({
kind: "QR code",
expiresAtMs: params.expiresAtMs,
}),
"",
...buildQrFollowUpLines(params.autoNotifyArmed),
"",
"If your camera still wont lock on, run `/pair` for a pasteable setup code.",
];
}
function formatQrInfoMarkdown(params: {
payload: SetupPayload;
authLabel: string;
autoNotifyArmed: boolean;
expiresAtMs: number;
}): string {
return [
`- Gateway: ${params.payload.url}`,
`- Auth: ${params.authLabel}`,
...buildSecurityNoticeLines({
kind: "QR code",
expiresAtMs: params.expiresAtMs,
markdown: true,
}),
"",
...buildQrFollowUpLines(params.autoNotifyArmed),
"",
"If your camera still wont lock on, run `/pair` for a pasteable setup code.",
].join("\n");
}
function canSendQrPngToChannel(channel: string): boolean {
return channel in QR_CHANNEL_SENDERS;
}
function resolveQrReplyTarget(ctx: QrCommandContext): string {
if (ctx.channel === "discord") {
const senderId = normalizeOptionalString(ctx.senderId) ?? "";
if (senderId) {
return senderId.startsWith("user:") || senderId.startsWith("channel:")
? senderId
: `user:${senderId}`;
}
}
return (
normalizeOptionalString(ctx.senderId) ||
normalizeOptionalString(ctx.from) ||
normalizeOptionalString(ctx.to) ||
""
);
}
async function issueSetupPayload(url: string): Promise<SetupPayload> {
const { issueDeviceBootstrapToken, PAIRING_SETUP_BOOTSTRAP_PROFILE } =
await loadDevicePairApiModule();
const issuedBootstrap = await issueDeviceBootstrapToken({
profile: PAIRING_SETUP_BOOTSTRAP_PROFILE,
});
return {
url,
bootstrapToken: issuedBootstrap.token,
expiresAtMs: issuedBootstrap.expiresAtMs,
};
}
async function sendQrPngToSupportedChannel(params: {
api: OpenClawPluginApi;
ctx: QrCommandContext;
target: string;
caption: string;
qrFilePath: string;
}): Promise<boolean> {
const mediaLocalRoots = [path.dirname(params.qrFilePath)];
const accountId = normalizeOptionalString(params.ctx.accountId) || undefined;
const sender = QR_CHANNEL_SENDERS[params.ctx.channel];
if (!sender) {
return false;
}
const adapter = await params.api.runtime.channel.outbound.loadAdapter(params.ctx.channel);
const send = adapter?.sendMedia;
if (!send) {
return false;
}
await send({
cfg: params.api.config,
to: params.target,
text: params.caption,
...sender.createOpts({
ctx: params.ctx,
qrFilePath: params.qrFilePath,
mediaLocalRoots,
accountId,
}),
});
return true;
}
export default definePluginEntry({
id: "device-pair",
name: "Device Pair",
description: "QR/bootstrap pairing helpers for OpenClaw devices",
register(api: OpenClawPluginApi) {
let notifierService: ReturnType<NotifyModule["createPairingNotifierService"]> | undefined;
api.registerService({
id: "device-pair-notifier",
start: async (ctx) => {
const { createPairingNotifierService } = await loadNotifyModule();
notifierService = createPairingNotifierService(api);
await notifierService.start(ctx);
},
stop: async (ctx) => {
await notifierService?.stop?.(ctx);
notifierService = undefined;
},
});
api.registerCommand({
name: "pair",
description: "Generate setup codes and approve device pairing requests.",
acceptsArgs: true,
requiredScopes: ["operator.pairing"],
handler: async (ctx) => {
const args = normalizeOptionalString(ctx.args) ?? "";
const tokens = args.split(/\s+/).filter(Boolean);
const action = normalizeLowercaseStringOrEmpty(tokens[0]);
const gatewayClientScopes = Array.isArray(ctx.gatewayClientScopes)
? ctx.gatewayClientScopes
: undefined;
const {
buildMissingPairingScopeReply,
buildMissingSetupHandoffScopeReply,
resolvePairingCommandAuthState,
} = await loadPairCommandAuthModule();
const authState = resolvePairingCommandAuthState({
channel: ctx.channel,
gatewayClientScopes,
senderIsOwner: ctx.senderIsOwner,
});
api.logger.info?.(
`device-pair: /pair invoked channel=${ctx.channel} sender=${ctx.senderId ?? "unknown"} action=${
action || "new"
}`,
);
if (authState.isMissingPairingPrivilege) {
return buildMissingPairingScopeReply();
}
if (action === "status" || action === "pending") {
const [{ listDevicePairing }, { formatPendingRequests }] = await Promise.all([
loadDevicePairApiModule(),
loadNotifyModule(),
]);
const list = await listDevicePairing();
return { text: formatPendingRequests(list.pending) };
}
if (action === "notify") {
const notifyAction = normalizeLowercaseStringOrEmpty(tokens[1]) || "status";
const { handleNotifyCommand } = await loadNotifyModule();
return await handleNotifyCommand({
api,
ctx,
action: notifyAction,
});
}
if (action === "approve") {
const [
{ listDevicePairing },
{ approvePendingPairingRequest, selectPendingApprovalRequest },
] = await Promise.all([loadDevicePairApiModule(), loadPairCommandApproveModule()]);
const list = await listDevicePairing();
const selected = selectPendingApprovalRequest({
pending: list.pending,
requested: normalizeOptionalString(tokens[1]),
});
if (selected.reply) {
return selected.reply;
}
const pending = selected.pending;
if (!pending) {
return { text: "Pairing request not found." };
}
return await approvePendingPairingRequest({
requestId: pending.requestId,
callerScopes: authState.approvalCallerScopes,
});
}
if (action === "cleanup" || action === "clear" || action === "revoke") {
const { clearDeviceBootstrapTokens } = await loadDevicePairApiModule();
const cleared = await clearDeviceBootstrapTokens();
return {
text:
cleared.removed > 0
? `Invalidated ${cleared.removed} unused setup code${cleared.removed === 1 ? "" : "s"}.`
: "No unused setup codes were active.",
};
}
if (authState.isMissingSetupHandoffPrivilege) {
return buildMissingSetupHandoffScopeReply();
}
const authLabelResult = resolveAuthLabel(api.config);
if (authLabelResult.error) {
return { text: `Error: ${authLabelResult.error}` };
}
const urlResult = await resolveMobilePairingGatewayUrl(api);
if (!urlResult.url) {
return { text: `Error: ${urlResult.error ?? "Gateway URL unavailable."}` };
}
const authLabel = authLabelResult.label ?? "auth";
if (action === "qr") {
const channel = ctx.channel;
const target = resolveQrReplyTarget(ctx);
let autoNotifyArmed = false;
if (channel === "telegram" && target) {
try {
const { armPairNotifyOnce } = await loadNotifyModule();
autoNotifyArmed = await armPairNotifyOnce({ api, ctx });
} catch (err) {
api.logger.warn?.(
`device-pair: failed to arm one-shot pairing notify (${(err as Error)?.message ?? err})`,
);
}
}
let payload = await issueSetupPayload(urlResult.url);
let setupCode = encodeSetupCode(payload);
const infoLines = buildQrInfoLines({
payload,
authLabel,
autoNotifyArmed,
expiresAtMs: payload.expiresAtMs,
});
if (target && canSendQrPngToChannel(channel)) {
let qrFilePath: string | undefined;
try {
const { resolvePreferredOpenClawTmpDir, writeQrPngTempFile } =
await loadDevicePairApiModule();
qrFilePath = (
await writeQrPngTempFile(setupCode, {
tmpRoot: resolvePreferredOpenClawTmpDir(),
dirPrefix: "device-pair-qr-",
fileName: "pair-qr.png",
})
).filePath;
const sent = await sendQrPngToSupportedChannel({
api,
ctx,
target,
caption: ["Scan this QR code with the OpenClaw iOS app:", "", ...infoLines].join(
"\n",
),
qrFilePath,
});
if (sent) {
return {
text:
`QR code sent above.\n` +
`Expires: ${formatDurationMinutes(payload.expiresAtMs)}\n` +
"IMPORTANT: Run /pair cleanup after pairing finishes.",
};
}
} catch (err) {
const { revokeDeviceBootstrapToken } = await loadDevicePairApiModule();
api.logger.warn?.(
`device-pair: QR image send failed channel=${channel}, falling back (${(err as Error)?.message ?? err})`,
);
await revokeDeviceBootstrapToken({ token: payload.bootstrapToken }).catch(() => {});
payload = await issueSetupPayload(urlResult.url);
setupCode = encodeSetupCode(payload);
} finally {
if (qrFilePath) {
await rm(path.dirname(qrFilePath), { recursive: true, force: true }).catch(
() => {},
);
}
}
}
api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`);
if (channel === "webchat") {
try {
const { renderQrPngDataUrl } = await loadDevicePairApiModule();
await renderQrPngDataUrl(setupCode);
} catch (err) {
const { revokeDeviceBootstrapToken } = await loadDevicePairApiModule();
api.logger.warn?.(
`device-pair: webchat QR render failed, falling back (${(err as Error)?.message ?? err})`,
);
await revokeDeviceBootstrapToken({ token: payload.bootstrapToken }).catch(() => {});
payload = await issueSetupPayload(urlResult.url);
return {
text:
"QR image delivery is not available on this channel right now, so I generated a pasteable setup code instead.\n\n" +
formatSetupReply(payload, authLabel),
};
}
return {
text: [
"Scan this QR code with the OpenClaw iOS app:",
"",
formatQrInfoMarkdown({
payload,
authLabel,
autoNotifyArmed,
expiresAtMs: payload.expiresAtMs,
}),
].join("\n"),
channelData: buildDevicePairPairingQrChannelData({
setupCode,
expiresAtMs: payload.expiresAtMs,
}),
sensitiveMedia: true,
};
}
return {
text:
"QR image delivery is not available on this channel, so I generated a pasteable setup code instead.\n\n" +
formatSetupReply(payload, authLabel),
};
}
const channel = ctx.channel;
const target =
normalizeOptionalString(ctx.senderId) ||
normalizeOptionalString(ctx.from) ||
normalizeOptionalString(ctx.to) ||
"";
const payload = await issueSetupPayload(urlResult.url);
if (channel === "telegram" && target) {
try {
const runtimeKeys = Object.keys(api.runtime ?? {});
const channelKeys = Object.keys(api.runtime?.channel ?? {});
api.logger.debug?.(
`device-pair: runtime keys=${runtimeKeys.join(",") || "none"} channel keys=${
channelKeys.join(",") || "none"
}`,
);
const adapter = await api.runtime.channel.outbound.loadAdapter("telegram");
const send = adapter?.sendText;
if (!send) {
throw new Error(
`telegram runtime unavailable (runtime keys: ${runtimeKeys.join(",")}; channel keys: ${channelKeys.join(
",",
)})`,
);
}
await send({
cfg: api.config,
to: target,
text: formatSetupInstructions(payload.expiresAtMs),
...(ctx.messageThreadId != null ? { threadId: ctx.messageThreadId } : {}),
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
});
api.logger.info?.(
`device-pair: telegram split send ok target=${target} account=${ctx.accountId ?? "none"} thread=${
ctx.messageThreadId ?? "none"
}`,
);
return { text: encodeSetupCode(payload) };
} catch (err) {
api.logger.warn?.(
`device-pair: telegram split send failed, falling back to single message (${(err as Error)?.message ?? err})`,
);
}
}
return {
text: formatSetupReply(payload, authLabel),
};
},
});
},
});

View File

@@ -0,0 +1,131 @@
// Device Pair notify state helpers keep runtime and doctor migration in sync.
import { createHash } from "node:crypto";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
export const DEVICE_PAIR_NOTIFY_LEGACY_STATE_FILE = "device-pair-notify.json";
export const DEVICE_PAIR_NOTIFY_SUBSCRIBER_NAMESPACE = "notify-subscribers";
export const DEVICE_PAIR_NOTIFY_SEEN_REQUEST_NAMESPACE = "notify-seen-requests";
export const DEVICE_PAIR_NOTIFY_SUBSCRIBER_MAX_ENTRIES = 1024;
export const DEVICE_PAIR_NOTIFY_SEEN_REQUEST_MAX_ENTRIES = 4096;
export const DEVICE_PAIR_NOTIFY_MAX_SEEN_AGE_MS = 24 * 60 * 60 * 1000;
export type NotifySubscription = {
to: string;
accountId?: string;
messageThreadId?: string | number;
mode: "persistent" | "once";
addedAtMs: number;
};
export type NotifySeenRequest = {
requestId: string;
notifiedAtMs: number;
};
export type LegacyNotifyStateFile = {
subscribers: NotifySubscription[];
notifiedRequestIds: Record<string, number>;
};
export function normalizeLegacyNotifyState(raw: unknown): LegacyNotifyStateFile {
const root = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
const subscribersRaw = Array.isArray(root.subscribers) ? root.subscribers : [];
const notifiedRaw =
typeof root.notifiedRequestIds === "object" && root.notifiedRequestIds !== null
? (root.notifiedRequestIds as Record<string, unknown>)
: {};
const subscribers: NotifySubscription[] = [];
for (const item of subscribersRaw) {
if (typeof item !== "object" || item === null) {
continue;
}
const record = item as Record<string, unknown>;
const to = normalizeOptionalString(record.to) ?? "";
if (!to) {
continue;
}
const accountId = normalizeOptionalString(record.accountId) ?? undefined;
const messageThreadId =
typeof record.messageThreadId === "string"
? normalizeOptionalString(record.messageThreadId) || undefined
: typeof record.messageThreadId === "number" && Number.isFinite(record.messageThreadId)
? Math.trunc(record.messageThreadId)
: undefined;
const mode = record.mode === "once" ? "once" : "persistent";
const addedAtMs =
typeof record.addedAtMs === "number" && Number.isFinite(record.addedAtMs)
? Math.trunc(record.addedAtMs)
: Date.now();
subscribers.push({
to,
accountId,
messageThreadId,
mode,
addedAtMs,
});
}
const notifiedRequestIds: Record<string, number> = {};
for (const [requestId, ts] of Object.entries(notifiedRaw)) {
const normalizedRequestId = normalizeOptionalString(requestId);
if (!normalizedRequestId) {
continue;
}
if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= 0) {
continue;
}
notifiedRequestIds[normalizedRequestId] = Math.trunc(ts);
}
return { subscribers, notifiedRequestIds };
}
export function normalizeNotifyThreadKey(messageThreadId?: string | number): string {
if (typeof messageThreadId === "number" && Number.isFinite(messageThreadId)) {
return String(Math.trunc(messageThreadId));
}
if (typeof messageThreadId !== "string") {
return "";
}
const normalized = normalizeOptionalString(messageThreadId);
if (!normalized) {
return "";
}
if (!/^-?\d+$/u.test(normalized)) {
return normalized;
}
try {
return BigInt(normalized).toString();
} catch {
return normalized;
}
}
export function notifySubscriberKey(subscriber: {
to: string;
accountId?: string;
messageThreadId?: string | number;
}): string {
return JSON.stringify([
subscriber.to,
subscriber.accountId ?? "",
normalizeNotifyThreadKey(subscriber.messageThreadId),
]);
}
function hashStoreKey(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
export function notifySubscriberStoreKey(subscriber: {
to: string;
accountId?: string;
messageThreadId?: string | number;
}): string {
return hashStoreKey(notifySubscriberKey(subscriber));
}
export function notifyRequestStoreKey(requestId: string): string {
return hashStoreKey(requestId);
}

View File

@@ -0,0 +1,158 @@
// Device Pair tests cover notify plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
DEVICE_PAIR_NOTIFY_SUBSCRIBER_MAX_ENTRIES,
DEVICE_PAIR_NOTIFY_SUBSCRIBER_NAMESPACE,
notifySubscriberStoreKey,
type NotifySubscription,
} from "./notify-state.js";
const listDevicePairingMock = vi.hoisted(() => vi.fn(async () => ({ pending: [] })));
vi.mock("./api.js", () => ({
listDevicePairing: listDevicePairingMock,
}));
import { handleNotifyCommand } from "./notify.js";
afterAll(() => {
vi.doUnmock("./api.js");
vi.resetModules();
});
describe("device-pair notify persistence", () => {
let stateDir: string;
let env: NodeJS.ProcessEnv;
beforeEach(async () => {
resetPluginStateStoreForTests();
vi.clearAllMocks();
listDevicePairingMock.mockResolvedValue({ pending: [] });
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "device-pair-notify-"));
env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
});
afterEach(async () => {
await fs.rm(stateDir, { recursive: true, force: true });
});
function openStore<T>(options: OpenKeyedStoreOptions) {
return createPluginStateKeyedStoreForTests<T>("device-pair", {
...options,
env: options.env ?? env,
});
}
function createApi() {
return createTestPluginApi({
runtime: {
state: {
resolveStateDir: () => stateDir,
openKeyedStore: openStore,
},
} as never,
});
}
function openSubscriberStore() {
return openStore<NotifySubscription>({
namespace: DEVICE_PAIR_NOTIFY_SUBSCRIBER_NAMESPACE,
maxEntries: DEVICE_PAIR_NOTIFY_SUBSCRIBER_MAX_ENTRIES,
});
}
it("matches persisted telegram thread ids across number and string roundtrips", async () => {
const subscriber: NotifySubscription = {
to: "chat-123",
accountId: "telegram-default",
messageThreadId: 271,
mode: "persistent",
addedAtMs: 1,
};
await openSubscriberStore().register(notifySubscriberStoreKey(subscriber), subscriber);
const api = createApi();
const status = await handleNotifyCommand({
api,
ctx: {
channel: "telegram",
senderId: "chat-123",
accountId: "telegram-default",
messageThreadId: "271",
},
action: "status",
});
expect(status.text).toContain("Pair request notifications: enabled for this chat.");
expect(status.text).toContain("Mode: persistent");
await handleNotifyCommand({
api,
ctx: {
channel: "telegram",
senderId: "chat-123",
accountId: "telegram-default",
messageThreadId: "271",
},
action: "off",
});
await expect(openSubscriberStore().entries()).resolves.toStrictEqual([]);
});
it("does not remove a different persisted subscriber when notify fields contain pipes", async () => {
const firstSubscriber: NotifySubscription = {
to: "chat|123",
accountId: "acct",
mode: "persistent",
addedAtMs: 1,
};
const secondSubscriber: NotifySubscription = {
to: "chat",
accountId: "123|acct",
mode: "persistent",
addedAtMs: 2,
};
const store = openSubscriberStore();
await store.register(notifySubscriberStoreKey(firstSubscriber), firstSubscriber);
await store.register(notifySubscriberStoreKey(secondSubscriber), secondSubscriber);
const api = createApi();
await handleNotifyCommand({
api,
ctx: {
channel: "telegram",
senderId: "chat",
accountId: "123|acct",
},
action: "off",
});
const status = await handleNotifyCommand({
api,
ctx: {
channel: "telegram",
senderId: "chat",
accountId: "123|acct",
},
action: "status",
});
expect(status.text).toContain("Pair request notifications: disabled for this chat.");
await expect(openSubscriberStore().entries()).resolves.toMatchObject([
{
key: notifySubscriberStoreKey(firstSubscriber),
value: firstSubscriber,
},
]);
});
});

View File

@@ -0,0 +1,484 @@
// Device Pair plugin module implements notify behavior.
import type { OpenClawPluginService } from "openclaw/plugin-sdk/core";
import { listDevicePairing } from "openclaw/plugin-sdk/device-bootstrap";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import type { PluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
DEVICE_PAIR_NOTIFY_MAX_SEEN_AGE_MS,
DEVICE_PAIR_NOTIFY_SEEN_REQUEST_MAX_ENTRIES,
DEVICE_PAIR_NOTIFY_SEEN_REQUEST_NAMESPACE,
DEVICE_PAIR_NOTIFY_SUBSCRIBER_MAX_ENTRIES,
DEVICE_PAIR_NOTIFY_SUBSCRIBER_NAMESPACE,
notifyRequestStoreKey,
notifySubscriberKey,
notifySubscriberStoreKey,
type NotifySeenRequest,
type NotifySubscription,
} from "./notify-state.js";
const NOTIFY_POLL_INTERVAL_MS = 10_000;
type NotifyStateFile = {
subscribers: NotifySubscription[];
notifiedRequestIds: Record<string, number>;
};
type PendingPairingRequest = {
requestId: string;
deviceId: string;
displayName?: string;
platform?: string;
role?: string;
roles?: string[];
scopes?: string[];
remoteIp?: string;
ts?: number;
};
function formatStringList(values?: readonly string[]): string {
if (!Array.isArray(values) || values.length === 0) {
return "none";
}
const normalized = values.map((value) => value.trim()).filter((value) => value.length > 0);
return normalized.length > 0 ? normalized.join(", ") : "none";
}
function formatRoleList(request: PendingPairingRequest): string {
const role = normalizeOptionalString(request.role);
if (role) {
return role;
}
return formatStringList(request.roles);
}
function formatScopeList(request: PendingPairingRequest): string {
return formatStringList(request.scopes);
}
export function formatPendingRequests(pending: PendingPairingRequest[]): string {
if (pending.length === 0) {
return "No pending device pairing requests.";
}
const lines: string[] = ["Pending device pairing requests:"];
for (const req of pending) {
const label = normalizeOptionalString(req.displayName) || req.deviceId;
const platform = normalizeOptionalString(req.platform);
const ip = normalizeOptionalString(req.remoteIp);
const parts = [
`- ${req.requestId}`,
label ? `name=${label}` : null,
platform ? `platform=${platform}` : null,
`role=${formatRoleList(req)}`,
`scopes=${formatScopeList(req)}`,
ip ? `ip=${ip}` : null,
].filter(Boolean);
lines.push(parts.join(" · "));
}
return lines.join("\n");
}
function openNotifySubscriberStore(
api: OpenClawPluginApi,
): PluginStateKeyedStore<NotifySubscription> {
return api.runtime.state.openKeyedStore<NotifySubscription>({
namespace: DEVICE_PAIR_NOTIFY_SUBSCRIBER_NAMESPACE,
maxEntries: DEVICE_PAIR_NOTIFY_SUBSCRIBER_MAX_ENTRIES,
});
}
function openNotifySeenRequestStore(
api: OpenClawPluginApi,
): PluginStateKeyedStore<NotifySeenRequest> {
return api.runtime.state.openKeyedStore<NotifySeenRequest>({
namespace: DEVICE_PAIR_NOTIFY_SEEN_REQUEST_NAMESPACE,
maxEntries: DEVICE_PAIR_NOTIFY_SEEN_REQUEST_MAX_ENTRIES,
defaultTtlMs: DEVICE_PAIR_NOTIFY_MAX_SEEN_AGE_MS,
});
}
async function readNotifyState(api: OpenClawPluginApi): Promise<NotifyStateFile> {
const subscriberStore = openNotifySubscriberStore(api);
const seenRequestStore = openNotifySeenRequestStore(api);
const [subscriberEntries, seenRequestEntries] = await Promise.all([
subscriberStore.entries(),
seenRequestStore.entries(),
]);
const subscribers = subscriberEntries
.map((entry) => entry.value)
.toSorted((a, b) => a.addedAtMs - b.addedAtMs);
const notifiedRequestIds: Record<string, number> = {};
for (const entry of seenRequestEntries) {
const requestId = normalizeOptionalString(entry.value.requestId);
const notifiedAtMs = entry.value.notifiedAtMs;
if (!requestId || !Number.isFinite(notifiedAtMs) || notifiedAtMs <= 0) {
continue;
}
notifiedRequestIds[requestId] = Math.trunc(notifiedAtMs);
}
return { subscribers, notifiedRequestIds };
}
async function writeNotifyState(api: OpenClawPluginApi, state: NotifyStateFile): Promise<void> {
const subscriberStore = openNotifySubscriberStore(api);
const nextSubscribers = new Map(
state.subscribers.map((subscriber) => [notifySubscriberStoreKey(subscriber), subscriber]),
);
for (const entry of await subscriberStore.entries()) {
if (!nextSubscribers.has(entry.key)) {
await subscriberStore.delete(entry.key);
}
}
for (const [key, subscriber] of nextSubscribers) {
await subscriberStore.register(key, subscriber);
}
const seenRequestStore = openNotifySeenRequestStore(api);
const nextSeenRequests = new Map(
Object.entries(state.notifiedRequestIds).map(([requestId, notifiedAtMs]) => [
notifyRequestStoreKey(requestId),
{ requestId, notifiedAtMs },
]),
);
for (const entry of await seenRequestStore.entries()) {
if (!nextSeenRequests.has(entry.key)) {
await seenRequestStore.delete(entry.key);
}
}
for (const [key, value] of nextSeenRequests) {
await seenRequestStore.register(key, value, {
ttlMs: DEVICE_PAIR_NOTIFY_MAX_SEEN_AGE_MS,
});
}
}
type NotifyTarget = {
to: string;
accountId?: string;
messageThreadId?: string | number;
};
function resolveNotifyTarget(ctx: {
senderId?: string;
from?: string;
to?: string;
accountId?: string;
messageThreadId?: string | number;
}): NotifyTarget | null {
const to =
normalizeOptionalString(ctx.senderId) ||
normalizeOptionalString(ctx.from) ||
normalizeOptionalString(ctx.to) ||
"";
if (!to) {
return null;
}
return {
to,
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}),
};
}
function upsertNotifySubscriber(
subscribers: NotifySubscription[],
target: NotifyTarget,
mode: NotifySubscription["mode"],
): boolean {
const key = notifySubscriberKey(target);
const index = subscribers.findIndex((entry) => notifySubscriberKey(entry) === key);
const next: NotifySubscription = {
...target,
mode,
addedAtMs: Date.now(),
};
if (index === -1) {
subscribers.push(next);
return true;
}
const existing = subscribers[index];
if (existing?.mode === mode) {
return false;
}
subscribers[index] = next;
return true;
}
function buildPairingRequestNotificationText(request: PendingPairingRequest): string {
const label = normalizeOptionalString(request.displayName) || request.deviceId;
const platform = normalizeOptionalString(request.platform);
const ip = normalizeOptionalString(request.remoteIp);
const role = formatRoleList(request);
const scopes = formatScopeList(request);
const lines = [
"📲 New device pairing request",
`ID: ${request.requestId}`,
`Name: ${label}`,
...(platform ? [`Platform: ${platform}`] : []),
`Role: ${role}`,
`Scopes: ${scopes}`,
...(ip ? [`IP: ${ip}`] : []),
"",
`Approve: /pair approve ${request.requestId}`,
"List pending: /pair pending",
];
return lines.join("\n");
}
function requestTimestampMs(request: PendingPairingRequest): number | null {
if (typeof request.ts !== "number" || !Number.isFinite(request.ts)) {
return null;
}
const ts = Math.trunc(request.ts);
return ts > 0 ? ts : null;
}
function shouldNotifySubscriberForRequest(
subscriber: NotifySubscription,
request: PendingPairingRequest,
): boolean {
if (subscriber.mode !== "once") {
return true;
}
const ts = requestTimestampMs(request);
// One-shot subscriptions should only notify for new requests created after arming.
if (ts == null) {
return false;
}
return ts >= subscriber.addedAtMs;
}
async function notifySubscriber(params: {
api: OpenClawPluginApi;
subscriber: NotifySubscription;
text: string;
}): Promise<boolean> {
const adapter = await params.api.runtime.channel.outbound.loadAdapter("telegram");
const send = adapter?.sendText;
if (!send) {
params.api.logger.warn(
"device-pair: telegram outbound adapter unavailable for pairing notifications",
);
return false;
}
try {
await send({
cfg: params.api.config,
to: params.subscriber.to,
text: params.text,
...(params.subscriber.accountId ? { accountId: params.subscriber.accountId } : {}),
...(params.subscriber.messageThreadId != null
? { threadId: params.subscriber.messageThreadId }
: {}),
});
return true;
} catch (err) {
params.api.logger.warn(
`device-pair: failed to send pairing notification to ${params.subscriber.to}: ${formatErrorMessage(err)}`,
);
return false;
}
}
async function notifyPendingPairingRequests(params: { api: OpenClawPluginApi }): Promise<void> {
const state = await readNotifyState(params.api);
const pairing = await listDevicePairing();
const pending: PendingPairingRequest[] = pairing.pending;
const now = Date.now();
const pendingIds = new Set(pending.map((entry) => entry.requestId));
let changed = false;
for (const [requestId, ts] of Object.entries(state.notifiedRequestIds)) {
if (!pendingIds.has(requestId) || now - ts > DEVICE_PAIR_NOTIFY_MAX_SEEN_AGE_MS) {
delete state.notifiedRequestIds[requestId];
changed = true;
}
}
if (state.subscribers.length > 0) {
const oneShotDelivered = new Set<string>();
for (const request of pending) {
if (state.notifiedRequestIds[request.requestId]) {
continue;
}
const text = buildPairingRequestNotificationText(request);
let delivered = false;
for (const subscriber of state.subscribers) {
if (!shouldNotifySubscriberForRequest(subscriber, request)) {
continue;
}
const sent = await notifySubscriber({
api: params.api,
subscriber,
text,
});
delivered = delivered || sent;
if (sent && subscriber.mode === "once") {
oneShotDelivered.add(notifySubscriberKey(subscriber));
}
}
if (delivered) {
state.notifiedRequestIds[request.requestId] = now;
changed = true;
}
}
if (oneShotDelivered.size > 0) {
const initialCount = state.subscribers.length;
state.subscribers = state.subscribers.filter(
(subscriber) => !oneShotDelivered.has(notifySubscriberKey(subscriber)),
);
if (state.subscribers.length !== initialCount) {
changed = true;
}
}
}
if (changed) {
await writeNotifyState(params.api, state);
}
}
export async function armPairNotifyOnce(params: {
api: OpenClawPluginApi;
ctx: {
channel: string;
senderId?: string;
from?: string;
to?: string;
accountId?: string;
messageThreadId?: string | number;
};
}): Promise<boolean> {
if (params.ctx.channel !== "telegram") {
return false;
}
const target = resolveNotifyTarget(params.ctx);
if (!target) {
return false;
}
const state = await readNotifyState(params.api);
let changed = false;
if (upsertNotifySubscriber(state.subscribers, target, "once")) {
changed = true;
}
if (changed) {
await writeNotifyState(params.api, state);
}
return true;
}
export async function handleNotifyCommand(params: {
api: OpenClawPluginApi;
ctx: {
channel: string;
senderId?: string;
from?: string;
to?: string;
accountId?: string;
messageThreadId?: string | number;
};
action: string;
}): Promise<{ text: string }> {
if (params.ctx.channel !== "telegram") {
return { text: "Pairing notifications are currently supported only on Telegram." };
}
const target = resolveNotifyTarget(params.ctx);
if (!target) {
return { text: "Could not resolve Telegram target for this chat." };
}
const state = await readNotifyState(params.api);
const targetKey = notifySubscriberKey(target);
const current = state.subscribers.find((entry) => notifySubscriberKey(entry) === targetKey);
if (params.action === "on" || params.action === "enable") {
if (upsertNotifySubscriber(state.subscribers, target, "persistent")) {
await writeNotifyState(params.api, state);
}
return {
text:
"✅ Pair request notifications enabled for this Telegram chat.\n" +
"I will ping here when a new device pairing request arrives.",
};
}
if (params.action === "off" || params.action === "disable") {
const currentIndex = state.subscribers.findIndex(
(entry) => notifySubscriberKey(entry) === targetKey,
);
if (currentIndex !== -1) {
state.subscribers.splice(currentIndex, 1);
await writeNotifyState(params.api, state);
}
return { text: "✅ Pair request notifications disabled for this Telegram chat." };
}
if (params.action === "once" || params.action === "arm") {
await armPairNotifyOnce({
api: params.api,
ctx: params.ctx,
});
return {
text:
"✅ One-shot pairing notification armed for this Telegram chat.\n" +
"I will notify on the next new pairing request, then auto-disable.",
};
}
if (params.action === "status" || params.action === "") {
const pending = await listDevicePairing();
const enabled = Boolean(current);
const mode = current?.mode ?? "off";
return {
text: [
`Pair request notifications: ${enabled ? "enabled" : "disabled"} for this chat.`,
`Mode: ${mode}`,
`Subscribers: ${state.subscribers.length}`,
`Pending requests: ${pending.pending.length}`,
"",
"Use /pair notify on|off|once",
].join("\n"),
};
}
return { text: "Usage: /pair notify on|off|once|status" };
}
export function createPairingNotifierService(api: OpenClawPluginApi): OpenClawPluginService {
let notifyInterval: ReturnType<typeof setInterval> | null = null;
return {
id: "device-pair-notifier",
start: async () => {
const tick = async () => {
await notifyPendingPairingRequests({ api });
};
await tick().catch((err: unknown) => {
api.logger.warn(`device-pair: initial notify poll failed: ${formatErrorMessage(err)}`);
});
notifyInterval = setInterval(() => {
tick().catch((err: unknown) => {
api.logger.warn(`device-pair: notify poll failed: ${formatErrorMessage(err)}`);
});
}, NOTIFY_POLL_INTERVAL_MS);
notifyInterval.unref?.();
},
stop: async () => {
if (notifyInterval) {
clearInterval(notifyInterval);
notifyInterval = null;
}
},
};
}

View File

@@ -0,0 +1,30 @@
{
"id": "device-pair",
"activation": {
"onStartup": true
},
"enabledByDefault": true,
"name": "Device Pairing",
"description": "Generate setup codes and approve device pairing requests.",
"commandAliases": [
{
"name": "pair",
"kind": "runtime-slash"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"publicUrl": {
"type": "string"
}
}
},
"uiHints": {
"publicUrl": {
"label": "Gateway URL",
"help": "Public WebSocket URL used for /pair setup codes (ws/wss or http/https)."
}
}
}

View File

@@ -0,0 +1,84 @@
// Device Pair plugin module implements pair command approve behavior.
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { approveDevicePairing, listDevicePairing } from "./api.js";
import { formatPendingRequests } from "./notify.js";
type PendingPairingEntry = Awaited<ReturnType<typeof listDevicePairing>>["pending"][number];
type ApprovePairingResult = Awaited<ReturnType<typeof approveDevicePairing>>;
type ApprovedPairingEntry = Exclude<ApprovePairingResult, null | { status: "forbidden" }>;
type ForbiddenPairingEntry = Extract<ApprovePairingResult, { status: "forbidden" }>;
function buildMultiplePendingApprovalReply(pending: PendingPairingEntry[]): { text: string } {
return {
text:
`${formatPendingRequests(pending)}\n\n` +
"Multiple pending requests found. Approve one explicitly:\n" +
"/pair approve <requestId>\n" +
"Or approve the most recent:\n" +
"/pair approve latest",
};
}
export function selectPendingApprovalRequest(params: {
pending: PendingPairingEntry[];
requested?: string;
}): { pending?: PendingPairingEntry; reply?: { text: string } } {
if (params.pending.length === 0) {
return { reply: { text: "No pending device pairing requests." } };
}
if (!params.requested) {
return params.pending.length === 1
? { pending: params.pending[0] }
: { reply: buildMultiplePendingApprovalReply(params.pending) };
}
if (normalizeLowercaseStringOrEmpty(params.requested) === "latest") {
let latest = params.pending[0];
for (let index = 1; index < params.pending.length; index += 1) {
const pending = params.pending[index];
if ((pending.ts ?? 0) > (latest.ts ?? 0)) {
latest = pending;
}
}
return { pending: latest };
}
return {
pending: params.pending.find((entry) => entry.requestId === params.requested),
reply: undefined,
};
}
function formatApprovedPairingReply(approved: ApprovedPairingEntry): { text: string } {
const label = normalizeOptionalString(approved.device.displayName) || approved.device.deviceId;
const platform = normalizeOptionalString(approved.device.platform);
const platformLabel = platform ? ` (${platform})` : "";
return { text: `✅ Paired ${label}${platformLabel}.` };
}
function formatForbiddenPairingRequirement(approved: ForbiddenPairingEntry): string {
return approved.scope ?? approved.role ?? "additional approval";
}
export async function approvePendingPairingRequest(params: {
requestId: string;
callerScopes?: readonly string[];
}): Promise<{ text: string }> {
const approved =
params.callerScopes === undefined
? await approveDevicePairing(params.requestId)
: await approveDevicePairing(params.requestId, { callerScopes: params.callerScopes });
if (!approved) {
return { text: "Pairing request not found." };
}
if (approved.status === "forbidden") {
return {
text: `⚠️ This command requires ${formatForbiddenPairingRequirement(approved)} to approve this pairing request.`,
};
}
return formatApprovedPairingReply(approved);
}

View File

@@ -0,0 +1,99 @@
// Device Pair tests cover pair command auth plugin behavior.
import { describe, expect, it } from "vitest";
import { resolvePairingCommandAuthState } from "./pair-command-auth.js";
describe("device-pair pairing command auth", () => {
it("fails closed for non-gateway channels without pairing scopes", () => {
expect(
resolvePairingCommandAuthState({
channel: "telegram",
gatewayClientScopes: undefined,
}),
).toEqual({
isInternalGatewayCaller: false,
isMissingPairingPrivilege: true,
isMissingSetupHandoffPrivilege: true,
approvalCallerScopes: undefined,
});
});
it("accepts command owners on non-gateway channels", () => {
expect(
resolvePairingCommandAuthState({
channel: "telegram",
gatewayClientScopes: undefined,
senderIsOwner: true,
}),
).toEqual({
isInternalGatewayCaller: false,
isMissingPairingPrivilege: false,
isMissingSetupHandoffPrivilege: false,
approvalCallerScopes: ["operator.pairing"],
});
});
it("fails closed for webchat when scopes are absent", () => {
expect(
resolvePairingCommandAuthState({
channel: "webchat",
gatewayClientScopes: undefined,
}),
).toEqual({
isInternalGatewayCaller: true,
isMissingPairingPrivilege: true,
isMissingSetupHandoffPrivilege: true,
approvalCallerScopes: [],
});
});
it("tracks pairing and setup-handoff privileges independently for internal callers", () => {
expect(
resolvePairingCommandAuthState({
channel: "webchat",
gatewayClientScopes: ["operator.write", "operator.pairing"],
}),
).toEqual({
isInternalGatewayCaller: true,
isMissingPairingPrivilege: false,
isMissingSetupHandoffPrivilege: true,
approvalCallerScopes: ["operator.write", "operator.pairing"],
});
expect(
resolvePairingCommandAuthState({
channel: "webchat",
gatewayClientScopes: ["operator.write", "operator.pairing", "operator.talk.secrets"],
}),
).toEqual({
isInternalGatewayCaller: true,
isMissingPairingPrivilege: false,
isMissingSetupHandoffPrivilege: false,
approvalCallerScopes: ["operator.write", "operator.pairing", "operator.talk.secrets"],
});
expect(
resolvePairingCommandAuthState({
channel: "webchat",
gatewayClientScopes: ["operator.admin"],
}),
).toEqual({
isInternalGatewayCaller: true,
isMissingPairingPrivilege: false,
isMissingSetupHandoffPrivilege: false,
approvalCallerScopes: ["operator.admin"],
});
});
it("preserves gateway scopes for command owners with gateway scope context", () => {
expect(
resolvePairingCommandAuthState({
channel: "telegram",
gatewayClientScopes: ["operator.write", "operator.pairing"],
senderIsOwner: true,
}),
).toEqual({
isInternalGatewayCaller: true,
isMissingPairingPrivilege: false,
isMissingSetupHandoffPrivilege: true,
approvalCallerScopes: ["operator.write", "operator.pairing"],
});
});
});

View File

@@ -0,0 +1,75 @@
// Device Pair plugin module implements pair command auth behavior.
type PairingCommandAuthParams = {
channel: string;
gatewayClientScopes?: readonly string[] | null;
senderIsOwner?: boolean;
};
type PairingCommandAuthState = {
isInternalGatewayCaller: boolean;
isMissingPairingPrivilege: boolean;
isMissingSetupHandoffPrivilege: boolean;
approvalCallerScopes?: readonly string[];
};
const COMMAND_OWNER_PAIRING_SCOPES = ["operator.pairing"] as const;
const PAIRING_SCOPE = "operator.pairing";
const ADMIN_SCOPE = "operator.admin";
const TALK_SECRETS_SCOPE = "operator.talk.secrets";
function isInternalGatewayPairingCaller(params: PairingCommandAuthParams): boolean {
return params.channel === "webchat" || Array.isArray(params.gatewayClientScopes);
}
function hasPairingPrivilege(scopes: readonly string[]): boolean {
return scopes.includes(PAIRING_SCOPE) || scopes.includes(ADMIN_SCOPE);
}
function hasSetupHandoffPrivilege(scopes: readonly string[]): boolean {
return scopes.includes(TALK_SECRETS_SCOPE) || scopes.includes(ADMIN_SCOPE);
}
export function resolvePairingCommandAuthState(
params: PairingCommandAuthParams,
): PairingCommandAuthState {
const isInternalGatewayCaller = isInternalGatewayPairingCaller(params);
if (isInternalGatewayCaller) {
const approvalCallerScopes = Array.isArray(params.gatewayClientScopes)
? params.gatewayClientScopes
: [];
return {
isInternalGatewayCaller,
isMissingPairingPrivilege: !hasPairingPrivilege(approvalCallerScopes),
isMissingSetupHandoffPrivilege: !hasSetupHandoffPrivilege(approvalCallerScopes),
approvalCallerScopes,
};
}
if (params.senderIsOwner === true) {
return {
isInternalGatewayCaller,
isMissingPairingPrivilege: false,
isMissingSetupHandoffPrivilege: false,
approvalCallerScopes: COMMAND_OWNER_PAIRING_SCOPES,
};
}
return {
isInternalGatewayCaller,
isMissingPairingPrivilege: true,
isMissingSetupHandoffPrivilege: true,
approvalCallerScopes: undefined,
};
}
export function buildMissingPairingScopeReply(): { text: string } {
return {
text: "⚠️ This command requires operator.pairing.",
};
}
export function buildMissingSetupHandoffScopeReply(): { text: string } {
return {
text: "⚠️ Setup code handoff includes Talk secrets and requires operator.talk.secrets.",
};
}

View File

@@ -0,0 +1,19 @@
// Private device-pair -> Gateway live-display envelope.
// Keep this local so pairing QR metadata does not become public Plugin SDK API.
export const DEVICE_PAIR_PAIRING_QR_CHANNEL_DATA_KEY = "openclawPairingQr";
export type DevicePairPairingQrChannelData = {
setupCode: string;
expiresAtMs: number;
};
export function buildDevicePairPairingQrChannelData(
params: DevicePairPairingQrChannelData,
): Record<string, unknown> {
return {
[DEVICE_PAIR_PAIRING_QR_CHANNEL_DATA_KEY]: {
setupCode: params.setupCode,
expiresAtMs: params.expiresAtMs,
},
};
}

View File

@@ -0,0 +1,6 @@
// Device Pair plugin module implements qr image behavior.
export {
renderQrPngBase64,
renderQrPngDataUrl,
writeQrPngTempFile,
} from "openclaw/plugin-sdk/media-runtime";