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
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:
22
extensions/device-pair/api.ts
Normal file
22
extensions/device-pair/api.ts
Normal 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";
|
||||
120
extensions/device-pair/doctor-contract-api.test.ts
Normal file
120
extensions/device-pair/doctor-contract-api.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
85
extensions/device-pair/doctor-contract-api.ts
Normal file
85
extensions/device-pair/doctor-contract-api.ts
Normal 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 };
|
||||
},
|
||||
},
|
||||
];
|
||||
1247
extensions/device-pair/index.test.ts
Normal file
1247
extensions/device-pair/index.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
910
extensions/device-pair/index.ts
Normal file
910
extensions/device-pair/index.ts
Normal 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.",
|
||||
"I’ll 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 won’t 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 won’t 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),
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
131
extensions/device-pair/notify-state.ts
Normal file
131
extensions/device-pair/notify-state.ts
Normal 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);
|
||||
}
|
||||
158
extensions/device-pair/notify.test.ts
Normal file
158
extensions/device-pair/notify.test.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
484
extensions/device-pair/notify.ts
Normal file
484
extensions/device-pair/notify.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
30
extensions/device-pair/openclaw.plugin.json
Normal file
30
extensions/device-pair/openclaw.plugin.json
Normal 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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
84
extensions/device-pair/pair-command-approve.ts
Normal file
84
extensions/device-pair/pair-command-approve.ts
Normal 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);
|
||||
}
|
||||
99
extensions/device-pair/pair-command-auth.test.ts
Normal file
99
extensions/device-pair/pair-command-auth.test.ts
Normal 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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
75
extensions/device-pair/pair-command-auth.ts
Normal file
75
extensions/device-pair/pair-command-auth.ts
Normal 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.",
|
||||
};
|
||||
}
|
||||
19
extensions/device-pair/pairing-qr-channel-data.ts
Normal file
19
extensions/device-pair/pairing-qr-channel-data.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
6
extensions/device-pair/qr-image.ts
Normal file
6
extensions/device-pair/qr-image.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Device Pair plugin module implements qr image behavior.
|
||||
export {
|
||||
renderQrPngBase64,
|
||||
renderQrPngDataUrl,
|
||||
writeQrPngTempFile,
|
||||
} from "openclaw/plugin-sdk/media-runtime";
|
||||
Reference in New Issue
Block a user