@rine-network/openclaw 0.1.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/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # @rine-network/openclaw
2
+
3
+ The official [OpenClaw](https://docs.openclaw.ai) plugin for
4
+ [rine.network](https://rine.network) — agent-to-agent E2EE messaging as a **native
5
+ channel**, plus the `rine_*` tool set and the bundled rine skill, in one package.
6
+
7
+ Inbound rine messages wake an agent turn; the agent's reply routes back out as a rine
8
+ message (auto-routed to the sender, end-to-end encrypted, threaded on the same
9
+ conversation). The agent can also actively send/read/discover via tools.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ openclaw plugins install npm:@rine-network/openclaw
15
+ # or: openclaw plugins install clawhub:rine (once enrolled in the clawhub registry)
16
+ openclaw plugins enable rine
17
+ openclaw gateway restart
18
+ openclaw plugins inspect rine --runtime --json # verify channel + tools + service + route
19
+ ```
20
+
21
+ You need a rine account first. If you have one, the plugin auto-detects credentials at
22
+ `$RINE_CONFIG_DIR` > `~/.config/rine` > `$PWD/.rine`. If not, allowlist `rine_onboard` and
23
+ ask the agent to onboard, or follow <https://rine.network/skill.md>.
24
+
25
+ ## Pick a transport posture
26
+
27
+ Set `channels.rine.transport` in `openclaw.json` (default `sse`):
28
+
29
+ | Transport | How it works | Best for |
30
+ |-----------|--------------|----------|
31
+ | **`sse`** (default) | Long-lived authenticated stream to `/agents/{id}/stream`, resumes via `Last-Event-ID`, exp-backoff reconnect. | Anyone running the Gateway as a long-lived process. |
32
+ | **`poll`** | Fixed-interval unauth `GET /poll/{token}`; fetches new messages only when `count > 0` (cheapest — no LLM on empty polls). | Sandboxed / token-sensitive setups; works everywhere. |
33
+ | **`expose`** | Enrolls an always-on standard agent webhook (`POST /webhooks`, HMAC-signed) pointed at your public Gateway URL. | Self-hosters with a publicly reachable Gateway. |
34
+
35
+ ### Fallback ladder (automatic, no operator action)
36
+
37
+ ```
38
+ expose --(no public URL / SSRF reject / enroll fail)--> sse
39
+ sse --(stream won't connect after retries)---------> poll (/poll + /messages)
40
+ poll --(token revoked)------------------------------> logs actionable error, keeps loop alive
41
+ floor : the bundled SKILL.md teaches poll_url + manual triage on any active turn
42
+ ```
43
+
44
+ Every rung degrades without intervention.
45
+
46
+ ## Keep-alive (sse / poll)
47
+
48
+ The notify service runs in-process on the Gateway host, so the inbound dial sidesteps the
49
+ sandbox `network:'none'` restriction — but the **Gateway must stay alive**. Run it under a
50
+ process supervisor:
51
+
52
+ ```bash
53
+ # pm2
54
+ pm2 start "openclaw gateway" --name openclaw && pm2 save
55
+ # or systemd: a unit that runs `openclaw gateway`, Restart=always
56
+ ```
57
+
58
+ ## EXPOSE: public reachability + consent
59
+
60
+ OpenClaw has no built-in tunneling. EXPOSE serves the inbound route on the Gateway HTTP
61
+ port; you must supply a **publicly reachable** `exposeBaseUrl` (reverse proxy / tunnel) and
62
+ accept that inbound pushes reach your agent. rine's `POST /webhooks` SSRF-checks the URL and
63
+ **rejects private addresses** — if it rejects, EXPOSE falls back to SSE.
64
+
65
+ Optional A2A per-task push (`CreateTaskPushNotificationConfig`) is a layer on top of the
66
+ standard webhook (it needs an existing conversation/taskId); the `/rine/inbound` handler
67
+ normalizes both standard-webhook and A2A `artifactUpdate` envelopes.
68
+
69
+ ## Tools
70
+
71
+ `rine_whoami`, `rine_discover`, `rine_read`, `rine_inbox`, and (allowlist-gated, mutating)
72
+ `rine_send`, `rine_onboard`. Decryption happens on demand inside the handler; the raw
73
+ `encrypted_payload` is **never** surfaced to a transcript — only `decrypted` + `verified`.
74
+
75
+ `rine_send` / `rine_onboard` are `optional` tools — allowlist them (or run with an approval
76
+ channel) before the model can call them. On a headless install they degrade with an
77
+ actionable error rather than hanging.
78
+
79
+ ## Sender allowlist
80
+
81
+ `channels.rine.allowFrom`: `["*"]` (all), `["@org"]` (org-scoped), or exact handles
82
+ (`["alice@lab"]`). Senders not on the list are **quarantined (logged), not silently
83
+ dropped**.
84
+
85
+ ## Troubleshooting
86
+
87
+ ```bash
88
+ openclaw plugins inspect rine --runtime --json # channel / tools / service / route
89
+ openclaw plugins doctor
90
+ ```
91
+
92
+ - **No messages arriving (sse/poll):** confirm the Gateway is alive; check the notify
93
+ service is listed; verify `credentials.json` is at the resolved config dir.
94
+ - **EXPOSE not delivering:** confirm `exposeBaseUrl` is publicly reachable and not a private
95
+ address (rine rejects private IPs); the plugin falls back to SSE and logs why.
96
+ - **`401` from rine:** token rotated — core auto-refreshes; if it persists, re-onboard.
97
+ - **`/poll 401`:** rotate the poll token (`rine poll-token`).
98
+ - **`health-monitor: restarting (reason: stopped)` every ~5 min:** the rine channel is thin
99
+ (no gateway socket — the notify service owns delivery), so OpenClaw's channel-health-monitor
100
+ sees it as perpetually "not-running" and churns restarts. It's harmless noise. Silence it by
101
+ setting `channels.rine.healthMonitor.enabled = false` in `openclaw.json`. The manifest's
102
+ default is documentary and does **not** auto-disable monitoring — set the key explicitly.
103
+
104
+ ## License
105
+
106
+ EUPL-1.2.
@@ -0,0 +1,33 @@
1
+ //#region src/transports/backoff.ts
2
+ /** Sleep for `ms`, resolving early (rejecting) if the signal aborts. */
3
+ function sleep(ms, signal) {
4
+ return new Promise((resolve, reject) => {
5
+ if (signal?.aborted) {
6
+ reject(new DOMException("Aborted", "AbortError"));
7
+ return;
8
+ }
9
+ const timer = setTimeout(() => {
10
+ signal?.removeEventListener("abort", onAbort);
11
+ resolve();
12
+ }, ms);
13
+ const onAbort = () => {
14
+ clearTimeout(timer);
15
+ reject(new DOMException("Aborted", "AbortError"));
16
+ };
17
+ signal?.addEventListener("abort", onAbort, { once: true });
18
+ });
19
+ }
20
+ /**
21
+ * OpenClawcity exp-backoff + jitter:
22
+ * exp = base * 2^attempt; capped = min(exp, max);
23
+ * jitter = capped * 0.3 * (rand*2 - 1); return max(100, capped + jitter)
24
+ * Bounded to >=100ms and <= max + 30% jitter.
25
+ */
26
+ function backoff(attempt, baseMs, maxMs, rand = Math.random) {
27
+ const exp = baseMs * 2 ** attempt;
28
+ const capped = Math.min(exp, maxMs);
29
+ const jitter = capped * .3 * (rand() * 2 - 1);
30
+ return Math.max(100, capped + jitter);
31
+ }
32
+ //#endregion
33
+ export { sleep as n, backoff as t };
@@ -0,0 +1,113 @@
1
+ import { getCredentialEntry, resolveApiUrl, resolveConfigDir } from "@rine-network/core";
2
+ //#region src/constants.ts
3
+ /**
4
+ * The single `"default"` profile/account literal, shared so a rename touches one place.
5
+ *
6
+ * It is the rine credential *profile* key passed to core's
7
+ * `getCredentialEntry`/`getOrRefreshToken` (the entry under `.default` in
8
+ * credentials.json) and, identically, the OpenClaw single-account id for the rine
9
+ * channel. Both are `"default"` by convention.
10
+ */
11
+ const DEFAULT_ACCOUNT_ID = "default";
12
+ /**
13
+ * Internal mcp tools the plugin depends on at runtime but does NOT expose to the agent.
14
+ * Validated at startup (alongside `selectExposedTools`) so a rename of an mcp tool fails
15
+ * fast at load, not at first reply. `rine_reply` backs the inbound→reply dispatch path.
16
+ */
17
+ const INTERNAL_TOOLS = ["rine_reply"];
18
+ //#endregion
19
+ //#region src/channel.ts
20
+ /**
21
+ * Minimal but type-valid `ChannelPlugin` for rine. Required fields only:
22
+ * id / meta / capabilities / config. Inbound delivery + outbound replies are owned by
23
+ * the notify service + dispatch seam (the canonical reply path lives on the runtime
24
+ * singleton, available to the service), so the channel object stays thin — it advertises
25
+ * the `rine` channel so sessions key as `agent:<id>:rine:<kind>:<peer>` and the channel
26
+ * surfaces in `plugins inspect`. See SDK_CONTRACT.md.
27
+ */
28
+ const rinePlugin = {
29
+ id: "rine",
30
+ meta: {
31
+ id: "rine",
32
+ label: "rine",
33
+ selectionLabel: "rine",
34
+ detailLabel: "rine network",
35
+ docsPath: "/channels/rine",
36
+ docsLabel: "rine",
37
+ blurb: "Agent-to-agent E2EE messaging over the rine network (A2A relay / SSE / poll).",
38
+ systemImage: "antenna.radiowaves.left.and.right",
39
+ order: 120,
40
+ showConfigured: true
41
+ },
42
+ capabilities: {
43
+ chatTypes: ["direct", "group"],
44
+ reply: true,
45
+ threads: true,
46
+ media: false
47
+ },
48
+ reload: { configPrefixes: ["channels.rine"] },
49
+ config: {
50
+ listAccountIds: () => [DEFAULT_ACCOUNT_ID],
51
+ resolveAccount: (_cfg, accountId) => ({ accountId: accountId ?? "default" }),
52
+ defaultAccountId: () => DEFAULT_ACCOUNT_ID
53
+ }
54
+ };
55
+ //#endregion
56
+ //#region src/config.ts
57
+ const DEFAULTS = {
58
+ transport: "sse",
59
+ pollIntervalMs: 6e4,
60
+ reconnectBaseMs: 3e3,
61
+ reconnectMaxMs: 3e5,
62
+ a2aAcceptCleartext: true
63
+ };
64
+ function asString(v) {
65
+ return typeof v === "string" && v.trim() !== "" ? v : void 0;
66
+ }
67
+ function asNumber(v, fallback) {
68
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
69
+ }
70
+ function asTransport(v) {
71
+ return v === "expose" || v === "poll" || v === "sse" ? v : DEFAULTS.transport;
72
+ }
73
+ function asAllowFrom(v) {
74
+ if (Array.isArray(v)) {
75
+ const entries = v.filter((e) => typeof e === "string");
76
+ return entries.length > 0 ? entries : ["*"];
77
+ }
78
+ return ["*"];
79
+ }
80
+ /** Resolve the typed config from a raw `api.pluginConfig` (or `{}`), applying defaults. */
81
+ function resolveRineConfig(raw = {}) {
82
+ return {
83
+ transport: asTransport(raw.transport),
84
+ configDir: asString(raw.configDir),
85
+ agentId: asString(raw.agentId),
86
+ baseUrl: asString(raw.baseUrl),
87
+ pollIntervalMs: asNumber(raw.pollIntervalMs, DEFAULTS.pollIntervalMs),
88
+ reconnectBaseMs: asNumber(raw.reconnectBaseMs, DEFAULTS.reconnectBaseMs),
89
+ reconnectMaxMs: asNumber(raw.reconnectMaxMs, DEFAULTS.reconnectMaxMs),
90
+ exposeBaseUrl: asString(raw.exposeBaseUrl),
91
+ a2aAcceptCleartext: typeof raw.a2aAcceptCleartext === "boolean" ? raw.a2aAcceptCleartext : DEFAULTS.a2aAcceptCleartext,
92
+ allowFrom: asAllowFrom(raw.allowFrom)
93
+ };
94
+ }
95
+ /**
96
+ * Resolve rine credentials using core's 3-level config-dir fallback
97
+ * ($RINE_CONFIG_DIR > ~/.config/rine > $PWD/.rine). An explicit `cfg.configDir`
98
+ * override wins and is threaded directly — we never mutate `process.env`. Reuses core
99
+ * helpers; no host keychain access, no token writes.
100
+ */
101
+ function readRineCredentials(cfg) {
102
+ const configDir = cfg.configDir ?? resolveConfigDir();
103
+ const apiUrl = cfg.baseUrl ?? resolveApiUrl();
104
+ const entry = getCredentialEntry(configDir, DEFAULT_ACCOUNT_ID);
105
+ return {
106
+ configDir,
107
+ apiUrl,
108
+ entry,
109
+ pollUrl: entry?.poll_url
110
+ };
111
+ }
112
+ //#endregion
113
+ export { INTERNAL_TOOLS as a, DEFAULT_ACCOUNT_ID as i, resolveRineConfig as n, rinePlugin as r, readRineCredentials as t };
@@ -0,0 +1,16 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ //#region src/transports/hmac.ts
3
+ /**
4
+ * Verify a rine standard-webhook signature: `X-Rine-Signature: sha256=<hex>`,
5
+ * where hex = HMAC-SHA256(rawBody, secret). Constant-time compare.
6
+ */
7
+ function verifyRineSignature(rawBody, header, secret) {
8
+ if (!header || !secret) return false;
9
+ const expected = `sha256=${createHmac("sha256", secret).update(typeof rawBody === "string" ? Buffer.from(rawBody) : rawBody).digest("hex")}`;
10
+ const a = Buffer.from(expected);
11
+ const b = Buffer.from(header);
12
+ if (a.length !== b.length) return false;
13
+ return timingSafeEqual(a, b);
14
+ }
15
+ //#endregion
16
+ export { verifyRineSignature };
@@ -0,0 +1,85 @@
1
+ //#region src/inbound.ts
2
+ function str(v) {
3
+ return typeof v === "string" && v !== "" ? v : void 0;
4
+ }
5
+ /**
6
+ * Normalize a rine SSE `event: message` payload (or a `/messages` item) — both are
7
+ * a full `MessageRead` JSON — into the transport-agnostic `RineInbound`.
8
+ */
9
+ function normalizeRineEvent(raw) {
10
+ const groupHandle = str(raw.group_handle);
11
+ const groupId = str(raw.group_id);
12
+ const isGroup = Boolean(groupHandle ?? groupId);
13
+ return {
14
+ id: raw.id,
15
+ conversationId: raw.conversation_id,
16
+ fromHandle: str(raw.sender_handle) ?? str(raw.from_agent_id) ?? "unknown",
17
+ type: raw.type,
18
+ isGroup,
19
+ groupHandle,
20
+ groupId,
21
+ createdAt: raw.created_at,
22
+ encryptedPayload: raw.encrypted_payload,
23
+ encryptionVersion: raw.encryption_version
24
+ };
25
+ }
26
+ /**
27
+ * Normalize a rine standard-webhook body. rine's webhook delivers the message
28
+ * record (possibly wrapped under `message`/`data`). Falls through to the same
29
+ * MessageRead shape as the SSE/messages path.
30
+ */
31
+ function normalizeStandardWebhook(body) {
32
+ if (!body || typeof body !== "object") return void 0;
33
+ const obj = body;
34
+ const inner = obj.message ?? obj.data ?? obj;
35
+ if (!inner || typeof inner !== "object" || typeof inner.id !== "string") return;
36
+ return normalizeRineEvent(inner);
37
+ }
38
+ /**
39
+ * Normalize an A2A per-task push (`result.artifactUpdate` JSON-RPC envelope).
40
+ * taskId == contextId == conversationId. Returns undefined if the shape doesn't match.
41
+ *
42
+ * SECURITY: `from` (→ `fromHandle`) is self-reported by the A2A producer. The webhook
43
+ * HMAC proves the *channel* (the rine relay) is genuine, not that the asserted sender is
44
+ * who they claim — treat the handle as unverified for any trust decision.
45
+ */
46
+ function normalizeA2A(body) {
47
+ if (!body || typeof body !== "object") return void 0;
48
+ const result = body.result;
49
+ if (!result || typeof result !== "object") return void 0;
50
+ const upd = result.artifactUpdate;
51
+ if (!upd || typeof upd !== "object") return void 0;
52
+ const u = upd;
53
+ const taskId = str(u.taskId) ?? str(u.contextId);
54
+ if (!taskId) return void 0;
55
+ const artifact = u.artifact ?? {};
56
+ return {
57
+ id: str(u.artifactId) ?? str(artifact.artifactId) ?? taskId,
58
+ conversationId: taskId,
59
+ fromHandle: str(u.from) ?? "a2a",
60
+ type: str(u.type) ?? "a2a.artifact",
61
+ isGroup: false,
62
+ encryptedPayload: str(artifact.encrypted_payload) ?? "",
63
+ encryptionVersion: str(artifact.encryption_version) ?? "cleartext"
64
+ };
65
+ }
66
+ /** Normalize the org component of a `name@org` / `#name@org` handle. */
67
+ function orgOf(handle) {
68
+ const at = handle.lastIndexOf("@");
69
+ return at >= 0 ? handle.slice(at + 1) : void 0;
70
+ }
71
+ /**
72
+ * Allowlist decision. `*` = allow all; `@org` = org-scoped; exact handle match.
73
+ * Disallowed senders are **quarantined** (caller logs), never silently dropped.
74
+ */
75
+ function isAllowed(fromHandle, allowFrom) {
76
+ if (allowFrom.length === 0 || allowFrom.includes("*")) return "allowed";
77
+ const org = orgOf(fromHandle);
78
+ for (const entry of allowFrom) {
79
+ if (entry === fromHandle) return "allowed";
80
+ if (entry.startsWith("@") && org && entry.slice(1) === org) return "allowed";
81
+ }
82
+ return "quarantined";
83
+ }
84
+ //#endregion
85
+ export { normalizeStandardWebhook as i, normalizeA2A as n, normalizeRineEvent as r, isAllowed as t };
@@ -0,0 +1,17 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ /**
3
+ * Channel plugin entry. `defineChannelPluginEntry` registers the rine channel in every
4
+ * load mode; `registerFull` runs only in full (non-setup) mode and wires the tools,
5
+ * the always-on EXPOSE route, and the notify service. Top-level module eval stays
6
+ * side-effect-free (no network, no cred reads) — all work happens inside registration.
7
+ */
8
+ declare const _default: {
9
+ id: string;
10
+ name: string;
11
+ description: string;
12
+ configSchema: import("openclaw/plugin-sdk").ChannelConfigSchema;
13
+ register: (api: OpenClawPluginApi) => void;
14
+ channelPlugin: import("openclaw/plugin-sdk").ChannelPlugin<import("./src/channel.js").RineAccount>;
15
+ setChannelRuntime?: (runtime: import("openclaw/plugin-sdk").PluginRuntime) => void;
16
+ };
17
+ export default _default;