@alfe.ai/openclaw-chat 0.0.20 → 0.0.22

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/dist/plugin2.cjs CHANGED
@@ -402,6 +402,107 @@ function buildA2ATools(chatClient, log) {
402
402
  ];
403
403
  }
404
404
  //#endregion
405
+ //#region src/attachment-url.ts
406
+ /**
407
+ * Attachment URL validation for the openclaw-chat plugin.
408
+ *
409
+ * This package runs inside user-installed OpenClaw agents and has no
410
+ * dependency on the internal @alfe/api-core SSRF validator, so the
411
+ * plumbing is inlined here. The rules are deliberately conservative:
412
+ *
413
+ * - `https:` only.
414
+ * - Userinfo (`https://u:p@host/...`) is rejected.
415
+ * - IP-literal hosts (v4 or bracketed v6) are rejected outright — only
416
+ * DNS names for known provider CDNs are acceptable.
417
+ * - The host must match a curated allowlist of Alfe-issued / channel-
418
+ * provider CDNs. Anything else is dropped before we fetch it.
419
+ *
420
+ * Operators running an agent in a private network that needs to dereference
421
+ * additional hosts can extend the list via the
422
+ * `ALFE_ATTACHMENT_ALLOWED_HOSTS` env var (comma-separated, entries prefixed
423
+ * with `.` match suffixes).
424
+ */
425
+ const DEFAULT_EXACT_HOSTS = [
426
+ "s3.amazonaws.com",
427
+ "api.twilio.com",
428
+ "graph.microsoft.com",
429
+ "mmg.whatsapp.net"
430
+ ];
431
+ const DEFAULT_SUFFIX_HOSTS = [
432
+ ".s3.amazonaws.com",
433
+ ".amazonaws.com",
434
+ ".twiliocdn.com",
435
+ ".cdn.discordapp.com",
436
+ ".discordapp.net",
437
+ ".telegram.org",
438
+ ".alfe.ai"
439
+ ];
440
+ function parseExtraHosts(raw) {
441
+ const exact = /* @__PURE__ */ new Set();
442
+ const suffix = [];
443
+ if (!raw) return {
444
+ exact,
445
+ suffix
446
+ };
447
+ for (const part of raw.split(",")) {
448
+ const trimmed = part.trim().toLowerCase();
449
+ if (!trimmed) continue;
450
+ if (trimmed.startsWith(".")) suffix.push(trimmed);
451
+ else exact.add(trimmed);
452
+ }
453
+ return {
454
+ exact,
455
+ suffix
456
+ };
457
+ }
458
+ function isIpLiteral(host) {
459
+ if (host.startsWith("[") && host.endsWith("]")) return true;
460
+ return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
461
+ }
462
+ function validateAttachmentUrl(input, opts = {}) {
463
+ if (!input || input.trim() === "") return {
464
+ ok: false,
465
+ reason: "empty"
466
+ };
467
+ let url;
468
+ try {
469
+ url = new URL(input);
470
+ } catch {
471
+ return {
472
+ ok: false,
473
+ reason: "invalid_url"
474
+ };
475
+ }
476
+ if (url.protocol !== "https:") return {
477
+ ok: false,
478
+ reason: "not_https"
479
+ };
480
+ if (url.username !== "" || url.password !== "") return {
481
+ ok: false,
482
+ reason: "userinfo_not_allowed"
483
+ };
484
+ const host = url.hostname.toLowerCase();
485
+ if (!host) return {
486
+ ok: false,
487
+ reason: "empty_host"
488
+ };
489
+ if (isIpLiteral(host)) return {
490
+ ok: false,
491
+ reason: "ip_literal"
492
+ };
493
+ if (host === "localhost" || host === "metadata" || host === "metadata.google.internal") return {
494
+ ok: false,
495
+ reason: "blocked_host"
496
+ };
497
+ const extra = parseExtraHosts(opts.extraHosts ?? process.env.ALFE_ATTACHMENT_ALLOWED_HOSTS);
498
+ if (new Set([...DEFAULT_EXACT_HOSTS.map((h) => h.toLowerCase()), ...extra.exact]).has(host)) return { ok: true };
499
+ if ([...DEFAULT_SUFFIX_HOSTS.map((h) => h.toLowerCase()), ...extra.suffix].some((rule) => host === rule.slice(1) || host.endsWith(rule))) return { ok: true };
500
+ return {
501
+ ok: false,
502
+ reason: "host_not_in_allowlist"
503
+ };
504
+ }
505
+ //#endregion
405
506
  //#region src/plugin.ts
