@rine-network/openclaw 0.1.5 → 0.2.1

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/README.md CHANGED
@@ -12,7 +12,7 @@ conversation). The agent can also actively send/read/discover via tools.
12
12
 
13
13
  ```bash
14
14
  # 1. Install and enable the plugin
15
- openclaw plugins install npm:@rine-network/openclaw
15
+ openclaw plugins install @rine-network/openclaw
16
16
  openclaw plugins enable rine
17
17
  ```
18
18
 
@@ -42,8 +42,8 @@ openclaw gateway restart
42
42
  openclaw plugins inspect rine --runtime --json # verify channel + tools + service + route
43
43
  ```
44
44
 
45
- Published on npm only (`npm:@rine-network/openclaw`); the plain spec
46
- `openclaw plugins install @rine-network/openclaw` works too. The Gateway warns when
45
+ Published on npm only; the explicit spec
46
+ `openclaw plugins install npm:@rine-network/openclaw` works too. The Gateway warns when
47
47
  `plugins.allow` is empty — for a locked-down host, add `rine` to `plugins.allow` in
48
48
  `openclaw.json` so only trusted plugin ids auto-load.
49
49
 
@@ -122,10 +122,26 @@ Optional A2A per-task push (`CreateTaskPushNotificationConfig`) is a layer on to
122
122
  standard webhook (it needs an existing conversation/taskId); the `/rine/inbound` handler
123
123
  normalizes both standard-webhook and A2A `artifactUpdate` envelopes.
124
124
 
125
+ ## Inbound webhooks (the rine Funnel)
126
+
127
+ An OpenClaw agent can receive external webhook sources (GitHub, Stripe, or any
128
+ HMAC-signing sender) as encrypted inbox messages through the
129
+ [rine Funnel](https://rine.network/funnel.md). Run `rine hook create` to allocate a public
130
+ hostname and secret, then a long-lived `rine relay` on the Gateway box to verify each
131
+ request, encrypt it to the agent's key, and self-send it as a `rine.v1.webhook` message
132
+ (the originating hook name is in cleartext metadata at `rine.hook_name`).
133
+
134
+ A Funnel webhook is a **one-way event**: the relay sends it on the agent's behalf, so its
135
+ sender is the agent's own handle. Act on the webhook — don't reply to its sender. A reply
136
+ targets the agent itself, which rine rejects (OpenClaw logs a non-fatal
137
+ `Cannot reply to your own message` and carries on); to follow up, send a new message to a
138
+ real peer. With a non-`*` `channels.rine.allowFrom`, add the agent's own handle (or its
139
+ `@org`) or the self-sent webhook is quarantined.
140
+
125
141
  ## Tools
126
142
 
127
- `rine_whoami`, `rine_discover`, `rine_read`, `rine_inbox`, and (allowlist-gated, mutating)
128
- `rine_send`, `rine_onboard`. Decryption happens on demand inside the handler; the raw
143
+ `rine_whoami`, `rine_discover`, `rine_read`, `rine_inbox`, `rine_thread`, and
144
+ (allowlist-gated, mutating) `rine_send`, `rine_onboard`. Decryption happens on demand inside the handler; the raw
129
145
  `encrypted_payload` is **never** surfaced to a transcript — only `decrypted` + `verified`.
130
146
 
131
147
  `rine_send` / `rine_onboard` are `optional` tools — allowlist them (or run with an approval
@@ -162,6 +178,12 @@ openclaw plugins doctor
162
178
  - **`npm ... ENOENT ... mkdir '…/.npm'` while installing:** read-only-rootfs host — npm can't
163
179
  write its default cache. See _Hardened / read-only-rootfs containers_ under **Install**.
164
180
 
181
+ ## For AI Agents
182
+
183
+ - [Platform docs](https://rine.network/llms.txt)
184
+ - [OpenClaw integration docs](https://rine.network/openclaw.md)
185
+ - [Protocol](https://rine.network/protocol.md)
186
+
165
187
  ## License
166
188
 
167
189
  EUPL-1.2.
@@ -12,6 +12,12 @@ import { tools } from "@rine-network/mcp/tools";
12
12
  */
13
13
  const DEFAULT_ACCOUNT_ID = "default";
14
14
  /**
15
+ * The rine channel / plugin id. It is the key under both `channels.<id>` (activation +
16
+ * effective config) and `plugins.entries.<id>` in openclaw.json, and the `id` this plugin
17
+ * registers under. Shared so the config resolver reads the same block the channel activates.
18
+ */
19
+ const CHANNEL_ID = "rine";
20
+ /**
15
21
  * Internal mcp tools the plugin depends on at runtime but does NOT expose to the agent.
16
22
  * Validated at startup (alongside `selectExposedTools`) so a rename of an mcp tool fails
17
23
  * fast at load, not at first reply. `rine_reply` backs the inbound→reply dispatch path;
@@ -43,7 +49,7 @@ function asAllowFrom(v) {
43
49
  }
44
50
  return ["*"];
45
51
  }
46
- /** Resolve the typed config from a raw `api.pluginConfig` (or `{}`), applying defaults. */
52
+ /** Resolve the typed config from a raw config record (or `{}`), applying defaults. */
47
53
  function resolveRineConfig(raw = {}) {
48
54
  return {
49
55
  transport: asTransport(raw.transport),
@@ -58,6 +64,28 @@ function resolveRineConfig(raw = {}) {
58
64
  allowFrom: asAllowFrom(raw.allowFrom)
59
65
  };
60
66
  }
67
+ function asRecord(v) {
68
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? v : {};
69
+ }
70
+ /**
71
+ * Resolve the typed config from a live OpenClawConfig (`api.config` / an outbound `ctx.cfg`).
72
+ *
73
+ * `channels.rine` is the documented, activation-required home for `transport`/`allowFrom`/etc.,
74
+ * so it is authoritative; `plugins.entries.rine.config` is merged underneath as a fallback for
75
+ * installs that placed config there (both are validated against the same manifest schema). This
76
+ * is the single source of truth for the notify service, the tool client, and the outbound
77
+ * adapter — before, the service/tools read `api.pluginConfig` (`plugins.entries.rine.config`)
78
+ * while outbound read `channels.rine`, so `channels.rine.transport` was silently ignored.
79
+ */
80
+ function resolveRineConfigFromCfg(cfg) {
81
+ const root = asRecord(cfg);
82
+ const channelCfg = asRecord(asRecord(root.channels)[CHANNEL_ID]);
83
+ const entryCfg = asRecord(asRecord(asRecord(root.plugins).entries)[CHANNEL_ID]).config;
84
+ return resolveRineConfig({
85
+ ...asRecord(entryCfg),
86
+ ...channelCfg
87
+ });
88
+ }
61
89
  /**
62
90
  * Resolve rine credentials using core's 3-level config-dir fallback
63
91
  * ($RINE_CONFIG_DIR > ~/.config/rine > $PWD/.rine). An explicit `cfg.configDir`
@@ -136,11 +164,18 @@ function buildRineClient(creds) {
136
164
  async function pollCount(pollUrl, signal) {
137
165
  try {
138
166
  const res = await fetch(pollUrl, { signal: signal ?? AbortSignal.timeout(POLL_REQUEST_TIMEOUT_MS) });
139
- if (!res.ok) return 0;
167
+ if (res.status === 401 || res.status === 403) return {
168
+ kind: "auth-error",
169
+ status: res.status
170
+ };
171
+ if (!res.ok) return { kind: "error" };
140
172
  const body = await res.json();
141
- return typeof body.count === "number" ? body.count : 0;
173
+ return {
174
+ kind: "count",
175
+ count: typeof body.count === "number" ? body.count : 0
176
+ };
142
177
  } catch {
143
- return 0;
178
+ return { kind: "error" };
144
179
  }
145
180
  }
146
181
  async function fetchNewMessages(agentId, limit = 50) {
@@ -165,13 +200,6 @@ function buildRineClient(creds) {
165
200
  markDelivered
166
201
  };
167
202
  }
168
- //#endregion
169
- //#region src/channel.ts
170
- /** Re-derive the typed rine config from the `channels.rine` block of a live OpenClawConfig. */
171
- function rineConfigFromCfg(cfg) {
172
- const channels = cfg.channels;
173
- return resolveRineConfig(channels?.rine ?? {});
174
- }
175
203
  /**
176
204
  * Minimal but type-valid `ChannelPlugin` for rine. Required fields only:
177
205
  * id / meta / capabilities / config. Inbound delivery + outbound replies are owned by
@@ -252,7 +280,7 @@ const rinePlugin = {
252
280
  };
253
281
  },
254
282
  sendText: async (ctx) => {
255
- const config = rineConfigFromCfg(ctx.cfg);
283
+ const config = resolveRineConfigFromCfg(ctx.cfg);
256
284
  const creds = readRineCredentials(config);
257
285
  if (!creds.entry) throw new Error("rine: no credentials at the resolved config dir — run rine_onboard or set RINE_CONFIG_DIR.");
258
286
  const { messageId } = await sendRineText(buildRineClient(creds), ctx.to, ctx.text, config.agentId);
@@ -274,4 +302,4 @@ const rinePlugin = {
274
302
  } }
275
303
  };
276
304
  //#endregion
277
- export { resolveRineConfig as a, readRineCredentials as i, buildRineClient as n, INTERNAL_TOOLS as o, sendRineReply as r, rinePlugin as t };
305
+ export { resolveRineConfig as a, readRineCredentials as i, buildRineClient as n, resolveRineConfigFromCfg as o, sendRineReply as r, INTERNAL_TOOLS as s, rinePlugin as t };
@@ -10,11 +10,14 @@ function normalizeRineEvent(raw) {
10
10
  const groupHandle = str(raw.group_handle);
11
11
  const groupId = str(raw.group_id);
12
12
  const isGroup = Boolean(groupHandle ?? groupId);
13
+ const fromAgentId = str(raw.from_agent_id);
14
+ const toAgentId = str(raw.to_agent_id);
13
15
  return {
14
16
  id: raw.id,
15
17
  conversationId: raw.conversation_id,
16
18
  fromHandle: str(raw.sender_handle) ?? str(raw.from_agent_id) ?? "unknown",
17
19
  type: raw.type,
20
+ selfSend: Boolean(fromAgentId && fromAgentId === toAgentId),
18
21
  isGroup,
19
22
  groupHandle,
20
23
  groupId,
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { a as resolveRineConfig, i as readRineCredentials, n as buildRineClient, o as INTERNAL_TOOLS, r as sendRineReply, t as rinePlugin } from "./channel-DpHhVQ-n.js";
2
- import { i as normalizeStandardWebhook, n as normalizeA2A, t as isAllowed } from "./inbound-G0JD7YmI.js";
1
+ import { i as readRineCredentials, n as buildRineClient, o as resolveRineConfigFromCfg, r as sendRineReply, s as INTERNAL_TOOLS, t as rinePlugin } from "./channel-BPBnthxw.js";
2
+ import { i as normalizeStandardWebhook, n as normalizeA2A, t as isAllowed } from "./inbound-DEe69mNL.js";
3
3
  import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core";
4
4
  import { fetchAgents, resolveAgent } from "@rine-network/core";
5
5
  import { tools } from "@rine-network/mcp/tools";
@@ -85,6 +85,20 @@ var configSchema = {
85
85
  };
86
86
  //#endregion
87
87
  //#region src/dispatch.ts
88
+ /**
89
+ * The Funnel relay self-sends this type (`from == to`); it is a one-way event the agent ACTS
90
+ * on and must never auto-reply to (the reply is a self-send the server rejects). Compared as a
91
+ * literal so the guard never depends on the installed SDK exporting a named constant — mirrors
92
+ * the rine-hermes `_FUNNEL_WEBHOOK_TYPE` precedent.
93
+ */
94
+ const WEBHOOK_TYPE = "rine.v1.webhook";
95
+ /**
96
+ * A one-way inbound: a Funnel webhook, or any self-addressed message (`from == to`). The agent
97
+ * wakes to act via tools, but an auto-reply would target itself and be rejected (REQ-FSH-02).
98
+ */
99
+ function isOneWayEvent(msg) {
100
+ return msg.type === WEBHOOK_TYPE || msg.selfSend === true;
101
+ }
88
102
  function msgOf(err) {
89
103
  return err instanceof Error ? err.message : String(err);
90
104
  }
@@ -120,7 +134,7 @@ function makeOnMessage(deps) {
120
134
  lastClear = Date.now();
121
135
  }
122
136
  }
123
- return async function onMessage(msg) {
137
+ return async function onMessage(msg, opts) {
124
138
  maybeClear();
125
139
  if (seen.has(msg.id)) return true;
126
140
  if (isAllowed(msg.fromHandle, config.allowFrom) === "quarantined") {
@@ -133,7 +147,7 @@ function makeOnMessage(deps) {
133
147
  logger.info(`rine: inbound ${msg.type} ${msg.id} from ${msg.fromHandle} → dispatching`);
134
148
  seen.add(msg.id);
135
149
  try {
136
- await dispatch(msg, signal);
150
+ await dispatch(msg, signal, opts);
137
151
  } catch (err) {
138
152
  seen.delete(msg.id);
139
153
  logger.error(`rine: dispatch failed for ${msg.id}: ${msgOf(err)}`);
@@ -157,7 +171,7 @@ function makeOnMessage(deps) {
157
171
  */
158
172
  function makeRuntimeDispatcher(params) {
159
173
  const { cfg, runtime, client, agentId, logger } = params;
160
- return async function dispatch(msg, signal) {
174
+ return async function dispatch(msg, signal, opts) {
161
175
  signal.throwIfAborted();
162
176
  const peerId = msg.isGroup ? msg.conversationId : msg.fromHandle;
163
177
  const timestamp = msg.createdAt ? Date.parse(msg.createdAt) : void 0;
@@ -185,6 +199,14 @@ function makeRuntimeDispatcher(params) {
185
199
  logger.warn(`rine: dropping non-text reply for ${msg.id} (rine replies are text-only)`);
186
200
  return;
187
201
  }
202
+ if (isOneWayEvent(msg)) {
203
+ logger.debug?.(`rine: no auto-reply for one-way ${msg.type} ${msg.id} (act via tools; reply would target self)`);
204
+ return;
205
+ }
206
+ if (opts?.replayed) {
207
+ logger.debug?.(`rine: no auto-reply for catch-up/replayed ${msg.id} (live-phase frames only)`);
208
+ return;
209
+ }
188
210
  await replyToRine(client, msg, text, logger);
189
211
  },
190
212
  onRecordError: (err) => logger.warn(`rine: session-record error for ${msg.id}: ${msgOf(err)}`),
@@ -333,7 +355,7 @@ async function deleteWebhook(client, webhookId, logger) {
333
355
  }
334
356
  }
335
357
  async function fallbackToSse(tc) {
336
- const { runSseTransport } = await import("./sse-CLGVjmVC.js");
358
+ const { runSseTransport } = await import("./sse-BEULwHS1.js");
337
359
  await runSseTransport(tc);
338
360
  }
339
361
  function waitUntilAbort(signal) {
@@ -352,12 +374,12 @@ const SERVICE_ID = "rine-notify";
352
374
  async function runTransport(tc) {
353
375
  switch (tc.config.transport) {
354
376
  case "poll": {
355
- const { runPollTransport } = await import("./poll-BvmG87ve.js");
377
+ const { runPollTransport } = await import("./poll-bXndKyw3.js");
356
378
  return runPollTransport(tc);
357
379
  }
358
380
  case "expose": return runExposeTransport(tc);
359
381
  default: {
360
- const { runSseTransport } = await import("./sse-CLGVjmVC.js");
382
+ const { runSseTransport } = await import("./sse-BEULwHS1.js");
361
383
  return runSseTransport(tc);
362
384
  }
363
385
  }
@@ -424,7 +446,7 @@ function makeRineService(api) {
424
446
  id: SERVICE_ID,
425
447
  start(ctx) {
426
448
  const logger = ctx.logger ?? api.logger;
427
- const config = resolveRineConfig(api.pluginConfig ?? {});
449
+ const config = resolveRineConfigFromCfg(api.config);
428
450
  const creds = readRineCredentials(config);
429
451
  if (!creds.entry) {
430
452
  logger.warn(`rine: no credentials.json at ${creds.configDir} — notify service idle (run rine_onboard or set RINE_CONFIG_DIR)`);
@@ -459,11 +481,12 @@ const EXPOSED_TOOLS = [
459
481
  "rine_send",
460
482
  "rine_read",
461
483
  "rine_inbox",
484
+ "rine_thread",
462
485
  "rine_onboard"
463
486
  ];
464
487
  /** Mutating tools gated behind the allowlist (manifest `toolMetadata.optional`). */
465
488
  const OPTIONAL_TOOLS = new Set(["rine_send", "rine_onboard"]);
466
- const CIPHERTEXT_KEYS = ["encrypted_payload"];
489
+ const CIPHERTEXT_KEYS = ["encrypted_payload", "self_encrypted_payload"];
467
490
  /**
468
491
  * Strip raw ciphertext from any tool result before it reaches a transcript.
469
492
  * Recurses through objects/arrays so `rine_read`/`rine_inbox` (which spread the
@@ -564,7 +587,7 @@ var rine_openclaw_default = defineChannelPluginEntry({
564
587
  uiHints
565
588
  },
566
589
  registerFull(api) {
567
- registerRineTools(api, buildRineClient(readRineCredentials(resolveRineConfig(api.pluginConfig ?? {}))).toolContext);
590
+ registerRineTools(api, buildRineClient(readRineCredentials(resolveRineConfigFromCfg(api.config))).toolContext);
568
591
  registerExposeRoute(api);
569
592
  api.registerService(makeRineService(api));
570
593
  }
@@ -0,0 +1,61 @@
1
+ import { r as normalizeRineEvent } from "./inbound-DEe69mNL.js";
2
+ import { n as sleep } from "./backoff-BMNABavv.js";
3
+ //#region src/transports/poll.ts
4
+ /**
5
+ * POLL transport — least token-intensive. Fixed-interval `GET /poll/{token}` (unauth);
6
+ * only on `count > 0` does it mint a JWT, fetch `/messages?status=new`, and dispatch.
7
+ *
8
+ * Graceful fallback: the loop never crashes. A revoked/expired token (`401`/`403`) is
9
+ * surfaced as an actionable error exactly once (REQ-FSH-06 — not masked as an empty inbox);
10
+ * a transient network/HTTP error warns once; a healthy count of 0 is silent no-work. Every
11
+ * failure keeps the loop alive at the fixed interval, and a healthy poll re-arms the warnings.
12
+ */
13
+ async function runPollTransport(tc) {
14
+ const { client, config, agentId, logger, onMessage, signal, pollUrl } = tc;
15
+ if (!pollUrl) {
16
+ logger.error("rine: POLL transport requires a poll_url in credentials.json (.default.poll_url) — run `rine poll-token`");
17
+ return;
18
+ }
19
+ let warnedKind;
20
+ while (!signal.aborted) {
21
+ const result = await client.pollCount(pollUrl, signal);
22
+ if (result.kind === "auth-error") {
23
+ if (warnedKind !== "auth") {
24
+ warnedKind = "auth";
25
+ logger.error(`rine: POLL token rejected (HTTP ${result.status}) — inbox is silently dead until you run \`rine poll-token\` and update credentials.json (.default.poll_url)`);
26
+ }
27
+ await wait(config.pollIntervalMs, signal);
28
+ continue;
29
+ }
30
+ if (result.kind === "error") {
31
+ if (warnedKind !== "network") {
32
+ warnedKind = "network";
33
+ logger.warn("rine: POLL count request failed (network/transient) — keeping loop alive");
34
+ }
35
+ await wait(config.pollIntervalMs, signal);
36
+ continue;
37
+ }
38
+ warnedKind = void 0;
39
+ if (result.count <= 0) {
40
+ await wait(config.pollIntervalMs, signal);
41
+ continue;
42
+ }
43
+ try {
44
+ const items = await client.fetchNewMessages(agentId);
45
+ for (const raw of items) {
46
+ if (signal.aborted) break;
47
+ await onMessage(normalizeRineEvent(raw));
48
+ }
49
+ } catch (err) {
50
+ logger.warn(`rine: POLL fetch/dispatch error (keeping loop alive): ${err instanceof Error ? err.message : String(err)}`);
51
+ }
52
+ await wait(config.pollIntervalMs, signal);
53
+ }
54
+ }
55
+ async function wait(ms, signal) {
56
+ try {
57
+ await sleep(ms, signal);
58
+ } catch {}
59
+ }
60
+ //#endregion
61
+ export { runPollTransport };
package/dist/setup.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as resolveRineConfig, i as readRineCredentials, t as rinePlugin } from "./channel-DpHhVQ-n.js";
1
+ import { a as resolveRineConfig, i as readRineCredentials, t as rinePlugin } from "./channel-BPBnthxw.js";
2
2
  import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core";
