@cutoffs/openclaw-plugin 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,1720 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, statSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { Type } from "@sinclair/typebox";
6
+
7
+ const PLUGIN_ID = "cutoffs-plugin";
8
+ const LEGACY_PLUGIN_IDS = ["cutoffs", "openclaw-plugin"];
9
+ const DEFAULT_BASE_URL = "https://cutoffs.dev";
10
+ const USER_AGENT = "@cutoffs/openclaw-plugin/0.3.0";
11
+ const DEFAULT_PAIRING_POLL_INTERVAL_MS = 3000;
12
+ const DEFAULT_PAIRING_WAIT_TIMEOUT_MS = 5 * 60 * 1000;
13
+ const MAX_FILE_UPLOAD_BYTES = 25 * 1024 * 1024;
14
+ const APOLLO_S3KEY_PATTERN =
15
+ /^\d+\/[A-Z0-9_]+\/[A-Z0-9_]+\/(request|response)\/[A-Za-z0-9_-]+$/;
16
+
17
+ function safeTrim(value) {
18
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : "";
19
+ }
20
+
21
+ function tokenizeArgs(value) {
22
+ const input = typeof value === "string" ? value.trim() : "";
23
+
24
+ if (!input) {
25
+ return [];
26
+ }
27
+
28
+ return Array.from(input.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g), (match) =>
29
+ match[1] ?? match[2] ?? match[3] ?? "",
30
+ );
31
+ }
32
+
33
+ function isNonEmptyString(value) {
34
+ return typeof value === "string" && value.trim().length > 0;
35
+ }
36
+
37
+ function maskSecret(value) {
38
+ if (!isNonEmptyString(value)) {
39
+ return "(unset)";
40
+ }
41
+
42
+ const trimmed = value.trim();
43
+
44
+ if (trimmed.length <= 8) {
45
+ return `${trimmed.slice(0, 2)}***`;
46
+ }
47
+
48
+ return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`;
49
+ }
50
+
51
+ function getPluginConfigEntry(entries, pluginId) {
52
+ const pluginEntry = entries?.[pluginId];
53
+
54
+ if (pluginEntry && typeof pluginEntry === "object") {
55
+ return pluginEntry.config ?? {};
56
+ }
57
+
58
+ return null;
59
+ }
60
+
61
+ function getDynamicPluginConfig(api) {
62
+ const liveConfig = api.runtime?.config?.loadConfig?.();
63
+ const entries = liveConfig?.plugins?.entries;
64
+
65
+ for (const pluginId of [api.id, ...LEGACY_PLUGIN_IDS]) {
66
+ const config = getPluginConfigEntry(entries, pluginId);
67
+
68
+ if (config) {
69
+ return config;
70
+ }
71
+ }
72
+
73
+ return api.pluginConfig ?? {};
74
+ }
75
+
76
+ function readApiKey(config) {
77
+ return isNonEmptyString(config.apiKey) ? config.apiKey.trim() : "";
78
+ }
79
+
80
+ function getPluginConfig(api) {
81
+ const rawConfig = getDynamicPluginConfig(api);
82
+ const apiKey = readApiKey(rawConfig);
83
+ const pendingPairing = isPlainObject(rawConfig.pendingPairing)
84
+ ? {
85
+ sessionToken: safeTrim(rawConfig.pendingPairing.sessionToken),
86
+ verifier: safeTrim(rawConfig.pendingPairing.verifier),
87
+ pairUrl: safeTrim(rawConfig.pendingPairing.pairUrl),
88
+ displayCode: safeTrim(rawConfig.pendingPairing.displayCode) || null,
89
+ deviceLabel: safeTrim(rawConfig.pendingPairing.deviceLabel),
90
+ expiresAt: safeTrim(rawConfig.pendingPairing.expiresAt),
91
+ }
92
+ : null;
93
+
94
+ const normalizedPendingPairing =
95
+ pendingPairing &&
96
+ pendingPairing.sessionToken &&
97
+ pendingPairing.verifier &&
98
+ pendingPairing.pairUrl &&
99
+ pendingPairing.expiresAt
100
+ ? pendingPairing
101
+ : null;
102
+
103
+ return {
104
+ apiKey,
105
+ pendingPairing: normalizedPendingPairing,
106
+ };
107
+ }
108
+
109
+ function requireApiKey(api) {
110
+ const { apiKey } = getPluginConfig(api);
111
+
112
+ if (!apiKey) {
113
+ throw new Error(
114
+ "Cutoffs is not configured yet. Start browser pairing with cutoffs_begin_pairing. If the plugin was just installed and this chat cannot see the tools yet, start a fresh chat and retry setup there.",
115
+ );
116
+ }
117
+
118
+ return { apiKey };
119
+ }
120
+
121
+ async function parseJsonSafely(response) {
122
+ try {
123
+ return await response.json();
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ function stringifyPayload(payload) {
130
+ return JSON.stringify(payload, null, 2);
131
+ }
132
+
133
+ function isPlainObject(value) {
134
+ return typeof value === "object" && value !== null && !Array.isArray(value);
135
+ }
136
+
137
+ class CutoffsPluginValidationError extends Error {
138
+ constructor(message, { code, invalidFields = [], hint } = {}) {
139
+ super(message);
140
+ this.name = "CutoffsPluginValidationError";
141
+ this.code = code;
142
+ this.invalidFields = invalidFields;
143
+ this.hint = hint;
144
+ }
145
+ }
146
+
147
+ function isFileUploadableShape(value) {
148
+ return (
149
+ isPlainObject(value) &&
150
+ isNonEmptyString(value.name) &&
151
+ isNonEmptyString(value.mimetype) &&
152
+ isNonEmptyString(value.s3key)
153
+ );
154
+ }
155
+
156
+ function collectFileUploadables(value, pathPrefix = "arguments") {
157
+ const matches = [];
158
+
159
+ function walk(current, currentPath) {
160
+ if (isFileUploadableShape(current)) {
161
+ matches.push({ path: currentPath, value: current });
162
+ return;
163
+ }
164
+
165
+ if (Array.isArray(current)) {
166
+ current.forEach((item, index) => walk(item, `${currentPath}[${index}]`));
167
+ return;
168
+ }
169
+
170
+ if (!isPlainObject(current)) {
171
+ return;
172
+ }
173
+
174
+ for (const [key, child] of Object.entries(current)) {
175
+ walk(child, `${currentPath}.${key}`);
176
+ }
177
+ }
178
+
179
+ walk(value, pathPrefix);
180
+ return matches;
181
+ }
182
+
183
+ function expandHomePath(filePath) {
184
+ if (filePath === "~") {
185
+ return os.homedir();
186
+ }
187
+
188
+ if (filePath.startsWith("~/")) {
189
+ return path.join(os.homedir(), filePath.slice(2));
190
+ }
191
+
192
+ return filePath;
193
+ }
194
+
195
+ function hasTraversalSegment(filePath) {
196
+ return filePath.split(/[\\/]+/).includes("..");
197
+ }
198
+
199
+ function permittedMediaRoots() {
200
+ return [
201
+ "/data/.openclaw/media",
202
+ path.join(os.homedir(), ".openclaw", "media"),
203
+ path.resolve(process.cwd(), ".openclaw", "media"),
204
+ ].map((root) => path.resolve(root));
205
+ }
206
+
207
+ // Subdirectories of `.openclaw/` the plugin is allowed to read files from.
208
+ //
209
+ // This is a SECURITY BOUNDARY. The file-upload relay reads whatever local path
210
+ // the agent puts in `s3key` and ships the bytes off-machine to a public
211
+ // destination (e.g. a social post), with no human confirming the file. Because
212
+ // the path is chosen by a prompt-injectable LLM, an unrestricted allowlist would
213
+ // turn `s3key` into an arbitrary-local-file-exfiltration primitive — an injected
214
+ // instruction could publish `~/.ssh/id_rsa`, `~/.aws/credentials`, a `.env`, etc.
215
+ //
216
+ // We therefore only read from `.openclaw/` subtrees that hold media-shareable or
217
+ // agent-owned content:
218
+ // media — files the user attached in OpenClaw
219
+ // agents — agent working dirs
220
+ // workspace-* — content the agent generated in its own workspace
221
+ // Everything else (home dotfiles, ~/.ssh, ~/.aws, /etc, arbitrary absolute
222
+ // paths) stays blocked, as does any `..` traversal (see hasTraversalSegment).
223
+ const PERMITTED_OPENCLAW_SUBDIRS = ["media", "agents"];
224
+
225
+ function isPermittedOpenclawSubdir(segment) {
226
+ return PERMITTED_OPENCLAW_SUBDIRS.includes(segment) || segment.startsWith("workspace");
227
+ }
228
+
229
+ function isInsidePath(root, candidate) {
230
+ const relative = path.relative(root, candidate);
231
+ return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative);
232
+ }
233
+
234
+ function resolvePermittedMediaPath(s3key) {
235
+ if (!isNonEmptyString(s3key) || hasTraversalSegment(s3key)) {
236
+ return null;
237
+ }
238
+
239
+ const normalized = path.resolve(expandHomePath(s3key));
240
+ const roots = permittedMediaRoots();
241
+ if (roots.some((root) => isInsidePath(root, normalized))) {
242
+ return normalized;
243
+ }
244
+
245
+ // Allow files under a trusted `.openclaw/<subdir>/...` subtree even when
246
+ // OpenClaw's root is in a location we don't enumerate above (e.g. a container
247
+ // path). Match the segment immediately after `.openclaw`, and require at least
248
+ // one path segment after it so we only ever resolve a file *inside* the subdir.
249
+ const segments = normalized.split(/[\\/]+/);
250
+ const openclawIndex = segments.lastIndexOf(".openclaw");
251
+ if (
252
+ openclawIndex !== -1 &&
253
+ openclawIndex + 2 < segments.length &&
254
+ isPermittedOpenclawSubdir(segments[openclawIndex + 1])
255
+ ) {
256
+ return normalized;
257
+ }
258
+
259
+ return null;
260
+ }
261
+
262
+ function readFileForUpload(fileUploadable, fieldPath) {
263
+ const filePath = resolvePermittedMediaPath(fileUploadable.s3key);
264
+ if (!filePath) {
265
+ throw new CutoffsPluginValidationError(
266
+ `The file for ${fieldPath} is outside the permitted OpenClaw attachment roots.`,
267
+ {
268
+ code: "invalid_file_path",
269
+ invalidFields: [`${fieldPath}.s3key`],
270
+ hint:
271
+ "Re-attach the file through OpenClaw so it is stored under an OpenClaw media directory before retrying.",
272
+ },
273
+ );
274
+ }
275
+
276
+ let stats;
277
+ try {
278
+ stats = statSync(filePath);
279
+ } catch (error) {
280
+ throw new CutoffsPluginValidationError(
281
+ `The file for ${fieldPath} could not be read: ${error instanceof Error ? error.message : "unknown read error"}.`,
282
+ {
283
+ code: "file_read_failed",
284
+ invalidFields: [`${fieldPath}.s3key`],
285
+ hint: "Re-attach the file through OpenClaw and retry the tool call.",
286
+ },
287
+ );
288
+ }
289
+
290
+ if (!stats.isFile()) {
291
+ throw new CutoffsPluginValidationError(
292
+ `The file for ${fieldPath} is not a regular file.`,
293
+ {
294
+ code: "file_read_failed",
295
+ invalidFields: [`${fieldPath}.s3key`],
296
+ hint: "Re-attach a regular file through OpenClaw and retry the tool call.",
297
+ },
298
+ );
299
+ }
300
+
301
+ if (stats.size > MAX_FILE_UPLOAD_BYTES) {
302
+ throw new CutoffsPluginValidationError(
303
+ `The file for ${fieldPath} is ${stats.size} bytes, exceeding the ${MAX_FILE_UPLOAD_BYTES}-byte cap (${MAX_FILE_UPLOAD_BYTES / (1024 * 1024)} MB).`,
304
+ {
305
+ code: "file_too_large",
306
+ invalidFields: [`${fieldPath}.s3key`],
307
+ hint: "Use a public URL field for this media when the tool supports one, or attach a smaller file.",
308
+ },
309
+ );
310
+ }
311
+
312
+ let bytes;
313
+ try {
314
+ bytes = readFileSync(filePath);
315
+ } catch (error) {
316
+ throw new CutoffsPluginValidationError(
317
+ `The file for ${fieldPath} could not be read: ${error instanceof Error ? error.message : "unknown read error"}.`,
318
+ {
319
+ code: "file_read_failed",
320
+ invalidFields: [`${fieldPath}.s3key`],
321
+ hint: "Re-attach the file through OpenClaw and retry the tool call.",
322
+ },
323
+ );
324
+ }
325
+
326
+ if (bytes.byteLength > MAX_FILE_UPLOAD_BYTES) {
327
+ throw new CutoffsPluginValidationError(
328
+ `The file for ${fieldPath} is ${bytes.byteLength} bytes, exceeding the ${MAX_FILE_UPLOAD_BYTES}-byte cap (${MAX_FILE_UPLOAD_BYTES / (1024 * 1024)} MB).`,
329
+ {
330
+ code: "file_too_large",
331
+ invalidFields: [`${fieldPath}.s3key`],
332
+ hint: "Use a public URL field for this media when the tool supports one, or attach a smaller file.",
333
+ },
334
+ );
335
+ }
336
+
337
+ return {
338
+ pointer: fileUploadable.s3key,
339
+ name: fileUploadable.name,
340
+ mimetype: fileUploadable.mimetype,
341
+ md5: createHash("md5").update(bytes).digest("hex"),
342
+ dataBase64: bytes.toString("base64"),
343
+ };
344
+ }
345
+
346
+ function buildFilesEnvelope(args) {
347
+ const files = [];
348
+ const seenPointers = new Set();
349
+
350
+ for (const match of collectFileUploadables(args)) {
351
+ if (APOLLO_S3KEY_PATTERN.test(match.value.s3key)) {
352
+ continue;
353
+ }
354
+
355
+ if (seenPointers.has(match.value.s3key)) {
356
+ continue;
357
+ }
358
+
359
+ files.push(readFileForUpload(match.value, match.path));
360
+ seenPointers.add(match.value.s3key);
361
+ }
362
+
363
+ return files;
364
+ }
365
+
366
+ function collectErrorFields(value) {
367
+ if (!Array.isArray(value)) {
368
+ return [];
369
+ }
370
+
371
+ return value.filter(isNonEmptyString).map((item) => item.trim());
372
+ }
373
+
374
+ function resolveErrorSchema(payload) {
375
+ if (isPlainObject(payload?.inputSchema)) {
376
+ return payload.inputSchema;
377
+ }
378
+
379
+ if (isPlainObject(payload?.tool) && isPlainObject(payload.tool.inputSchema)) {
380
+ return payload.tool.inputSchema;
381
+ }
382
+
383
+ return null;
384
+ }
385
+
386
+ function formatStructuredCutoffsError(payload, status) {
387
+ if (payload && typeof payload.error === "string") {
388
+ const details =
389
+ Array.isArray(payload?.details) && payload.details.length > 0
390
+ ? `\n${payload.details.join("\n")}`
391
+ : "";
392
+ const upgradeHint =
393
+ typeof payload?.upgradeUrl === "string"
394
+ ? `\nUpgrade here: ${payload.upgradeUrl}`
395
+ : "";
396
+ return `${payload.error}${details}${upgradeHint}`;
397
+ }
398
+
399
+ const objectError = isPlainObject(payload?.error) ? payload.error : null;
400
+ const message = isNonEmptyString(objectError?.message)
401
+ ? objectError.message.trim()
402
+ : isNonEmptyString(payload?.message)
403
+ ? payload.message.trim()
404
+ : `Cutoffs request failed with status ${status}`;
405
+ const missingFields = collectErrorFields(payload?.missingFields);
406
+ const invalidFields = collectErrorFields(payload?.invalidFields);
407
+ const details = collectErrorFields(payload?.details);
408
+ const hint = isNonEmptyString(payload?.hint) ? payload.hint.trim() : "";
409
+ const schema = resolveErrorSchema(payload);
410
+ const lines = [message];
411
+
412
+ if (missingFields.length > 0) {
413
+ lines.push(`Missing fields: ${missingFields.join(", ")}`);
414
+ }
415
+
416
+ if (invalidFields.length > 0) {
417
+ lines.push(`Invalid fields: ${invalidFields.join(", ")}`);
418
+ }
419
+
420
+ if (details.length > 0) {
421
+ lines.push(...details);
422
+ }
423
+
424
+ if (hint) {
425
+ lines.push(`Hint: ${hint}`);
426
+ }
427
+
428
+ if (schema) {
429
+ lines.push("Input schema:");
430
+ lines.push(stringifyPayload(schema));
431
+ }
432
+
433
+ if (typeof payload?.upgradeUrl === "string") {
434
+ lines.push(`Upgrade here: ${payload.upgradeUrl}`);
435
+ }
436
+
437
+ return lines.join("\n");
438
+ }
439
+
440
+ async function callCutoffs(api, path, options = {}) {
441
+ const { apiKey } = requireApiKey(api);
442
+ const response = await fetch(`${DEFAULT_BASE_URL}${path}`, {
443
+ method: options.method ?? "GET",
444
+ headers: {
445
+ "X-Cutoffs-API-Key": apiKey,
446
+ "Content-Type": "application/json",
447
+ "User-Agent": USER_AGENT,
448
+ ...(options.headers ?? {}),
449
+ },
450
+ body: options.body ? JSON.stringify(options.body) : undefined,
451
+ });
452
+
453
+ const payload = await parseJsonSafely(response);
454
+
455
+ if (!response.ok) {
456
+ const error = new Error(formatStructuredCutoffsError(payload, response.status));
457
+ error.cutoffsPayload = payload;
458
+ error.cutoffsStatus = response.status;
459
+ throw error;
460
+ }
461
+
462
+ return payload;
463
+ }
464
+
465
+ async function callCutoffsPublic(path, options = {}) {
466
+ const response = await fetch(`${DEFAULT_BASE_URL}${path}`, {
467
+ method: options.method ?? "GET",
468
+ headers: {
469
+ "Content-Type": "application/json",
470
+ "User-Agent": USER_AGENT,
471
+ ...(options.headers ?? {}),
472
+ },
473
+ body: options.body ? JSON.stringify(options.body) : undefined,
474
+ });
475
+
476
+ const payload = await parseJsonSafely(response);
477
+
478
+ if (!response.ok) {
479
+ const message =
480
+ payload && typeof payload.error === "string"
481
+ ? payload.error
482
+ : `Cutoffs request failed with status ${response.status}`;
483
+ throw new Error(message);
484
+ }
485
+
486
+ return payload;
487
+ }
488
+
489
+ function textResult(text, details) {
490
+ return {
491
+ content: [
492
+ {
493
+ type: "text",
494
+ text,
495
+ },
496
+ ],
497
+ details,
498
+ };
499
+ }
500
+
501
+ function buildStartConnectionText(payload) {
502
+ if (payload?.alreadyConnected) {
503
+ const summary = {
504
+ integration: payload.integration,
505
+ connection: payload.connection ?? null,
506
+ };
507
+
508
+ return [
509
+ `${payload.integration} is already connected through Cutoffs. No new connection needed — use cutoffs_list_tools with integration "${payload.integration}" and cutoffs_call_tool to act on it.`,
510
+ "",
511
+ stringifyPayload(summary),
512
+ ].join("\n");
513
+ }
514
+
515
+ const summary = {
516
+ integration: payload.integration,
517
+ sessionToken: payload.sessionToken,
518
+ status: payload.status,
519
+ displayCode: payload.displayCode,
520
+ connectUrl: payload.connectUrl,
521
+ statusUrl: payload.statusUrl,
522
+ expiresAt: payload.expiresAt,
523
+ pollIntervalMs: payload.pollIntervalMs,
524
+ };
525
+
526
+ return [
527
+ "Cutoffs connection session started.",
528
+ "",
529
+ "Open the hosted setup URL, complete authentication, then poll the session token until the status changes.",
530
+ "",
531
+ stringifyPayload(summary),
532
+ ].join("\n");
533
+ }
534
+
535
+ function buildStatusText(payload) {
536
+ const summary = {
537
+ status: payload?.session?.status ?? null,
538
+ integration: payload?.session?.integration ?? null,
539
+ sessionToken: payload?.session?.token ?? null,
540
+ displayCode: payload?.session?.displayCode ?? null,
541
+ expiresAt: payload?.session?.expiresAt ?? null,
542
+ completedAt: payload?.session?.completedAt ?? null,
543
+ errorMessage: payload?.session?.errorMessage ?? null,
544
+ connection: payload?.connection ?? null,
545
+ };
546
+
547
+ return [
548
+ "Cutoffs connection session status:",
549
+ "",
550
+ stringifyPayload(summary),
551
+ ].join("\n");
552
+ }
553
+
554
+ function summarizeIntegrations(payload) {
555
+ const connections = Array.isArray(payload?.integrations) ? payload.integrations : [];
556
+ const grouped = new Map();
557
+
558
+ for (const connection of connections) {
559
+ const slug = typeof connection?.integration === "string" ? connection.integration : "unknown";
560
+ const existing = grouped.get(slug) ?? {
561
+ integration: slug,
562
+ connectionCount: 0,
563
+ defaultConnectionId: null,
564
+ connections: [],
565
+ };
566
+
567
+ existing.connectionCount += 1;
568
+
569
+ if (connection?.isDefault) {
570
+ existing.defaultConnectionId = connection.id ?? null;
571
+ }
572
+
573
+ existing.connections.push({
574
+ id: connection?.id ?? null,
575
+ connectionLabel: connection?.connectionLabel ?? null,
576
+ accountLabel: connection?.accountLabel ?? null,
577
+ isDefault: Boolean(connection?.isDefault),
578
+ expiresAt: connection?.expiresAt ?? null,
579
+ });
580
+
581
+ grouped.set(slug, existing);
582
+ }
583
+
584
+ return Array.from(grouped.values()).sort((left, right) =>
585
+ left.integration.localeCompare(right.integration),
586
+ );
587
+ }
588
+
589
+ function buildIntegrationListText(payload) {
590
+ const summary = summarizeIntegrations(payload);
591
+
592
+ if (summary.length === 0) {
593
+ return [
594
+ "No connected Cutoffs integrations found.",
595
+ "",
596
+ "Start a hosted connection first if the user wants to connect a new app.",
597
+ ].join("\n");
598
+ }
599
+
600
+ return [
601
+ "Connected Cutoffs integrations:",
602
+ "",
603
+ stringifyPayload(summary),
604
+ ].join("\n");
605
+ }
606
+
607
+ function buildToolListText(payload) {
608
+ const tools = Array.isArray(payload?.tools) ? payload.tools : [];
609
+ const integration = isNonEmptyString(payload?.integration) ? payload.integration : null;
610
+ const query = isNonEmptyString(payload?.query) ? payload.query : null;
611
+ const heading = query
612
+ ? integration
613
+ ? `Matching Cutoffs tools for ${integration} and "${query}":`
614
+ : `Matching Cutoffs tools for "${query}":`
615
+ : integration
616
+ ? `Available Cutoffs tools for ${integration}:`
617
+ : "Available Cutoffs tools:";
618
+ const emptyHeading = query
619
+ ? integration
620
+ ? `No matching Cutoffs tools found for ${integration} and "${query}".`
621
+ : `No matching Cutoffs tools found for "${query}".`
622
+ : integration
623
+ ? `No Cutoffs tools are currently available for ${integration}.`
624
+ : "No Cutoffs tools are currently available.";
625
+
626
+ return [
627
+ tools.length > 0 ? heading : emptyHeading,
628
+ "",
629
+ "Bias toward action: use the live inputSchema in these results, pick the best matching tool, and call it. Use cutoffs_describe_tool when you need more safety guidance or examples. If Cutoffs rejects the arguments, read the returned missingFields and inputSchema, then retry with corrections.",
630
+ "",
631
+ stringifyPayload(
632
+ tools.map((tool) => ({
633
+ integration: tool?.integration ?? null,
634
+ name: tool?.name ?? null,
635
+ description: tool?.description ?? tool?.summary ?? null,
636
+ inputSchema: tool?.inputSchema ?? null,
637
+ mode: tool?.mode ?? null,
638
+ accessLevel: tool?.accessLevel ?? null,
639
+ risk: tool?.risk ?? null,
640
+ guidanceAvailable: Boolean(tool?.guidanceAvailable),
641
+ requiresConfirmation: Boolean(tool?.requiresConfirmation),
642
+ previewAvailable: Boolean(tool?.previewAvailable),
643
+ defaultConnectionId: tool?.defaultConnectionId ?? null,
644
+ connectionCount: tool?.connectionCount ?? 0,
645
+ })),
646
+ ),
647
+ ].join("\n");
648
+ }
649
+
650
+ function buildToolDescriptionText(payload) {
651
+ const summary = payload?.tool ?? payload;
652
+
653
+ return [
654
+ "Cutoffs tool description:",
655
+ "",
656
+ stringifyPayload(summary),
657
+ ].join("\n");
658
+ }
659
+
660
+ function storedResultHint(payload) {
661
+ const candidate = payload?.result ?? payload?.data ?? payload;
662
+ const envelope =
663
+ candidate && typeof candidate === "object" && candidate.cutoffs_result === "stored"
664
+ ? candidate
665
+ : null;
666
+ if (!envelope) {
667
+ return null;
668
+ }
669
+
670
+ return [
671
+ "",
672
+ `This result is large and stored server-side (execution_id: ${envelope.execution_id}). Do not paste it or ask the user to.`,
673
+ `Read only what you need with cutoffs_get_result: { executionId: "${envelope.execution_id}", path, fields, offset, limit } (or count for just the shape).`,
674
+ ].join("\n");
675
+ }
676
+
677
+ function buildToolExecutionText(toolName, payload) {
678
+ const summary = {
679
+ tool: payload?.tool?.name ?? toolName,
680
+ integration: payload?.tool?.integration ?? null,
681
+ connectionId: payload?.connectionId ?? null,
682
+ requiresConfirmation: Boolean(payload?.requiresConfirmation),
683
+ policyReason: payload?.policyReason ?? null,
684
+ args: payload?.args ?? null,
685
+ result: payload?.result ?? payload,
686
+ };
687
+
688
+ const hint = storedResultHint(payload);
689
+
690
+ return [
691
+ `Cutoffs tool result: ${toolName}`,
692
+ "",
693
+ stringifyPayload(summary),
694
+ ...(hint ? [hint] : []),
695
+ ].join("\n");
696
+ }
697
+
698
+ function buildToolErrorText(toolName, error) {
699
+ const status = error?.cutoffsStatus ?? null;
700
+ const formatted =
701
+ typeof error?.message === "string" && error.message.trim()
702
+ ? error.message
703
+ : `Cutoffs request failed${status ? ` with status ${status}` : ""}.`;
704
+ return [`Cutoffs tool failed: ${toolName}`, "", formatted].join("\n");
705
+ }
706
+
707
+ function buildPluginValidationResult(toolName, error) {
708
+ const payload = {
709
+ ok: false,
710
+ error: {
711
+ type: "validation",
712
+ code: error.code ?? "invalid_file_upload",
713
+ message: error.message,
714
+ retryable: false,
715
+ },
716
+ details: [error.message],
717
+ missingFields: [],
718
+ invalidFields: error.invalidFields ?? [],
719
+ hint: error.hint,
720
+ };
721
+ const formattedError = new Error(formatStructuredCutoffsError(payload, 400));
722
+ formattedError.cutoffsPayload = payload;
723
+ formattedError.cutoffsStatus = 400;
724
+ return textResult(buildToolErrorText(toolName, formattedError), payload);
725
+ }
726
+
727
+ async function callToolWithErrorContent(toolName, api, path, options) {
728
+ try {
729
+ const payload = await callCutoffs(api, path, options);
730
+ return textResult(buildToolExecutionText(toolName, payload), payload);
731
+ } catch (error) {
732
+ if (error && typeof error === "object" && "cutoffsPayload" in error) {
733
+ return textResult(buildToolErrorText(toolName, error), error.cutoffsPayload);
734
+ }
735
+ throw error;
736
+ }
737
+ }
738
+
739
+ function buildPairingStartText(payload) {
740
+ const summary = buildPairingSummary(payload);
741
+
742
+ return [
743
+ "Cutoffs browser pairing started.",
744
+ "",
745
+ "Open the pairing URL in your browser, sign in to Cutoffs if needed, and approve this OpenClaw device. After approval, come back here and send `done` so OpenClaw can finish pairing safely.",
746
+ "",
747
+ stringifyPayload(summary),
748
+ ].join("\n");
749
+ }
750
+
751
+ function buildPairingSummary(payload) {
752
+ const session = payload?.session ?? null;
753
+
754
+ return {
755
+ status: session?.status ?? payload?.status ?? null,
756
+ sessionToken: session?.token ?? payload?.sessionToken ?? null,
757
+ deviceLabel: session?.deviceLabel ?? payload?.deviceLabel ?? null,
758
+ approvedUserHint: session?.approvedUserHint ?? null,
759
+ expiresAt: session?.expiresAt ?? payload?.expiresAt ?? null,
760
+ approvedAt: session?.approvedAt ?? null,
761
+ pairedAt: session?.pairedAt ?? null,
762
+ pairUrl: payload?.pairUrl ?? null,
763
+ pollIntervalMs: payload?.pollIntervalMs ?? null,
764
+ integrations: Array.isArray(payload?.integrations) ? payload.integrations : undefined,
765
+ };
766
+ }
767
+
768
+ function buildPairingPendingText(payload) {
769
+ return [
770
+ "Cutoffs pairing is still waiting for browser approval.",
771
+ "",
772
+ "Keep the pairing page open and approve this device. After approval, come back here and send `done` so OpenClaw can finish pairing.",
773
+ "",
774
+ stringifyPayload(buildPairingSummary(payload)),
775
+ ].join("\n");
776
+ }
777
+
778
+ function buildPairingProgressText(payload) {
779
+ return [
780
+ "Cutoffs browser approval received.",
781
+ "",
782
+ "Go back to OpenClaw and send `done` to finish the local setup.",
783
+ "",
784
+ stringifyPayload(buildPairingSummary(payload)),
785
+ ].join("\n");
786
+ }
787
+
788
+ function buildPairingCompletionText(payload) {
789
+ return [
790
+ "Cutoffs pairing completed.",
791
+ "",
792
+ "The local device credential is saved and ready to use.",
793
+ "",
794
+ stringifyPayload(buildPairingSummary(payload)),
795
+ ].join("\n");
796
+ }
797
+
798
+ function buildPairingStatusText(payload) {
799
+ return [
800
+ "Cutoffs pairing status:",
801
+ "",
802
+ stringifyPayload(buildPairingSummary(payload)),
803
+ ].join("\n");
804
+ }
805
+
806
+ function collectToolArguments(params, reservedKeys) {
807
+ if (isPlainObject(params.arguments)) {
808
+ return params.arguments;
809
+ }
810
+
811
+ const fallback = {};
812
+
813
+ for (const [key, value] of Object.entries(params ?? {})) {
814
+ if (reservedKeys.has(key)) {
815
+ continue;
816
+ }
817
+
818
+ fallback[key] = value;
819
+ }
820
+
821
+ return fallback;
822
+ }
823
+
824
+ async function persistPluginConfig(api, mutateConfig) {
825
+ const runtimeConfig = api.runtime?.config;
826
+
827
+ if (
828
+ typeof runtimeConfig?.loadConfig === "function" &&
829
+ typeof runtimeConfig?.writeConfigFile === "function"
830
+ ) {
831
+ const currentConfig = runtimeConfig.loadConfig();
832
+ const nextConfig = mutateConfig(structuredClone(currentConfig));
833
+
834
+ await runtimeConfig.writeConfigFile(nextConfig);
835
+
836
+ return nextConfig;
837
+ }
838
+
839
+ const fallbackRuntime = await import("openclaw/plugin-sdk/config-runtime");
840
+
841
+ if (typeof fallbackRuntime.updateConfig === "function") {
842
+ return fallbackRuntime.updateConfig((currentConfig) =>
843
+ mutateConfig(structuredClone(currentConfig)),
844
+ );
845
+ }
846
+
847
+ if (
848
+ typeof fallbackRuntime.loadConfig === "function" &&
849
+ typeof fallbackRuntime.writeConfigFile === "function"
850
+ ) {
851
+ const currentConfig = fallbackRuntime.loadConfig();
852
+ const nextConfig = mutateConfig(structuredClone(currentConfig));
853
+
854
+ await fallbackRuntime.writeConfigFile(nextConfig);
855
+
856
+ return nextConfig;
857
+ }
858
+
859
+ throw new Error("OpenClaw runtime config API is unavailable.");
860
+ }
861
+
862
+ function updatePluginEntryConfig(config, updater) {
863
+ const currentPlugins = config.plugins ?? {};
864
+ const currentEntries = currentPlugins.entries ?? {};
865
+ const legacyEntry = LEGACY_PLUGIN_IDS.map((pluginId) => currentEntries[pluginId]).find(
866
+ (entry) => entry && typeof entry === "object",
867
+ );
868
+ const currentEntry = currentEntries[PLUGIN_ID] ?? legacyEntry ?? {};
869
+ const currentPluginConfig = currentEntry.config ?? {};
870
+ const nextPluginConfig = updater({ ...currentPluginConfig });
871
+ const nextEntries = {
872
+ ...currentEntries,
873
+ [PLUGIN_ID]: {
874
+ ...currentEntry,
875
+ enabled: true,
876
+ config: nextPluginConfig,
877
+ },
878
+ };
879
+
880
+ for (const legacyPluginId of LEGACY_PLUGIN_IDS) {
881
+ if (legacyPluginId !== PLUGIN_ID) {
882
+ delete nextEntries[legacyPluginId];
883
+ }
884
+ }
885
+
886
+ return {
887
+ ...config,
888
+ plugins: {
889
+ ...currentPlugins,
890
+ entries: nextEntries,
891
+ },
892
+ };
893
+ }
894
+
895
+ async function saveApiKeyToConfig(api, apiKey, options = {}) {
896
+ const clearPendingPairing = options.clearPendingPairing !== false;
897
+
898
+ await persistPluginConfig(api, (config) =>
899
+ updatePluginEntryConfig(config, (pluginConfig) => {
900
+ const nextPluginConfig = {
901
+ ...pluginConfig,
902
+ apiKey,
903
+ };
904
+
905
+ delete nextPluginConfig.baseUrl;
906
+
907
+ if (clearPendingPairing) {
908
+ delete nextPluginConfig.pendingPairing;
909
+ }
910
+
911
+ return nextPluginConfig;
912
+ }),
913
+ );
914
+ }
915
+
916
+ async function clearPendingPairing(api) {
917
+ await persistPluginConfig(api, (config) =>
918
+ updatePluginEntryConfig(config, (pluginConfig) => {
919
+ const nextPluginConfig = { ...pluginConfig };
920
+ delete nextPluginConfig.pendingPairing;
921
+ return nextPluginConfig;
922
+ }),
923
+ );
924
+ }
925
+
926
+ async function savePendingPairing(api, pairing) {
927
+ await persistPluginConfig(api, (config) =>
928
+ updatePluginEntryConfig(config, (pluginConfig) => ({
929
+ ...pluginConfig,
930
+ pendingPairing: pairing,
931
+ })),
932
+ );
933
+ }
934
+
935
+ function isPendingPairingExpired(pendingPairing) {
936
+ if (!pendingPairing?.expiresAt) {
937
+ return true;
938
+ }
939
+
940
+ const expiresAt = Date.parse(pendingPairing.expiresAt);
941
+
942
+ return Number.isFinite(expiresAt) ? expiresAt <= Date.now() : true;
943
+ }
944
+
945
+ function normalizePairingPollIntervalMs(value) {
946
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
947
+ return Math.floor(value);
948
+ }
949
+
950
+ return DEFAULT_PAIRING_POLL_INTERVAL_MS;
951
+ }
952
+
953
+ function resolvePairingWaitTimeoutMs(pendingPairing) {
954
+ const expiresAt = Date.parse(pendingPairing?.expiresAt ?? "");
955
+
956
+ if (Number.isFinite(expiresAt)) {
957
+ return Math.max(0, Math.min(DEFAULT_PAIRING_WAIT_TIMEOUT_MS, expiresAt - Date.now()));
958
+ }
959
+
960
+ return DEFAULT_PAIRING_WAIT_TIMEOUT_MS;
961
+ }
962
+
963
+ function createAbortError() {
964
+ const error = new Error("Cutoffs pairing was interrupted before completion.");
965
+ error.name = "AbortError";
966
+ return error;
967
+ }
968
+
969
+ async function waitForPairingPollDelay(ms, signal) {
970
+ if (ms <= 0) {
971
+ return;
972
+ }
973
+
974
+ await new Promise((resolve, reject) => {
975
+ const timer = setTimeout(() => {
976
+ cleanup();
977
+ resolve();
978
+ }, ms);
979
+
980
+ const handleAbort = () => {
981
+ cleanup();
982
+ reject(createAbortError());
983
+ };
984
+
985
+ const cleanup = () => {
986
+ clearTimeout(timer);
987
+ signal?.removeEventListener("abort", handleAbort);
988
+ };
989
+
990
+ if (signal?.aborted) {
991
+ handleAbort();
992
+ return;
993
+ }
994
+
995
+ signal?.addEventListener("abort", handleAbort, { once: true });
996
+ });
997
+ }
998
+
999
+ async function exchangeAndFinalizePairing(api, pendingPairing) {
1000
+ const exchanged = await callCutoffsPublic(
1001
+ `/api/openclaw/pair/sessions/${encodeURIComponent(pendingPairing.sessionToken)}/exchange`,
1002
+ {
1003
+ method: "POST",
1004
+ body: { verifier: pendingPairing.verifier },
1005
+ },
1006
+ );
1007
+ const apiKey = safeTrim(exchanged?.apiKey);
1008
+
1009
+ if (!apiKey) {
1010
+ throw new Error("Cutoffs pairing did not return a local device credential.");
1011
+ }
1012
+
1013
+ await saveApiKeyToConfig(api, apiKey, {
1014
+ clearPendingPairing: false,
1015
+ });
1016
+
1017
+ let finalizedSession = exchanged?.session ?? null;
1018
+
1019
+ try {
1020
+ const finalized = await callCutoffsPublic(
1021
+ `/api/openclaw/pair/sessions/${encodeURIComponent(pendingPairing.sessionToken)}/finalize`,
1022
+ {
1023
+ method: "POST",
1024
+ body: { verifier: pendingPairing.verifier },
1025
+ },
1026
+ );
1027
+ finalizedSession = finalized?.session ?? finalizedSession;
1028
+ await clearPendingPairing(api);
1029
+ } catch {
1030
+ // Keep the pending pairing marker so the plugin can retry finalization later.
1031
+ }
1032
+
1033
+ const integrationsPayload = await callCutoffs(api, "/api/integrations").catch(() => ({
1034
+ integrations: [],
1035
+ }));
1036
+
1037
+ return {
1038
+ session: finalizedSession,
1039
+ integrations: Array.isArray(integrationsPayload?.integrations)
1040
+ ? integrationsPayload.integrations
1041
+ : [],
1042
+ };
1043
+ }
1044
+
1045
+ async function getPairingStatus(api) {
1046
+ const { pendingPairing, apiKey } = getPluginConfig(api);
1047
+
1048
+ if (!pendingPairing) {
1049
+ if (apiKey) {
1050
+ const integrationsPayload = await callCutoffs(api, "/api/integrations").catch(() => ({
1051
+ integrations: [],
1052
+ }));
1053
+
1054
+ return {
1055
+ session: {
1056
+ status: "paired",
1057
+ token: null,
1058
+ displayCode: null,
1059
+ deviceLabel: null,
1060
+ approvedUserHint: null,
1061
+ expiresAt: null,
1062
+ approvedAt: null,
1063
+ pairedAt: null,
1064
+ },
1065
+ integrations: Array.isArray(integrationsPayload?.integrations)
1066
+ ? integrationsPayload.integrations
1067
+ : [],
1068
+ };
1069
+ }
1070
+
1071
+ throw new Error("No Cutoffs pairing session is in progress.");
1072
+ }
1073
+
1074
+ if (isPendingPairingExpired(pendingPairing)) {
1075
+ await clearPendingPairing(api);
1076
+ throw new Error("The pending Cutoffs pairing session expired. Start a new pairing flow.");
1077
+ }
1078
+
1079
+ const payload = await callCutoffsPublic(
1080
+ `/api/openclaw/pair/sessions/${encodeURIComponent(pendingPairing.sessionToken)}`,
1081
+ );
1082
+ const status = safeTrim(payload?.session?.status);
1083
+
1084
+ if (status === "ready_for_device" || status === "awaiting_local_save") {
1085
+ const completed = await exchangeAndFinalizePairing(api, pendingPairing);
1086
+
1087
+ return {
1088
+ session: {
1089
+ ...(completed.session ?? {}),
1090
+ },
1091
+ pairUrl: pendingPairing.pairUrl,
1092
+ integrations: completed.integrations,
1093
+ };
1094
+ }
1095
+
1096
+ if (status === "expired" || status === "failed") {
1097
+ await clearPendingPairing(api);
1098
+ }
1099
+
1100
+ return {
1101
+ ...payload,
1102
+ pairUrl: pendingPairing.pairUrl,
1103
+ };
1104
+ }
1105
+
1106
+ async function waitForPairingCompletion(api, pendingPairing, options = {}) {
1107
+ const pollIntervalMs = normalizePairingPollIntervalMs(options.pollIntervalMs);
1108
+ const timeoutMs =
1109
+ typeof options.timeoutMs === "number" && Number.isFinite(options.timeoutMs)
1110
+ ? Math.max(0, Math.floor(options.timeoutMs))
1111
+ : resolvePairingWaitTimeoutMs(pendingPairing);
1112
+ const onUpdate = typeof options.onUpdate === "function" ? options.onUpdate : null;
1113
+ const initialPayload = {
1114
+ session: {
1115
+ status: "awaiting_browser",
1116
+ token: pendingPairing.sessionToken,
1117
+ deviceLabel: pendingPairing.deviceLabel || "OpenClaw device",
1118
+ approvedUserHint: null,
1119
+ expiresAt: pendingPairing.expiresAt,
1120
+ approvedAt: null,
1121
+ pairedAt: null,
1122
+ },
1123
+ pairUrl: pendingPairing.pairUrl,
1124
+ pollIntervalMs,
1125
+ };
1126
+
1127
+ onUpdate?.(textResult(buildPairingStartText({ ...initialPayload, autoFinish: true }), initialPayload));
1128
+
1129
+ const deadline = Date.now() + timeoutMs;
1130
+ let lastStatus = "awaiting_browser";
1131
+ let lastPayload = initialPayload;
1132
+
1133
+ while (Date.now() <= deadline) {
1134
+ if (options.signal?.aborted) {
1135
+ throw createAbortError();
1136
+ }
1137
+
1138
+ const payload = await getPairingStatus(api);
1139
+ const status = safeTrim(payload?.session?.status) || "awaiting_browser";
1140
+
1141
+ lastPayload = payload;
1142
+
1143
+ if (onUpdate && status !== lastStatus) {
1144
+ if (status === "ready_for_device" || status === "awaiting_local_save") {
1145
+ onUpdate(textResult(buildPairingProgressText(payload), payload));
1146
+ } else if (status === "paired") {
1147
+ onUpdate(textResult(buildPairingCompletionText(payload), payload));
1148
+ } else if (status === "expired" || status === "failed") {
1149
+ onUpdate(textResult(buildPairingStatusText(payload), payload));
1150
+ }
1151
+ }
1152
+
1153
+ lastStatus = status;
1154
+
1155
+ if (status === "paired" || status === "expired" || status === "failed") {
1156
+ return payload;
1157
+ }
1158
+
1159
+ const remainingMs = deadline - Date.now();
1160
+
1161
+ if (remainingMs <= 0) {
1162
+ break;
1163
+ }
1164
+
1165
+ await waitForPairingPollDelay(Math.min(pollIntervalMs, remainingMs), options.signal);
1166
+ }
1167
+
1168
+ return lastPayload;
1169
+ }
1170
+
1171
+ function buildCommandHelp() {
1172
+ return [
1173
+ "Cutoffs commands:",
1174
+ "",
1175
+ "/cutoffs pair [deviceLabel]",
1176
+ "/cutoffs pair-status",
1177
+ "/cutoffs status",
1178
+ "/cutoffs logout",
1179
+ ].join("\n");
1180
+ }
1181
+
1182
+ const cutoffsPlugin = {
1183
+ id: PLUGIN_ID,
1184
+ name: "Cutoffs",
1185
+ description: "Generic Cutoffs bridge for hosted connections and dynamic integration tools inside OpenClaw",
1186
+ register(api) {
1187
+ api.registerCommand({
1188
+ name: "cutoffs",
1189
+ description: "Configure Cutoffs pairing and inspect plugin status.",
1190
+ acceptsArgs: true,
1191
+ handler: async (ctx) => {
1192
+ const tokens = tokenizeArgs(ctx.args);
1193
+ const action = (tokens[0] ?? "help").toLowerCase();
1194
+
1195
+ if (action === "help") {
1196
+ return { text: buildCommandHelp() };
1197
+ }
1198
+
1199
+ if (action === "status") {
1200
+ const { apiKey, pendingPairing } = getPluginConfig(api);
1201
+
1202
+ return {
1203
+ text: [
1204
+ "Cutoffs status:",
1205
+ `- credential: ${maskSecret(apiKey)}`,
1206
+ `- configured: ${apiKey ? "yes" : "no"}`,
1207
+ `- pairing: ${
1208
+ pendingPairing
1209
+ ? isPendingPairingExpired(pendingPairing)
1210
+ ? "expired"
1211
+ : "pending"
1212
+ : apiKey
1213
+ ? "not needed"
1214
+ : "idle"
1215
+ }`,
1216
+ ...(pendingPairing && !isPendingPairingExpired(pendingPairing)
1217
+ ? [
1218
+ `- pairUrl: ${pendingPairing.pairUrl}`,
1219
+ `- expiresAt: ${pendingPairing.expiresAt}`,
1220
+ ]
1221
+ : []),
1222
+ ].join("\n"),
1223
+ };
1224
+ }
1225
+
1226
+ if (action === "pair") {
1227
+ const { apiKey, pendingPairing } = getPluginConfig(api);
1228
+
1229
+ if (apiKey) {
1230
+ return {
1231
+ text: "Cutoffs is already configured on this OpenClaw install. Use /cutoffs logout first if you want to pair a different account.",
1232
+ };
1233
+ }
1234
+
1235
+ if (pendingPairing && !isPendingPairingExpired(pendingPairing)) {
1236
+ return {
1237
+ text: buildPairingStartText({
1238
+ sessionToken: pendingPairing.sessionToken,
1239
+ deviceLabel: pendingPairing.deviceLabel || "OpenClaw device",
1240
+ pairUrl: pendingPairing.pairUrl,
1241
+ expiresAt: pendingPairing.expiresAt,
1242
+ pollIntervalMs: DEFAULT_PAIRING_POLL_INTERVAL_MS,
1243
+ }),
1244
+ };
1245
+ }
1246
+
1247
+ const deviceLabel = tokens.slice(1).join(" ").trim() || "OpenClaw device";
1248
+ const payload = await callCutoffsPublic("/api/openclaw/pair/start", {
1249
+ method: "POST",
1250
+ body: { deviceLabel },
1251
+ });
1252
+
1253
+ await savePendingPairing(api, {
1254
+ sessionToken: payload.sessionToken,
1255
+ verifier: payload.verifier,
1256
+ pairUrl: payload.pairUrl,
1257
+ displayCode: payload.displayCode,
1258
+ deviceLabel: payload.deviceLabel || deviceLabel,
1259
+ expiresAt: payload.expiresAt,
1260
+ });
1261
+
1262
+ return { text: buildPairingStartText(payload) };
1263
+ }
1264
+
1265
+ if (action === "pair-status") {
1266
+ const payload = await getPairingStatus(api);
1267
+ return { text: buildPairingStatusText(payload) };
1268
+ }
1269
+
1270
+ if (action === "login") {
1271
+ return {
1272
+ text: "Manual credential entry is no longer supported. Use /cutoffs pair to start browser pairing.",
1273
+ };
1274
+ }
1275
+
1276
+ if (action === "logout") {
1277
+ await persistPluginConfig(api, (config) =>
1278
+ updatePluginEntryConfig(config, (pluginConfig) => {
1279
+ const nextPluginConfig = { ...pluginConfig };
1280
+
1281
+ delete nextPluginConfig.apiKey;
1282
+ delete nextPluginConfig.baseUrl;
1283
+ delete nextPluginConfig.pendingPairing;
1284
+
1285
+ return nextPluginConfig;
1286
+ }),
1287
+ );
1288
+
1289
+ return { text: "Cutoffs credentials removed from local OpenClaw config." };
1290
+ }
1291
+
1292
+ return { text: buildCommandHelp() };
1293
+ },
1294
+ });
1295
+
1296
+ api.registerTool({
1297
+ name: "cutoffs_begin_pairing",
1298
+ description: "Start or resume browser pairing for this OpenClaw install. Use this when Cutoffs is not configured yet so the user can approve the device in a browser without manual credential entry. After the user approves in the browser and comes back saying they are done, call cutoffs_get_pairing_status to finish the local setup.",
1299
+ parameters: Type.Object({
1300
+ deviceLabel: Type.Optional(Type.String({
1301
+ description: "Optional friendly label for this OpenClaw device, for example Steve's MacBook.",
1302
+ minLength: 1,
1303
+ })),
1304
+ }),
1305
+ async execute(_id, params, _signal, _onUpdate) {
1306
+ const { apiKey, pendingPairing } = getPluginConfig(api);
1307
+
1308
+ if (apiKey) {
1309
+ return textResult(
1310
+ "Cutoffs is already configured on this OpenClaw install. No pairing is needed unless the user wants to switch accounts.",
1311
+ {
1312
+ configured: true,
1313
+ },
1314
+ );
1315
+ }
1316
+
1317
+ if (pendingPairing && isPendingPairingExpired(pendingPairing)) {
1318
+ await clearPendingPairing(api);
1319
+ }
1320
+
1321
+ const deviceLabel = isNonEmptyString(params.deviceLabel)
1322
+ ? params.deviceLabel.trim()
1323
+ : "OpenClaw device";
1324
+
1325
+ if (pendingPairing && !isPendingPairingExpired(pendingPairing)) {
1326
+ const payload = {
1327
+ status: "awaiting_browser",
1328
+ sessionToken: pendingPairing.sessionToken,
1329
+ deviceLabel: pendingPairing.deviceLabel || deviceLabel,
1330
+ pairUrl: pendingPairing.pairUrl,
1331
+ expiresAt: pendingPairing.expiresAt,
1332
+ pollIntervalMs: DEFAULT_PAIRING_POLL_INTERVAL_MS,
1333
+ };
1334
+
1335
+ return textResult(buildPairingStartText(payload), payload);
1336
+ }
1337
+
1338
+ const payload = await callCutoffsPublic("/api/openclaw/pair/start", {
1339
+ method: "POST",
1340
+ body: {
1341
+ deviceLabel,
1342
+ },
1343
+ });
1344
+
1345
+ await savePendingPairing(api, {
1346
+ sessionToken: payload.sessionToken,
1347
+ verifier: payload.verifier,
1348
+ pairUrl: payload.pairUrl,
1349
+ displayCode: payload.displayCode,
1350
+ deviceLabel: payload.deviceLabel || deviceLabel,
1351
+ expiresAt: payload.expiresAt,
1352
+ });
1353
+
1354
+ const startPayload = {
1355
+ ...payload,
1356
+ pollIntervalMs: normalizePairingPollIntervalMs(payload?.pollIntervalMs),
1357
+ };
1358
+
1359
+ return textResult(buildPairingStartText(startPayload), startPayload);
1360
+ },
1361
+ });
1362
+
1363
+ api.registerTool({
1364
+ name: "cutoffs_get_pairing_status",
1365
+ description: "Check whether browser pairing has been approved yet. Use this after the user comes back from the browser and says they are done. When approval is complete, this tool exchanges the session for a locally stored Cutoffs credential and finalizes pairing.",
1366
+ parameters: Type.Object({}),
1367
+ async execute() {
1368
+ const payload = await getPairingStatus(api);
1369
+ return textResult(buildPairingStatusText(payload), payload);
1370
+ },
1371
+ });
1372
+
1373
+ api.registerTool({
1374
+ name: "cutoffs_start_connection",
1375
+ description: "Internal: check whether a Cutoffs integration is already connected. Prefer `cutoffs_list_integrations` for this. Do NOT use this tool to start a new connection from chat — when an integration is not connected, tell the user to open https://cutoffs.dev/dashboard and connect it there. If the user already has an active connection, the response will be `alreadyConnected: true`.",
1376
+ parameters: Type.Object({
1377
+ integration: Type.String({
1378
+ description: "Integration slug to connect, for example slack, github, or notion.",
1379
+ minLength: 1,
1380
+ }),
1381
+ forceNew: Type.Optional(Type.Boolean({
1382
+ description: "Set to true only if the user explicitly wants to add an additional connection alongside an existing one. Defaults to false, which reuses the existing active connection when present.",
1383
+ })),
1384
+ }),
1385
+ async execute(_id, params) {
1386
+ const integration = isNonEmptyString(params.integration) ? params.integration.trim() : "";
1387
+
1388
+ if (!integration) {
1389
+ throw new Error("integration is required");
1390
+ }
1391
+
1392
+ const payload = await callCutoffs(api, "/api/connect/start", {
1393
+ method: "POST",
1394
+ body: {
1395
+ integration,
1396
+ reuseIfConnected: params.forceNew !== true,
1397
+ },
1398
+ });
1399
+
1400
+ return textResult(buildStartConnectionText(payload), payload);
1401
+ },
1402
+ });
1403
+
1404
+ api.registerTool({
1405
+ name: "cutoffs_get_connection_status",
1406
+ description: "Internal: poll a hosted Cutoffs connection session by token. Rarely needed — connection setup is done in the dashboard at https://cutoffs.dev/dashboard, and `cutoffs_list_integrations` is the right way to confirm a new connection is live.",
1407
+ parameters: Type.Object({
1408
+ sessionToken: Type.String({
1409
+ description: "Session token returned by cutoffs_start_connection.",
1410
+ minLength: 1,
1411
+ }),
1412
+ }),
1413
+ async execute(_id, params) {
1414
+ const sessionToken = isNonEmptyString(params.sessionToken)
1415
+ ? params.sessionToken.trim()
1416
+ : "";
1417
+
1418
+ if (!sessionToken) {
1419
+ throw new Error("sessionToken is required");
1420
+ }
1421
+
1422
+ const payload = await callCutoffs(
1423
+ api,
1424
+ `/api/connect/sessions/${encodeURIComponent(sessionToken)}`,
1425
+ );
1426
+
1427
+ return textResult(buildStatusText(payload), payload);
1428
+ },
1429
+ });
1430
+
1431
+ api.registerTool({
1432
+ name: "cutoffs_list_integrations",
1433
+ description: "List all external apps and services currently connected through Cutoffs.",
1434
+ parameters: Type.Object({}),
1435
+ async execute() {
1436
+ const payload = await callCutoffs(api, "/api/integrations");
1437
+ return textResult(buildIntegrationListText(payload), payload);
1438
+ },
1439
+ });
1440
+
1441
+ api.registerTool({
1442
+ name: "cutoffs_list_tools",
1443
+ description: "List the live Cutoffs tools for one connected integration so you can act instead of guessing. Results include each tool's current inputSchema. Pass the integration slug from cutoffs_list_integrations, then choose the best tool and call it.",
1444
+ parameters: Type.Object({
1445
+ integration: Type.String({
1446
+ description: "Required integration slug to list only that app's tools, for example youtube, gmail, slack, notion, or github. Call cutoffs_list_integrations first if you do not know the slug.",
1447
+ minLength: 1,
1448
+ }),
1449
+ }),
1450
+ async execute(_id, params) {
1451
+ const integration = isNonEmptyString(params?.integration)
1452
+ ? params.integration.trim().toLowerCase()
1453
+ : "";
1454
+
1455
+ if (!integration) {
1456
+ throw new Error("integration is required. Call cutoffs_list_integrations first, then call cutoffs_list_tools with that integration slug.");
1457
+ }
1458
+
1459
+ const path = `/api/tools?${new URLSearchParams({ integration }).toString()}`;
1460
+ const payload = await callCutoffs(api, path);
1461
+ return textResult(buildToolListText(payload), payload);
1462
+ },
1463
+ });
1464
+
1465
+ api.registerTool({
1466
+ name: "cutoffs_search_tools",
1467
+ description: "Search the user's connected Cutoffs tools by capability or keyword when you know the goal but not the exact tool name. Results include the live inputSchema for each match. Pick the best match and call it rather than asking the user to translate provider docs into arguments.",
1468
+ parameters: Type.Object({
1469
+ query: Type.String({
1470
+ description: "Capability or keyword to search for, for example playlist, send email, channel statistics, create event, upload file, or list records.",
1471
+ minLength: 1,
1472
+ }),
1473
+ integration: Type.Optional(Type.String({
1474
+ description: "Optional connected integration slug to narrow results, for example youtube, gmail, slack, notion, or github.",
1475
+ minLength: 1,
1476
+ })),
1477
+ limit: Type.Optional(Type.Integer({
1478
+ description: "Maximum number of matches to return. Defaults to 10 and is capped by Cutoffs.",
1479
+ minimum: 1,
1480
+ maximum: 25,
1481
+ })),
1482
+ }),
1483
+ async execute(_id, params) {
1484
+ const query = isNonEmptyString(params?.query) ? params.query.trim() : "";
1485
+
1486
+ if (!query) {
1487
+ throw new Error("query is required");
1488
+ }
1489
+
1490
+ const searchParams = new URLSearchParams({ query });
1491
+ const integration = isNonEmptyString(params?.integration)
1492
+ ? params.integration.trim().toLowerCase()
1493
+ : "";
1494
+
1495
+ if (integration) {
1496
+ searchParams.set("integration", integration);
1497
+ }
1498
+
1499
+ if (Number.isInteger(params?.limit)) {
1500
+ searchParams.set("limit", String(params.limit));
1501
+ }
1502
+
1503
+ const payload = await callCutoffs(api, `/api/tools/search?${searchParams.toString()}`);
1504
+ return textResult(buildToolListText(payload), payload);
1505
+ },
1506
+ });
1507
+
1508
+ api.registerTool({
1509
+ name: "cutoffs_describe_tool",
1510
+ description: "Get the live recipe card for one Cutoffs tool: inputSchema, safety guidance, examples, prerequisites, and follow-up hints. Use this when a tool is unfamiliar or the call is write-like, then execute with your best guess.",
1511
+ parameters: Type.Object({
1512
+ tool: Type.String({
1513
+ description: "Cutoffs tool name, for example notion_search or gmail_find_email.",
1514
+ minLength: 1,
1515
+ }),
1516
+ }),
1517
+ async execute(_id, params) {
1518
+ const tool = isNonEmptyString(params.tool) ? params.tool.trim() : "";
1519
+
1520
+ if (!tool) {
1521
+ throw new Error("tool is required");
1522
+ }
1523
+
1524
+ const payload = await callCutoffs(api, `/api/tools/${encodeURIComponent(tool)}`);
1525
+ return textResult(buildToolDescriptionText(payload), payload);
1526
+ },
1527
+ });
1528
+
1529
+ api.registerTool({
1530
+ name: "cutoffs_preview_tool",
1531
+ description: "Preview a Cutoffs tool call without side effects. Use this when a write tool feels risky or the arguments are still uncertain. Preview returns the same validation shape as a real call, so you can correct fields before executing.",
1532
+ parameters: Type.Object({
1533
+ tool: Type.String({
1534
+ description: "Cutoffs tool name, for example notion_create_page or gmail_send_email.",
1535
+ minLength: 1,
1536
+ }),
1537
+ arguments: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
1538
+ description: "Arguments for the selected tool.",
1539
+ })),
1540
+ connectionId: Type.Optional(Type.Integer({
1541
+ description: "Optional Cutoffs connection id when the user has multiple connections for one integration.",
1542
+ minimum: 1,
1543
+ })),
1544
+ }),
1545
+ async execute(_id, params) {
1546
+ const tool = isNonEmptyString(params.tool) ? params.tool.trim() : "";
1547
+ const args = collectToolArguments(
1548
+ params,
1549
+ new Set(["tool", "arguments", "connectionId"]),
1550
+ );
1551
+
1552
+ if (!tool) {
1553
+ throw new Error("tool is required");
1554
+ }
1555
+
1556
+ let files;
1557
+ try {
1558
+ files = buildFilesEnvelope(args);
1559
+ } catch (error) {
1560
+ if (error instanceof CutoffsPluginValidationError) {
1561
+ return buildPluginValidationResult(tool, error);
1562
+ }
1563
+ throw error;
1564
+ }
1565
+
1566
+ return callToolWithErrorContent(
1567
+ tool,
1568
+ api,
1569
+ `/api/tools/${encodeURIComponent(tool)}/preview`,
1570
+ {
1571
+ method: "POST",
1572
+ body: {
1573
+ arguments: args,
1574
+ ...(Number.isInteger(params.connectionId)
1575
+ ? { connectionId: params.connectionId }
1576
+ : {}),
1577
+ ...(files.length > 0 ? { files } : {}),
1578
+ },
1579
+ },
1580
+ );
1581
+ },
1582
+ });
1583
+
1584
+ api.registerTool({
1585
+ name: "cutoffs_call_tool",
1586
+ description: "Run a Cutoffs tool aggressively using the live tool catalog and inputSchema as your source of truth. Do not ask the user which fields to use when the schema already tells you. If Cutoffs rejects the arguments, the error includes missingFields and the full inputSchema, so inspect that payload and retry with corrected fields.",
1587
+ parameters: Type.Object({
1588
+ tool: Type.String({
1589
+ description: "Cutoffs tool name, for example notion_search or github_list_issues.",
1590
+ minLength: 1,
1591
+ }),
1592
+ arguments: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
1593
+ description: "Arguments for the selected tool.",
1594
+ })),
1595
+ connectionId: Type.Optional(Type.Integer({
1596
+ description: "Optional Cutoffs connection id when the user has multiple connections for one integration.",
1597
+ minimum: 1,
1598
+ })),
1599
+ confirmed: Type.Optional(Type.Boolean({
1600
+ description: "Defaults to true. Pass false only when you want Cutoffs to refuse a write tool that needs explicit re-confirmation; use cutoffs_preview_tool for dry runs instead.",
1601
+ })),
1602
+ }),
1603
+ async execute(_id, params) {
1604
+ const tool = isNonEmptyString(params.tool) ? params.tool.trim() : "";
1605
+ const args = collectToolArguments(
1606
+ params,
1607
+ new Set(["tool", "arguments", "connectionId", "confirmed"]),
1608
+ );
1609
+
1610
+ if (!tool) {
1611
+ throw new Error("tool is required");
1612
+ }
1613
+
1614
+ const confirmed = params.confirmed !== false;
1615
+
1616
+ let files;
1617
+ try {
1618
+ files = buildFilesEnvelope(args);
1619
+ } catch (error) {
1620
+ if (error instanceof CutoffsPluginValidationError) {
1621
+ return buildPluginValidationResult(tool, error);
1622
+ }
1623
+ throw error;
1624
+ }
1625
+
1626
+ return callToolWithErrorContent(
1627
+ tool,
1628
+ api,
1629
+ `/api/tools/${encodeURIComponent(tool)}/execute`,
1630
+ {
1631
+ method: "POST",
1632
+ // Opt into result offloading: large results come back as a compact
1633
+ // stored envelope (read with cutoffs_get_result) instead of flooding
1634
+ // context. Servers that predate offloading ignore this header.
1635
+ headers: { "x-cutoffs-capabilities": "offload" },
1636
+ body: {
1637
+ arguments: args,
1638
+ ...(Number.isInteger(params.connectionId)
1639
+ ? { connectionId: params.connectionId }
1640
+ : {}),
1641
+ confirmed,
1642
+ ...(files.length > 0 ? { files } : {}),
1643
+ },
1644
+ },
1645
+ );
1646
+ },
1647
+ });
1648
+
1649
+ api.registerTool({
1650
+ name: "cutoffs_get_result",
1651
+ description:
1652
+ "Read a large stored Cutoffs result in slices instead of dumping the whole thing into context. When cutoffs_call_tool returns a result marked \"cutoffs_result\": \"stored\", call this with its execution_id and selectors to fetch only the part you need. Never ask the user to paste large data.",
1653
+ parameters: Type.Object({
1654
+ executionId: Type.String({
1655
+ description: "The execution_id from a stored Cutoffs result envelope.",
1656
+ minLength: 1,
1657
+ }),
1658
+ path: Type.Optional(Type.String({
1659
+ description: "Dot path into the stored result, for example \"messages\" or \"data.items\". Omit for the whole result.",
1660
+ })),
1661
+ fields: Type.Optional(Type.Array(Type.String(), {
1662
+ description: "Return only these keys from each item (when the value at path is an array) or from the object.",
1663
+ })),
1664
+ offset: Type.Optional(Type.Integer({
1665
+ description: "Pagination offset when the value at path is an array.",
1666
+ minimum: 0,
1667
+ })),
1668
+ limit: Type.Optional(Type.Integer({
1669
+ description: "Pagination size when the value at path is an array. Defaults to 25, max 200.",
1670
+ minimum: 1,
1671
+ maximum: 200,
1672
+ })),
1673
+ count: Type.Optional(Type.Boolean({
1674
+ description: "Return only the shape/size of the value at path, no data.",
1675
+ })),
1676
+ }),
1677
+ async execute(_id, params) {
1678
+ const executionId = isNonEmptyString(params.executionId) ? params.executionId.trim() : "";
1679
+
1680
+ if (!executionId) {
1681
+ throw new Error("executionId is required. Pass the execution_id from a stored result envelope.");
1682
+ }
1683
+
1684
+ const search = new URLSearchParams();
1685
+ if (isNonEmptyString(params.path)) {
1686
+ search.set("path", params.path.trim());
1687
+ }
1688
+ if (Array.isArray(params.fields)) {
1689
+ const fields = params.fields.filter(isNonEmptyString).map((field) => field.trim());
1690
+ if (fields.length > 0) {
1691
+ search.set("fields", fields.join(","));
1692
+ }
1693
+ }
1694
+ if (Number.isInteger(params.offset)) {
1695
+ search.set("offset", String(params.offset));
1696
+ }
1697
+ if (Number.isInteger(params.limit)) {
1698
+ search.set("limit", String(params.limit));
1699
+ }
1700
+ if (params.count === true) {
1701
+ search.set("count", "true");
1702
+ }
1703
+
1704
+ const query = search.toString();
1705
+ const payload = await callCutoffs(
1706
+ api,
1707
+ `/api/executions/${encodeURIComponent(executionId)}${query ? `?${query}` : ""}`,
1708
+ { headers: { "x-cutoffs-capabilities": "offload" } },
1709
+ );
1710
+
1711
+ return textResult(
1712
+ [`Cutoffs stored result: ${executionId}`, "", stringifyPayload(payload)].join("\n"),
1713
+ payload,
1714
+ );
1715
+ },
1716
+ });
1717
+ },
1718
+ };
1719
+
1720
+ export default cutoffsPlugin;