406
507
  /**
407
508
  * @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
@@ -418,17 +519,29 @@ function buildA2ATools(chatClient, log) {
418
519
  */
419
520
  let dispatchInbound = null;
420
521
  /**
421
- * Resolve OpenClaw SDK functions from the global install.
422
- * The openclaw package is installed globally (/usr/lib/node_modules/openclaw/)
423
- * but is NOT in the plugin's node_modules. Built-in extensions can import
424
- * directly; external plugins must use createRequire.
522
+ * Resolve OpenClaw SDK functions from the running process.
523
+ *
524
+ * The openclaw package is NOT in the plugin's node_modules (it's a peer dep).
525
+ * Since the plugin runs inside OpenClaw's process, we anchor resolution to
526
+ * OpenClaw's own entry module via require.main, then fall back to deriving
527
+ * the global modules path from process.execPath.
425
528
  */
426
529
  function resolveOpenClawSdk(log) {
427
- for (const globalPath of ["/usr/lib/node_modules/openclaw/package.json", "/usr/local/lib/node_modules/openclaw/package.json"]) try {
428
- const channelInbound = (0, node_module.createRequire)(globalPath)("openclaw/plugin-sdk/channel-inbound");
530
+ const anchors = [require.main?.filename, process.argv[1]].filter(Boolean);
531
+ for (const anchor of anchors) try {
532
+ const channelInbound = (0, node_module.createRequire)(anchor)("openclaw/plugin-sdk/channel-inbound");
533
+ if (channelInbound.dispatchInboundDirectDmWithRuntime) {
534
+ dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
535
+ log.info(`Resolved OpenClaw SDK from ${anchor}`);
536
+ return;
537
+ }
538
+ } catch {}
539
+ try {
540
+ const derivedPath = (0, node_path.join)((0, node_path.resolve)((0, node_path.dirname)(process.execPath), ".."), "lib", "node_modules", "openclaw", "package.json");
541
+ const channelInbound = (0, node_module.createRequire)(derivedPath)("openclaw/plugin-sdk/channel-inbound");
429
542
  if (channelInbound.dispatchInboundDirectDmWithRuntime) {
430
543
  dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
431
- log.info(`Resolved OpenClaw SDK from ${globalPath}`);
544
+ log.info(`Resolved OpenClaw SDK from ${derivedPath}`);
432
545
  return;
433
546
  }
434
547
  } catch {}
@@ -440,6 +553,37 @@ let connectingPromise = null;
440
553
  let metricsClient = null;
441
554
  const MAX_FILE_SIZE = 50 * 1024 * 1024;
442
555
  const DOWNLOAD_TIMEOUT_MS = 3e4;
556
+ const MAX_REDIRECTS = 5;
557
+ /**
558
+ * Fetch `url` with `redirect: "manual"` and revalidate every hop against the
559
+ * attachment allowlist. This defeats presigned-URL substitution attacks
560
+ * where an attacker points us at a trusted host that 302s to metadata
561
+ * or an internal service.
562
+ */
563
+ async function fetchAttachmentWithValidation(url, signal) {
564
+ let currentUrl = url;
565
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
566
+ const verdict = validateAttachmentUrl(currentUrl);
567
+ if (!verdict.ok) throw new Error(`attachment_url_rejected:${verdict.reason ?? "unknown"}`);
568
+ const res = await fetch(currentUrl, {
569
+ redirect: "manual",
570
+ signal
571
+ });
572
+ const status = res.status;
573
+ if (status >= 300 && status < 400) {
574
+ const location = res.headers.get("location");
575
+ if (!location) throw new Error("redirect_without_location");
576
+ try {
577
+ currentUrl = new URL(location, currentUrl).toString();
578
+ } catch {
579
+ throw new Error("invalid_redirect_target");
580
+ }
581
+ continue;
582
+ }
583
+ return res;
584
+ }
585
+ throw new Error("too_many_redirects");
586
+ }
443
587
  async function downloadAttachments(attachments, log) {
444
588
  const attachDir = (0, node_path.join)((0, node_os.homedir)(), ".alfe", "attachments");
445
589
  await (0, node_fs_promises.mkdir)(attachDir, { recursive: true });
@@ -452,7 +596,7 @@ async function downloadAttachments(attachments, log) {
452
596
  controller.abort();
453
597
  }, DOWNLOAD_TIMEOUT_MS);
454
598
  try {
455
- const res = await fetch(att.url, { signal: controller.signal });
599
+ const res = await fetchAttachmentWithValidation(att.url, controller.signal);
456
600
  if (!res.ok) {
457
601
  log.warn(`Failed to download attachment ${att.id}: ${String(res.status)}`);
458
602
  continue;
package/dist/plugin2.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { ChatServiceClient, resolveAlfeChat } from "@alfe.ai/chat";
6
6
  import { resolveConfig } from "@alfe.ai/config";
7
7
  import { AgentApiClient } from "@alfe.ai/agent-api-client";
8
8
  import { existsSync } from "node:fs";
9
+ //#region \0rolldown/runtime.js
10
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
11
+ //#endregion
9
12
  //#region src/alfe-channel.ts
10
13
  const CHANNEL_ID = "alfe";
11
14
  const DEFAULT_ACCOUNT_ID = "default";
@@ -402,6 +405,107 @@ function buildA2ATools(chatClient, log) {
402
405
  ];
403
406
  }
404
407
  //#endregion
408
+ //#region src/attachment-url.ts
409
+ /**
410
+ * Attachment URL validation for the openclaw-chat plugin.
411
+ *
412
+ * This package runs inside user-installed OpenClaw agents and has no
413
+ * dependency on the internal @alfe/api-core SSRF validator, so the
414
+ * plumbing is inlined here. The rules are deliberately conservative:
415
+ *
416
+ * - `https:` only.
417
+ * - Userinfo (`https://u:p@host/...`) is rejected.
418
+ * - IP-literal hosts (v4 or bracketed v6) are rejected outright — only
419
+ * DNS names for known provider CDNs are acceptable.
420
+ * - The host must match a curated allowlist of Alfe-issued / channel-
421
+ * provider CDNs. Anything else is dropped before we fetch it.
422
+ *
423
+ * Operators running an agent in a private network that needs to dereference
424
+ * additional hosts can extend the list via the
425
+ * `ALFE_ATTACHMENT_ALLOWED_HOSTS` env var (comma-separated, entries prefixed
426
+ * with `.` match suffixes).
427
+ */
428
+ const DEFAULT_EXACT_HOSTS = [
429
+ "s3.amazonaws.com",
430
+ "api.twilio.com",
431
+ "graph.microsoft.com",
432
+ "mmg.whatsapp.net"
433
+ ];
434
+ const DEFAULT_SUFFIX_HOSTS = [
435
+ ".s3.amazonaws.com",
436
+ ".amazonaws.com",
437
+ ".twiliocdn.com",
438
+ ".cdn.discordapp.com",
439
+ ".discordapp.net",
440
+ ".telegram.org",
441
+ ".alfe.ai"
442
+ ];
443
+ function parseExtraHosts(raw) {
444
+ const exact = /* @__PURE__ */ new Set();
445
+ const suffix = [];
446
+ if (!raw) return {
447
+ exact,
448
+ suffix
449
+ };
450
+ for (const part of raw.split(",")) {
451
+ const trimmed = part.trim().toLowerCase();
452
+ if (!trimmed) continue;
453
+ if (trimmed.startsWith(".")) suffix.push(trimmed);
454
+ else exact.add(trimmed);
455
+ }
456
+ return {
457
+ exact,
458
+ suffix
459
+ };
460
+ }
461
+ function isIpLiteral(host) {
462
+ if (host.startsWith("[") && host.endsWith("]")) return true;
463
+ return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
464
+ }
465
+ function validateAttachmentUrl(input, opts = {}) {
466
+ if (!input || input.trim() === "") return {
467
+ ok: false,
468
+ reason: "empty"
469
+ };
470
+ let url;
471
+ try {
472
+ url = new URL(input);
473
+ } catch {
474
+ return {
475
+ ok: false,
476
+ reason: "invalid_url"
477
+ };
478
+ }
479
+ if (url.protocol !== "https:") return {
480
+ ok: false,
481
+ reason: "not_https"
482
+ };
483
+ if (url.username !== "" || url.password !== "") return {
484
+ ok: false,
485
+ reason: "userinfo_not_allowed"
486
+ };
487
+ const host = url.hostname.toLowerCase();
488
+ if (!host) return {
489
+ ok: false,
490
+ reason: "empty_host"
491
+ };
492
+ if (isIpLiteral(host)) return {
493
+ ok: false,
494
+ reason: "ip_literal"
495
+ };
496
+ if (host === "localhost" || host === "metadata" || host === "metadata.google.internal") return {
497
+ ok: false,
498
+ reason: "blocked_host"
499
+ };
500
+ const extra = parseExtraHosts(opts.extraHosts ?? process.env.ALFE_ATTACHMENT_ALLOWED_HOSTS);
501
+ if (new Set([...DEFAULT_EXACT_HOSTS.map((h) => h.toLowerCase()), ...extra.exact]).has(host)) return { ok: true };
502
+ if ([...DEFAULT_SUFFIX_HOSTS.map((h) => h.toLowerCase()), ...extra.suffix].some((rule) => host === rule.slice(1) || host.endsWith(rule))) return { ok: true };
503
+ return {
504
+ ok: false,
505
+ reason: "host_not_in_allowlist"
506
+ };
507
+ }
508
+ //#endregion
405
509
  //#region src/plugin.ts
406
510
  /**
407
511
  * @alfe.ai/openclaw-chat — OpenClaw chat channel plugin.
@@ -418,17 +522,29 @@ function buildA2ATools(chatClient, log) {
418
522
  */
419
523
  let dispatchInbound = null;
420
524
  /**
421
- * Resolve OpenClaw SDK functions from the global install.
422
- * The openclaw package is installed globally (/usr/lib/node_modules/openclaw/)
423
- * but is NOT in the plugin's node_modules. Built-in extensions can import
424
- * directly; external plugins must use createRequire.
525
+ * Resolve OpenClaw SDK functions from the running process.
526
+ *
527
+ * The openclaw package is NOT in the plugin's node_modules (it's a peer dep).
528
+ * Since the plugin runs inside OpenClaw's process, we anchor resolution to
529
+ * OpenClaw's own entry module via require.main, then fall back to deriving
530
+ * the global modules path from process.execPath.
425
531
  */
426
532
  function resolveOpenClawSdk(log) {
427
- for (const globalPath of ["/usr/lib/node_modules/openclaw/package.json", "/usr/local/lib/node_modules/openclaw/package.json"]) try {
428
- const channelInbound = createRequire(globalPath)("openclaw/plugin-sdk/channel-inbound");
533
+ const anchors = [__require.main?.filename, process.argv[1]].filter(Boolean);
534
+ for (const anchor of anchors) try {
535
+ const channelInbound = createRequire(anchor)("openclaw/plugin-sdk/channel-inbound");
536
+ if (channelInbound.dispatchInboundDirectDmWithRuntime) {
537
+ dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
538
+ log.info(`Resolved OpenClaw SDK from ${anchor}`);
539
+ return;
540
+ }
541
+ } catch {}
542
+ try {
543
+ const derivedPath = join(resolve(dirname(process.execPath), ".."), "lib", "node_modules", "openclaw", "package.json");
544
+ const channelInbound = createRequire(derivedPath)("openclaw/plugin-sdk/channel-inbound");
429
545
  if (channelInbound.dispatchInboundDirectDmWithRuntime) {
430
546
  dispatchInbound = channelInbound.dispatchInboundDirectDmWithRuntime;
431
- log.info(`Resolved OpenClaw SDK from ${globalPath}`);
547
+ log.info(`Resolved OpenClaw SDK from ${derivedPath}`);
432
548
  return;
433
549
  }
434
550
  } catch {}
@@ -440,6 +556,37 @@ let connectingPromise = null;
440
556
  let metricsClient = null;
441
557
  const MAX_FILE_SIZE = 50 * 1024 * 1024;
442
558
  const DOWNLOAD_TIMEOUT_MS = 3e4;
559
+ const MAX_REDIRECTS = 5;
560
+ /**
561
+ * Fetch `url` with `redirect: "manual"` and revalidate every hop against the
562
+ * attachment allowlist. This defeats presigned-URL substitution attacks
563
+ * where an attacker points us at a trusted host that 302s to metadata
564
+ * or an internal service.
565
+ */
566
+ async function fetchAttachmentWithValidation(url, signal) {
567
+ let currentUrl = url;
568
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
569
+ const verdict = validateAttachmentUrl(currentUrl);
570
+ if (!verdict.ok) throw new Error(`attachment_url_rejected:${verdict.reason ?? "unknown"}`);
571
+ const res = await fetch(currentUrl, {
572
+ redirect: "manual",
573
+ signal
574
+ });
575
+ const status = res.status;
576
+ if (status >= 300 && status < 400) {
577
+ const location = res.headers.get("location");
578
+ if (!location) throw new Error("redirect_without_location");
579
+ try {
580
+ currentUrl = new URL(location, currentUrl).toString();
581
+ } catch {
582
+ throw new Error("invalid_redirect_target");
583
+ }
584
+ continue;
585
+ }
586
+ return res;
587
+ }
588
+ throw new Error("too_many_redirects");
589
+ }
443
590
  async function downloadAttachments(attachments, log) {
444
591
  const attachDir = join(homedir(), ".alfe", "attachments");
445
592
  await mkdir(attachDir, { recursive: true });
@@ -452,7 +599,7 @@ async function downloadAttachments(attachments, log) {
452
599
  controller.abort();
453
600
  }, DOWNLOAD_TIMEOUT_MS);
454
601
  try {
455
- const res = await fetch(att.url, { signal: controller.signal });
602
+ const res = await fetchAttachmentWithValidation(att.url, controller.signal);
456
603
  if (!res.ok) {
457
604
  log.warn(`Failed to download attachment ${att.id}: ${String(res.status)}`);
458
605
  continue;
@@ -1,5 +1,9 @@
1
1
  {
2
2
  "id": "@alfe.ai/openclaw-chat",
3
+ "name": "Chat Plugin",
4
+ "version": "0.0.21",
5
+ "description": "Chat conversation channel — web widget and mobile app share unified chat sessions",
6
+ "entry": "./dist/plugin.js",
3
7
  "configSchema": {
4
8
  "type": "object",
5
9
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/openclaw-chat",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "description": "OpenClaw chat plugin for Alfe — web widget and mobile app channels",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin.js",