3
3
  //#region setup.ts
4
4
  /** Auto-detect existing rine creds for the setup flow. Pure read; no writes. */
@@ -1,7 +1,18 @@
1
1
  import type { CredentialEntry } from "@rine-network/core";
2
2
  import type { RineConfig } from "./types.js";
3
- /** Resolve the typed config from a raw `api.pluginConfig` (or `{}`), applying defaults. */
3
+ /** Resolve the typed config from a raw config record (or `{}`), applying defaults. */
4
4
  export declare function resolveRineConfig(raw?: Record<string, unknown>): RineConfig;
5
+ /**
6
+ * Resolve the typed config from a live OpenClawConfig (`api.config` / an outbound `ctx.cfg`).
7
+ *
8
+ * `channels.rine` is the documented, activation-required home for `transport`/`allowFrom`/etc.,
9
+ * so it is authoritative; `plugins.entries.rine.config` is merged underneath as a fallback for
10
+ * installs that placed config there (both are validated against the same manifest schema). This
11
+ * is the single source of truth for the notify service, the tool client, and the outbound
12
+ * adapter — before, the service/tools read `api.pluginConfig` (`plugins.entries.rine.config`)
13
+ * while outbound read `channels.rine`, so `channels.rine.transport` was silently ignored.
14
+ */
15
+ export declare function resolveRineConfigFromCfg(cfg: unknown): RineConfig;
5
16
  export interface ResolvedCreds {
6
17
  configDir: string;
7
18
  apiUrl: string;
@@ -7,6 +7,12 @@
7
7
  * channel. Both are `"default"` by convention.
8
8
  */
9
9
  export declare const DEFAULT_ACCOUNT_ID = "default";
10
+ /**
11
+ * The rine channel / plugin id. It is the key under both `channels.<id>` (activation +
12
+ * effective config) and `plugins.entries.<id>` in openclaw.json, and the `id` this plugin
13
+ * registers under. Shared so the config resolver reads the same block the channel activates.
14
+ */
15
+ export declare const CHANNEL_ID = "rine";
10
16
  /**
11
17
  * Internal mcp tools the plugin depends on at runtime but does NOT expose to the agent.
12
18
  * Validated at startup (alongside `selectExposedTools`) so a rename of an mcp tool fails
@@ -7,7 +7,19 @@ import type { RineConfig, RineInbound } from "./types.js";
7
7
  * (`openclaw/plugin-sdk/channel-inbound`), the canonical inbound→agent-turn helper that
8
8
  * routes, finalizes the PascalCase ctx, records the session, and dispatches.
9
9
  */
10
- export type DispatchFn = (msg: RineInbound, signal: AbortSignal) => Promise<void>;
10
+ export type DispatchFn = (msg: RineInbound, signal: AbortSignal, opts?: DispatchOpts) => Promise<void>;
11
+ /**
12
+ * Per-message dispatch modifiers set by the transport (not part of the message itself).
13
+ */
14
+ export interface DispatchOpts {
15
+ /**
16
+ * This frame was replayed during the SSE catch-up phase (before the stream reached the
17
+ * live/heartbeat state) — wake the agent to ACT, but never auto-reply. A restart against
18
+ * an undelivered backlog would otherwise fan out one canned reply per replayed message
19
+ * (REQ-FSH-01). Live-phase frames (opts absent / `replayed:false`) still auto-reply.
20
+ */
21
+ replayed?: boolean;
22
+ }
11
23
  export interface OnMessageDeps {
12
24
  client: RineClient;
13
25
  config: RineConfig;
@@ -23,7 +35,7 @@ export interface OnMessageDeps {
23
35
  * or quarantined) so the SSE cursor may advance past it; `false` only when dispatch FAILED —
24
36
  * a transient error that must be retried, so the cursor must stay behind it.
25
37
  */
26
- export type OnMessage = (msg: RineInbound) => Promise<boolean>;
38
+ export type OnMessage = (msg: RineInbound, opts?: DispatchOpts) => Promise<boolean>;
27
39
  /**
28
40
  * The inline pointer body — ciphertext stays out of the transcript; agent calls `rine_read`.
29
41
  *
@@ -2,6 +2,21 @@ import { HttpClient } from "@rine-network/core";
2
2
  import type { MessageRead } from "@rine-network/core";
3
3
  import type { ToolContext } from "@rine-network/mcp/tools";
4
4
  import type { ResolvedCreds } from "./config.js";
5
+ /**
6
+ * Outcome of a `/poll` count probe. A revoked/expired poll token (`401`/`403`) is
7
+ * distinguished from a healthy count and from a transient network/HTTP error so the poll
8
+ * loop can surface an actionable auth warning instead of masking it as an empty inbox
9
+ * (REQ-FSH-06).
10
+ */
11
+ export type PollResult = {
12
+ kind: "count";
13
+ count: number;
14
+ } | {
15
+ kind: "auth-error";
16
+ status: number;
17
+ } | {
18
+ kind: "error";
19
+ };
5
20
  export interface RineClient {
6
21
  /** Shared `ToolContext` for the lifted mcp tool handlers. */
7
22
  toolContext: ToolContext;
@@ -10,8 +25,12 @@ export interface RineClient {
10
25
  apiUrl: string;
11
26
  /** Mint/refresh the OAuth JWT via core's token cache (auto-refresh on 401). */
12
27
  getJwt: (force?: boolean) => Promise<string>;
13
- /** Unauthenticated poll: `GET {pollUrl}` → count. Returns 0 on any failure. */
14
- pollCount: (pollUrl: string, signal?: AbortSignal) => Promise<number>;
28
+ /**
29
+ * Unauthenticated poll: `GET {pollUrl}` a {@link PollResult}. Reports `auth-error` on a
30
+ * `401`/`403` (revoked token) and `error` on any transient network/HTTP failure, so the
31
+ * caller never confuses a dead token for an empty inbox (REQ-FSH-06).
32
+ */
33
+ pollCount: (pollUrl: string, signal?: AbortSignal) => Promise<PollResult>;
15
34
  /** Fetch new messages for an agent (auth). */
16
35
  fetchNewMessages: (agentId: string, limit?: number) => Promise<MessageRead[]>;
17
36
  /** Mark messages delivered (auth) — at-least-once after successful dispatch. */
@@ -2,7 +2,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
2
  import type { AnyAgentTool } from "openclaw/plugin-sdk";
3
3
  import type { ToolContext, ToolDef } from "@rine-network/mcp/tools";
4
4
  /** Tools exposed by the plugin (filtered from the mcp tool array). */
5
- export declare const EXPOSED_TOOLS: readonly ["rine_whoami", "rine_discover", "rine_send", "rine_read", "rine_inbox", "rine_onboard"];
5
+ export declare const EXPOSED_TOOLS: readonly ["rine_whoami", "rine_discover", "rine_send", "rine_read", "rine_inbox", "rine_thread", "rine_onboard"];
6
6
  /** Mutating tools gated behind the allowlist (manifest `toolMetadata.optional`). */
7
7
  export declare const OPTIONAL_TOOLS: Set<string>;
8
8
  /**
@@ -3,9 +3,9 @@ import type { TransportContext } from "./context.js";
3
3
  * POLL transport — least token-intensive. Fixed-interval `GET /poll/{token}` (unauth);
4
4
  * only on `count > 0` does it mint a JWT, fetch `/messages?status=new`, and dispatch.
5
5
  *
6
- * Graceful fallback: on `/poll` failure (e.g. revoked token) it logs an actionable
7
- * message and keeps the loop alive at the fixed interval never crashes the service.
8
- * (`pollCount` returns 0 on any error; a transient zero is indistinguishable from an
9
- * empty inbox, which is the desired no-work behavior.)
6
+ * Graceful fallback: the loop never crashes. A revoked/expired token (`401`/`403`) is
7
+ * surfaced as an actionable error exactly once (REQ-FSH-06 not masked as an empty inbox);
8
+ * a transient network/HTTP error warns once; a healthy count of 0 is silent no-work. Every
9
+ * failure keeps the loop alive at the fixed interval, and a healthy poll re-arms the warnings.
10
10
  */
11
11
  export declare function runPollTransport(tc: TransportContext): Promise<void>;
@@ -15,6 +15,13 @@ export interface RineInbound {
15
15
  fromHandle: string;
16
16
  /** rine message type, e.g. `rine.v1.dm`. */
17
17
  type: string;
18
+ /**
19
+ * `from_agent_id == to_agent_id` — a self-addressed message. A Funnel `rine.v1.webhook`
20
+ * is the canonical case (the relay sends on the agent's behalf), and the server rejects a
21
+ * reply to it ("Cannot reply to your own message"). Drives the one-way no-auto-reply guard
22
+ * (REQ-FSH-02). Absent when the transport can't determine both agent ids (e.g. A2A push).
23
+ */
24
+ selfSend?: boolean;
18
25
  /** Whether this is a group/broadcast message. */
19
26
  isGroup: boolean;
20
27
  /** Group handle `#name@org` when `isGroup`. */
@@ -1,4 +1,4 @@
1
- import { r as normalizeRineEvent } from "./inbound-G0JD7YmI.js";
1
+ import { r as normalizeRineEvent } from "./inbound-DEe69mNL.js";
2
2
  import { n as sleep, t as backoff } from "./backoff-BMNABavv.js";
3
3
  import { n as saveCursor, t as loadCursor } from "./cursor-DPl6-dKW.js";
4
4
  //#region src/transports/sse.ts
@@ -49,7 +49,7 @@ async function runSseTransport(tc) {
49
49
  onReset: () => {
50
50
  attempt = 0;
51
51
  },
52
- onRaw: async (raw) => onMessage(normalizeRineEvent(raw))
52
+ onRaw: async (raw, opts) => onMessage(normalizeRineEvent(raw), opts)
53
53
  });
54
54
  attempt = 0;
55
55
  } catch (err) {
@@ -57,7 +57,7 @@ async function runSseTransport(tc) {
57
57
  logger.warn(`rine: SSE stream error (attempt ${attempt}): ${err instanceof Error ? err.message : String(err)}`);
58
58
  if (attempt >= MAX_ATTEMPTS_BEFORE_FALLBACK) {
59
59
  logger.warn("rine: SSE failed repeatedly — falling back to POLL transport");
60
- const { runPollTransport } = await import("./poll-BvmG87ve.js");
60
+ const { runPollTransport } = await import("./poll-bXndKyw3.js");
61
61
  await runPollTransport(tc);
62
62
  return;
63
63
  }
@@ -89,6 +89,7 @@ async function streamOnce(params) {
89
89
  const decoder = new TextDecoder();
90
90
  let buffer = "";
91
91
  let gapSeen = false;
92
+ let live = lastEventId !== void 0;
92
93
  while (!signal.aborted) {
93
94
  const { done, value } = await readWithIdleWatchdog(reader);
94
95
  if (done) return;
@@ -98,10 +99,15 @@ async function streamOnce(params) {
98
99
  const block = buffer.slice(0, sep);
99
100
  buffer = buffer.slice(sep + 2);
100
101
  const evt = parseSseBlock(block);
101
- if (!evt || evt.event !== "message" || !evt.data) continue;
102
+ if (!evt) continue;
103
+ if (evt.event === "heartbeat" || evt.event === "status") {
104
+ live = true;
105
+ continue;
106
+ }
107
+ if (evt.event !== "message" || !evt.data) continue;
102
108
  let delivered = false;
103
109
  try {
104
- delivered = await onRaw(JSON.parse(evt.data));
110
+ delivered = await onRaw(JSON.parse(evt.data), { replayed: !live });
105
111
  } catch {
106
112
  if (evt.id) gapSeen = true;
107
113
  continue;
@@ -3,7 +3,7 @@
3
3
  "kind": "channel",
4
4
  "name": "rine",
5
5
  "description": "Agent-to-agent E2EE messaging over the rine network (A2A relay / SSE / poll).",
6
- "version": "0.1.5",
6
+ "version": "0.2.1",
7
7
  "channels": ["rine"],
8
8
  "skills": ["skills/rine"],
9
9
  "activation": { "onStartup": true },
@@ -14,6 +14,7 @@
14
14
  "rine_send",
15
15
  "rine_read",
16
16
  "rine_inbox",
17
+ "rine_thread",
17
18
  "rine_onboard"
18
19
  ]
19
20
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rine-network/openclaw",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
4
4
  "description": "Official OpenClaw plugin for rine.network \u2014 agent-to-agent E2EE messaging as a native channel, with A2A-relay / SSE / poll transports, tools, and the bundled rine skill.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -64,8 +64,8 @@
64
64
  "prepublishOnly": "node scripts/check-no-file-deps.mjs"
65
65
  },
66
66
  "dependencies": {
67
- "@rine-network/core": "^0.5.1",
68
- "@rine-network/mcp": "^0.4.2"
67
+ "@rine-network/core": "^0.8.1",
68
+ "@rine-network/mcp": "^0.6.0"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "openclaw": ">=2026.6.1"
@@ -52,6 +52,9 @@ rine read <message-id> [--agent <name>]
52
52
  # Reply to a message (type defaults to original message's type)
53
53
  rine reply <message-id> --payload '{"result": "Thanks!"}'
54
54
 
55
+ # Show a conversation's full decrypted transcript (both sides, role-tagged)
56
+ rine thread <conversation-id> [--agent <name>] [--limit 50]
57
+
55
58
  # Discover agents
56
59
  rine discover agents --query "invoice processing"
57
60
  rine discover categories
@@ -60,10 +63,14 @@ rine discover categories
60
63
  rine agent describe <id> --description "I process invoices"
61
64
  rine agent set-categories <id> --categories "finance,invoicing"
62
65
 
63
- # Webhooks
66
+ # Webhooks (outbound — rine POSTs to your URL)
64
67
  rine webhook create --url https://your-server.com/hook # --agent auto-resolved
65
68
  rine webhook list
66
69
 
70
+ # Inbound webhook funnel (GitHub/Stripe/custom → your encrypted inbox)
71
+ rine hook create --name github # prints Hostname, Payload URL, secret (shown once)
72
+ rine relay --hook github # long-lived daemon on your box: TLS + HMAC verify + encrypt
73
+
67
74
  # Real-time streaming
68
75
  rine stream [--agent <uuid>] [--json]
69
76
  ```
@@ -74,7 +81,7 @@ Common message types: `rine.v1.task_request`, `rine.v1.task_response`, `rine.v1.
74
81
 
75
82
  **MCP tools** (equivalent to CLI commands above):
76
83
  `rine_onboard` (register + create agent), `rine_agent_create`,
77
- `rine_send`, `rine_inbox`, `rine_read`, `rine_reply`,
84
+ `rine_send`, `rine_inbox`, `rine_read`, `rine_reply`, `rine_thread`,
78
85
  `rine_discover`, `rine_inspect`, `rine_discover_groups`,
79
86
  `rine_groups`, `rine_group_create`, `rine_group_join`, `rine_group_members`, `rine_group_invite`,
80
87
  `rine_whoami`, `rine_poll`.
@@ -238,6 +245,20 @@ rine webhook create --url https://your-server.com/hook
238
245
  ```
239
246
  Push delivery, HMAC-signed, 5 automatic retries on failure.
240
247
 
248
+ To receive an external webhook source (GitHub, Stripe, custom) as encrypted inbox
249
+ messages on a NAT'd box — the inbound direction — use the Funnel instead:
250
+ ```bash
251
+ rine hook create --name github # prints Hostname, Payload URL, X-Hub-Signature-256 secret (once)
252
+ rine relay --hook github # terminate TLS + verify HMAC + encrypt, all on your box
253
+ ```
254
+ The relay self-sends each verified webhook as a signed `rine.v1.webhook` message with the
255
+ originating hook in cleartext metadata at `rine.hook_name`. rine never sees the cleartext body,
256
+ the HMAC secret, or the TLS key.
257
+
258
+ A `rine.v1.webhook` is a one-way event: its sender is your own agent's handle (the relay
259
+ sends on the agent's behalf), so act on it — don't reply to the sender. A reply targets the
260
+ agent itself, which rine rejects. To follow up, send a new message to a real peer.
261
+
241
262
  ---
242
263
 
243
264
  ### Triage rules (once you've read the inbox)
@@ -1,42 +0,0 @@
1
- import { r as normalizeRineEvent } from "./inbound-G0JD7YmI.js";
2
- import { n as sleep } from "./backoff-BMNABavv.js";
3
- //#region src/transports/poll.ts
4
- /**
5
- * POLL transport — least token-intensive. Fixed-interval `GET /poll/{token}` (unauth);
6
- * only on `count > 0` does it mint a JWT, fetch `/messages?status=new`, and dispatch.
7
- *
8
- * Graceful fallback: on `/poll` failure (e.g. revoked token) it logs an actionable
9
- * message and keeps the loop alive at the fixed interval — never crashes the service.
10
- * (`pollCount` returns 0 on any error; a transient zero is indistinguishable from an
11
- * empty inbox, which is the desired no-work behavior.)
12
- */
13
- async function runPollTransport(tc) {
14
- const { client, config, agentId, logger, onMessage, signal, pollUrl } = tc;
15
- if (!pollUrl) {
16
- logger.error("rine: POLL transport requires a poll_url in credentials.json (.default.poll_url) — run `rine poll-token`");
17
- return;
18
- }
19
- while (!signal.aborted) {
20
- if (await client.pollCount(pollUrl, signal) <= 0) {
21
- await wait(config.pollIntervalMs, signal);
22
- continue;
23
- }
24
- try {
25
- const items = await client.fetchNewMessages(agentId);
26
- for (const raw of items) {
27
- if (signal.aborted) break;
28
- await onMessage(normalizeRineEvent(raw));
29
- }
30
- } catch (err) {
31
- logger.warn(`rine: POLL fetch/dispatch error (keeping loop alive): ${err instanceof Error ? err.message : String(err)}`);
32
- }
33
- await wait(config.pollIntervalMs, signal);
34
- }
35
- }
36
- async function wait(ms, signal) {
37
- try {
38
- await sleep(ms, signal);
39
- } catch {}
40
- }
41
- //#endregion
42
- export { runPollTransport };