@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/dist/index.js ADDED
@@ -0,0 +1,638 @@
1
+ import { a as INTERNAL_TOOLS, i as DEFAULT_ACCOUNT_ID, n as resolveRineConfig, r as rinePlugin, t as readRineCredentials } from "./config-BsdV6THh.js";
2
+ import { i as normalizeStandardWebhook, n as normalizeA2A, t as isAllowed } from "./inbound-G0JD7YmI.js";
3
+ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core";
4
+ import { HttpClient, fetchAgents, getOrRefreshToken, resolveAgent } from "@rine-network/core";
5
+ import { dispatchInboundDirectDmWithRuntime } from "openclaw/plugin-sdk/channel-inbound";
6
+ import { tools } from "@rine-network/mcp/tools";
7
+ import { jsonResult } from "openclaw/plugin-sdk/channel-actions";
8
+ //#region openclaw.plugin.json
9
+ var description = "Agent-to-agent E2EE messaging over the rine network (A2A relay / SSE / poll).";
10
+ var uiHints = {
11
+ "exposeBaseUrl": {
12
+ "label": "Public base URL",
13
+ "sensitive": false,
14
+ "help": "Required for EXPOSE: publicly reachable Gateway URL for inbound push (reverse proxy / tunnel)."
15
+ },
16
+ "transport": { "help": "expose = A2A/standard-webhook push (NAT-friendly, owner opt-in, needs a public URL); sse = long-lived outbound stream (safe default, needs the Gateway kept alive); poll = interval /poll check (least token-intensive)." }
17
+ };
18
+ var configSchema = {
19
+ "type": "object",
20
+ "additionalProperties": false,
21
+ "properties": {
22
+ "transport": {
23
+ "type": "string",
24
+ "enum": [
25
+ "expose",
26
+ "sse",
27
+ "poll"
28
+ ],
29
+ "default": "sse",
30
+ "description": "Inbound transport posture. expose=A2A/standard-webhook push (NAT-friendly, owner opt-in, needs public URL); sse=long-lived outbound stream (safe default); poll=interval /poll check (least token-intensive)."
31
+ },
32
+ "configDir": {
33
+ "type": "string",
34
+ "description": "Override rine creds dir (default $RINE_CONFIG_DIR > ~/.config/rine > cwd/.rine)."
35
+ },
36
+ "agentId": {
37
+ "type": "string",
38
+ "description": "rine agent id to bind (default: credentialed agent)."
39
+ },
40
+ "baseUrl": {
41
+ "type": "string",
42
+ "description": "rine API base URL (default from credentials.json / RINE_API_URL / https://rine.network)."
43
+ },
44
+ "pollIntervalMs": {
45
+ "type": "number",
46
+ "default": 6e4,
47
+ "description": "POLL only: interval between /poll checks."
48
+ },
49
+ "reconnectBaseMs": {
50
+ "type": "number",
51
+ "default": 3e3,
52
+ "description": "SSE/EXPOSE reconnect base backoff."
53
+ },
54
+ "reconnectMaxMs": {
55
+ "type": "number",
56
+ "default": 3e5,
57
+ "description": "SSE/EXPOSE reconnect ceiling."
58
+ },
59
+ "exposeBaseUrl": {
60
+ "type": "string",
61
+ "description": "EXPOSE only: public base URL for inbound webhook (e.g. https://gw.example.com)."
62
+ },
63
+ "a2aAcceptCleartext": {
64
+ "type": "boolean",
65
+ "default": true,
66
+ "description": "EXPOSE only: allow unencrypted A2A inbound."
67
+ },
68
+ "allowFrom": {
69
+ "type": "array",
70
+ "items": { "type": "string" },
71
+ "default": ["*"],
72
+ "description": "Sender allowlist: '*'=all, '@org'=org-scoped, exact handle. Disallowed senders are quarantined (logged), not silently dropped."
73
+ },
74
+ "healthMonitor": {
75
+ "type": "object",
76
+ "additionalProperties": false,
77
+ "description": "OpenClaw channel-health-monitor opt-out. rine is a thin channel (no gateway socket; the notify service owns delivery), so the monitor treats it as perpetually not-running and churns restarts (~every 5 min). Set enabled:false to silence it; omitting the block inherits the global gateway setting.",
78
+ "properties": { "enabled": {
79
+ "type": "boolean",
80
+ "default": false,
81
+ "description": "Whether OpenClaw's channel-health-monitor may restart this channel. Recommended false for rine — nothing to monitor."
82
+ } }
83
+ }
84
+ }
85
+ };
86
+ //#endregion
87
+ //#region src/rine-client.ts
88
+ /** Hard ceiling on a single unauthenticated /poll request (the poll loop owns the cadence). */
89
+ const POLL_REQUEST_TIMEOUT_MS = 3e4;
90
+ /**
91
+ * Build the rine HTTP client + ToolContext from resolved creds. Mirrors
92
+ * rine-mcp/src/server.ts bootstrap: tokenFn = getCredentialEntry + getOrRefreshToken;
93
+ * new HttpClient({ tokenFn, apiUrl, canRefresh }).
94
+ */
95
+ function buildRineClient(creds) {
96
+ const { configDir, apiUrl, entry } = creds;
97
+ const getJwt = (force) => getOrRefreshToken(configDir, apiUrl, entry, DEFAULT_ACCOUNT_ID, { force });
98
+ const client = new HttpClient({
99
+ tokenFn: getJwt,
100
+ apiUrl,
101
+ canRefresh: Boolean(entry)
102
+ });
103
+ const toolContext = {
104
+ client,
105
+ configDir,
106
+ apiUrl
107
+ };
108
+ async function pollCount(pollUrl, signal) {
109
+ try {
110
+ const res = await fetch(pollUrl, { signal: signal ?? AbortSignal.timeout(POLL_REQUEST_TIMEOUT_MS) });
111
+ if (!res.ok) return 0;
112
+ const body = await res.json();
113
+ return typeof body.count === "number" ? body.count : 0;
114
+ } catch {
115
+ return 0;
116
+ }
117
+ }
118
+ async function fetchNewMessages(agentId, limit = 50) {
119
+ const res = await client.get(`/agents/${agentId}/messages`, {
120
+ status: "new",
121
+ limit
122
+ });
123
+ return Array.isArray(res.items) ? res.items : [];
124
+ }
125
+ async function markDelivered(agentId, ids) {
126
+ if (ids.length === 0) return 0;
127
+ return (await client.markDelivered(agentId, ids)).marked;
128
+ }
129
+ return {
130
+ toolContext,
131
+ client,
132
+ configDir,
133
+ apiUrl,
134
+ getJwt,
135
+ pollCount,
136
+ fetchNewMessages,
137
+ markDelivered
138
+ };
139
+ }
140
+ //#endregion
141
+ //#region src/outbound.ts
142
+ function toolByName(name, all = tools) {
143
+ const def = all.find((t) => t.name === name);
144
+ if (!def) throw new Error(`rine: required mcp tool "${name}" not found — version skew`);
145
+ return def;
146
+ }
147
+ /**
148
+ * Route an agent reply back out as a rine message, reusing the lifted `rine_reply`
149
+ * handler (E2EE encrypt + POST /messages/{id}/reply, auto-routed to the original
150
+ * sender, conversation_id preserved). No crypto/HTTP reimplemented.
151
+ */
152
+ async function sendRineReply(client, inbound, text) {
153
+ return toolByName("rine_reply").handler(client.toolContext, {
154
+ message_id: inbound.id,
155
+ payload: { text }
156
+ });
157
+ }
158
+ //#endregion
159
+ //#region src/dispatch.ts
160
+ function msgOf(err) {
161
+ return err instanceof Error ? err.message : String(err);
162
+ }
163
+ /** The inline pointer body — ciphertext stays out of the transcript; agent calls `rine_read`. */
164
+ function pointerText(msg) {
165
+ return `[rine ${msg.type} from ${msg.fromHandle}] message ${msg.id} (read with rine_read)`;
166
+ }
167
+ /**
168
+ * Build the shared `onMessage` path all transports converge on.
169
+ *
170
+ * Invariants (acceptance §6):
171
+ * - dedupe by rine message id (Set, add-id-BEFORE-await race guard; delete-on-error
172
+ * so a failed dispatch is retried; hourly clear bounds memory).
173
+ * - mark-delivered ONLY after a successful dispatch (at-least-once).
174
+ * - disallowed sender → quarantine (logged exactly once), no dispatch, not marked
175
+ * delivered (recoverable after the operator fixes allowFrom + restarts the gateway).
176
+ */
177
+ function makeOnMessage(deps) {
178
+ const { client, config, agentId, logger, dispatch, signal } = deps;
179
+ const seen = /* @__PURE__ */ new Set();
180
+ const quarantined = /* @__PURE__ */ new Set();
181
+ let lastClear = Date.now();
182
+ function maybeClear() {
183
+ if (Date.now() - lastClear > 36e5) {
184
+ seen.clear();
185
+ lastClear = Date.now();
186
+ }
187
+ }
188
+ return async function onMessage(msg) {
189
+ maybeClear();
190
+ if (seen.has(msg.id)) return true;
191
+ if (isAllowed(msg.fromHandle, config.allowFrom) === "quarantined") {
192
+ if (!quarantined.has(msg.id)) {
193
+ quarantined.add(msg.id);
194
+ logger.warn(`rine: quarantined message ${msg.id} from disallowed sender "${msg.fromHandle}" (allowFrom=${config.allowFrom.join(",")}; allowFrom changes take effect on gateway restart)`);
195
+ }
196
+ return true;
197
+ }
198
+ logger.info(`rine: inbound ${msg.type} ${msg.id} from ${msg.fromHandle} → dispatching`);
199
+ seen.add(msg.id);
200
+ try {
201
+ await dispatch(msg, signal);
202
+ } catch (err) {
203
+ seen.delete(msg.id);
204
+ logger.error(`rine: dispatch failed for ${msg.id}: ${msgOf(err)}`);
205
+ return false;
206
+ }
207
+ try {
208
+ await client.markDelivered(agentId, [msg.id]);
209
+ } catch (err) {
210
+ logger.warn(`rine: mark-delivered failed for ${msg.id} (already dispatched): ${msgOf(err)}`);
211
+ }
212
+ return true;
213
+ };
214
+ }
215
+ /**
216
+ * Production dispatch fn: wakes an agent turn for one inbound rine message via the
217
+ * canonical `dispatchInboundDirectDmWithRuntime` helper. `api.config` (cfg) and
218
+ * `api.runtime` (the `DirectDmRuntime` facade) are threaded in from the service.
219
+ *
220
+ * Keeps the pointer-body design: `pointerText(msg)` carries only a "read with rine_read"
221
+ * pointer, so ciphertext never enters the transcript.
222
+ */
223
+ function makeRuntimeDispatcher(params) {
224
+ const { cfg, runtime, client, agentId, logger } = params;
225
+ return async function dispatch(msg, signal) {
226
+ signal.throwIfAborted();
227
+ const peerId = msg.isGroup ? msg.conversationId : msg.fromHandle;
228
+ const timestamp = msg.createdAt ? Date.parse(msg.createdAt) : void 0;
229
+ let dispatchErr;
230
+ await dispatchInboundDirectDmWithRuntime({
231
+ cfg,
232
+ runtime,
233
+ channel: "rine",
234
+ channelLabel: "Rine",
235
+ accountId: agentId,
236
+ peer: {
237
+ kind: "direct",
238
+ id: peerId
239
+ },
240
+ senderId: msg.fromHandle,
241
+ senderAddress: msg.fromHandle,
242
+ recipientAddress: agentId,
243
+ conversationLabel: msg.isGroup ? `rine group ${msg.conversationId}` : msg.fromHandle,
244
+ rawBody: pointerText(msg),
245
+ messageId: msg.id,
246
+ timestamp: Number.isFinite(timestamp) ? timestamp : void 0,
247
+ deliver: async (payload) => {
248
+ const text = payload.text ?? "";
249
+ if (!text) {
250
+ logger.warn(`rine: dropping non-text reply for ${msg.id} (rine replies are text-only)`);
251
+ return;
252
+ }
253
+ await replyToRine(client, msg, text, logger);
254
+ },
255
+ onRecordError: (err) => logger.warn(`rine: session-record error for ${msg.id}: ${msgOf(err)}`),
256
+ onDispatchError: (err) => {
257
+ dispatchErr = err;
258
+ }
259
+ });
260
+ if (dispatchErr) throw dispatchErr;
261
+ };
262
+ }
263
+ async function replyToRine(client, msg, text, logger) {
264
+ if (!text) return;
265
+ try {
266
+ await sendRineReply(client, msg, text);
267
+ } catch (err) {
268
+ logger.error(`rine: outbound reply failed for ${msg.id}: ${msgOf(err)}`);
269
+ }
270
+ }
271
+ //#endregion
272
+ //#region src/transports/expose.ts
273
+ const RINE_INBOUND_PATH = "/rine/inbound";
274
+ /** Reject bodies larger than this before HMAC work — bounds memory + CPU on hostile input. */
275
+ const MAX_BODY_BYTES = 512 * 1024;
276
+ let exposeState;
277
+ /** Read the request body with a hard size cap; undefined signals "too large". */
278
+ async function readBody(req) {
279
+ const chunks = [];
280
+ let total = 0;
281
+ for await (const chunk of req) {
282
+ const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
283
+ total += buf.length;
284
+ if (total > MAX_BODY_BYTES) return void 0;
285
+ chunks.push(buf);
286
+ }
287
+ return Buffer.concat(chunks);
288
+ }
289
+ /** The /rine/inbound handler: HMAC-verify, normalize (standard or A2A), dispatch. */
290
+ async function handleInbound(req, res) {
291
+ if (req.method !== "POST") return false;
292
+ const state = exposeState;
293
+ if (!state) {
294
+ res.statusCode = 503;
295
+ res.end("rine expose inactive");
296
+ return true;
297
+ }
298
+ const raw = await readBody(req);
299
+ if (!raw) {
300
+ res.statusCode = 413;
301
+ res.end("payload too large");
302
+ return true;
303
+ }
304
+ const sig = headerValue(req.headers["x-rine-signature"]);
305
+ const { verifyRineSignature } = await import("./hmac-BDQF87Wz.js");
306
+ if (!verifyRineSignature(raw, sig, state.secret)) {
307
+ state.logger.warn("rine: rejected inbound webhook with invalid HMAC signature");
308
+ res.statusCode = 401;
309
+ res.end("invalid signature");
310
+ return true;
311
+ }
312
+ let parsed;
313
+ try {
314
+ parsed = JSON.parse(raw.toString("utf-8"));
315
+ } catch {
316
+ res.statusCode = 400;
317
+ res.end("bad json");
318
+ return true;
319
+ }
320
+ const inbound = normalizeA2A(parsed) ?? normalizeStandardWebhook(parsed);
321
+ if (!inbound) {
322
+ res.statusCode = 202;
323
+ res.end("ignored");
324
+ return true;
325
+ }
326
+ if (inbound.encryptionVersion === "cleartext" && !state.a2aAcceptCleartext) {
327
+ state.logger.warn(`rine: dropped cleartext inbound ${inbound.id} (a2aAcceptCleartext=false)`);
328
+ res.statusCode = 202;
329
+ res.end("cleartext rejected");
330
+ return true;
331
+ }
332
+ res.statusCode = 200;
333
+ res.end("ok");
334
+ await state.onMessage(inbound);
335
+ return true;
336
+ }
337
+ function headerValue(h) {
338
+ return Array.isArray(h) ? h[0] : h;
339
+ }
340
+ /** Register the /rine/inbound route globally (idempotent no-op until EXPOSE active). */
341
+ function registerExposeRoute(api) {
342
+ api.registerHttpRoute({
343
+ path: RINE_INBOUND_PATH,
344
+ auth: "plugin",
345
+ match: "exact",
346
+ handler: handleInbound
347
+ });
348
+ }
349
+ /**
350
+ * EXPOSE transport. Requires a public base URL; enrolls a standard agent webhook
351
+ * (`POST /webhooks`, HMAC-signed, fires on all inbound). On any precondition failure
352
+ * (no URL / SSRF reject / enroll fail) it falls back to SSE. Never a hard failure.
353
+ * The enrolled webhook is DELETEd best-effort on teardown so it doesn't accumulate
354
+ * against the per-agent quota.
355
+ */
356
+ async function runExposeTransport(tc) {
357
+ const { client, config, agentId, logger, onMessage } = tc;
358
+ if (!config.exposeBaseUrl) {
359
+ logger.warn("rine: EXPOSE requires exposeBaseUrl (public Gateway URL) — falling back to SSE");
360
+ return fallbackToSse(tc);
361
+ }
362
+ const url = `${config.exposeBaseUrl.replace(/\/$/, "")}${RINE_INBOUND_PATH}`;
363
+ let secret;
364
+ let webhookId;
365
+ try {
366
+ const created = await client.client.post("/webhooks", {
367
+ agent_id: agentId,
368
+ url
369
+ });
370
+ if (!created.secret) throw new Error("no secret returned");
371
+ secret = created.secret;
372
+ webhookId = created.id;
373
+ } catch (err) {
374
+ logger.warn(`rine: webhook enrollment failed (${err instanceof Error ? err.message : String(err)}) — falling back to SSE`);
375
+ return fallbackToSse(tc);
376
+ }
377
+ exposeState = {
378
+ secret,
379
+ onMessage,
380
+ a2aAcceptCleartext: config.a2aAcceptCleartext,
381
+ logger
382
+ };
383
+ logger.info(`rine: EXPOSE active — standard webhook enrolled at ${url}`);
384
+ try {
385
+ await waitUntilAbort(tc.signal);
386
+ } finally {
387
+ exposeState = void 0;
388
+ await deleteWebhook(client, webhookId, logger);
389
+ }
390
+ }
391
+ /** Best-effort webhook teardown; never throws (a failed delete must not crash stop). */
392
+ async function deleteWebhook(client, webhookId, logger) {
393
+ if (!webhookId) return;
394
+ try {
395
+ await client.client.delete(`/webhooks/${webhookId}`);
396
+ } catch (err) {
397
+ logger.warn(`rine: webhook ${webhookId} cleanup failed (${err instanceof Error ? err.message : String(err)})`);
398
+ }
399
+ }
400
+ async function fallbackToSse(tc) {
401
+ const { runSseTransport } = await import("./sse-DqQGOjpI.js");
402
+ await runSseTransport(tc);
403
+ }
404
+ function waitUntilAbort(signal) {
405
+ return new Promise((resolve) => {
406
+ if (signal.aborted) {
407
+ resolve();
408
+ return;
409
+ }
410
+ signal.addEventListener("abort", () => resolve(), { once: true });
411
+ });
412
+ }
413
+ //#endregion
414
+ //#region src/service.ts
415
+ const SERVICE_ID = "rine-notify";
416
+ /** Run the chosen transport. Exposed for testing with a stubbed transport map. */
417
+ async function runTransport(tc) {
418
+ switch (tc.config.transport) {
419
+ case "poll": {
420
+ const { runPollTransport } = await import("./poll-BvmG87ve.js");
421
+ return runPollTransport(tc);
422
+ }
423
+ case "expose": return runExposeTransport(tc);
424
+ default: {
425
+ const { runSseTransport } = await import("./sse-DqQGOjpI.js");
426
+ return runSseTransport(tc);
427
+ }
428
+ }
429
+ }
430
+ /**
431
+ * Resolve the rine agent id this install is bound to. `channels.rine.agentId` (a UUID,
432
+ * handle, or bare name) wins; otherwise the org's sole agent is auto-selected — the same
433
+ * resolution the CLI uses (`fetchAgents` + `resolveAgent`).
434
+ *
435
+ * The OAuth `client_id` is NOT an agent id: credentials.json stores `client_id`/`client_secret`,
436
+ * and binding the transport to it requests `/agents/{client_id}/stream|messages`, which 404s on
437
+ * every transport. Returns undefined (the service idles) on a network error or an ambiguous
438
+ * multi-agent org with no `agentId` set — both surface an actionable log; the gateway
439
+ * health-monitor restarts the idle service, re-resolving once connectivity/config is fixed.
440
+ */
441
+ async function resolveBoundAgentId(client, config, logger) {
442
+ try {
443
+ const agents = await fetchAgents(client.client);
444
+ return await resolveAgent(client.apiUrl, agents, config.agentId);
445
+ } catch (err) {
446
+ logger.error(`rine: cannot resolve agent to bind (${err instanceof Error ? err.message : String(err)}). Set channels.rine.agentId (UUID or handle) or check connectivity — notify service idle.`);
447
+ return;
448
+ }
449
+ }
450
+ /** Resolve the bound agent, wire dispatch + transport context, and run the transport loop. */
451
+ async function startNotifyLoop(params) {
452
+ const { api, config, creds, client, signal, logger } = params;
453
+ const agentId = await resolveBoundAgentId(client, config, logger);
454
+ if (!agentId || signal.aborted) return;
455
+ const tc = {
456
+ client,
457
+ config,
458
+ agentId,
459
+ logger,
460
+ onMessage: makeOnMessage({
461
+ client,
462
+ config,
463
+ agentId,
464
+ logger,
465
+ dispatch: makeRuntimeDispatcher({
466
+ cfg: api.config,
467
+ runtime: api.runtime,
468
+ client,
469
+ agentId,
470
+ logger
471
+ }),
472
+ signal
473
+ }),
474
+ signal,
475
+ pollUrl: creds.pollUrl
476
+ };
477
+ logger.info(`rine: notify service starting (transport=${config.transport}, agent=${agentId})`);
478
+ await runTransport(tc);
479
+ }
480
+ /**
481
+ * The single background notify service. `start(ctx)` resolves creds, owns the
482
+ * AbortController (the service ctx has NO abort signal — see SDK_CONTRACT.md), then
483
+ * hands off to `startNotifyLoop` which resolves the bound agent id and runs the transport.
484
+ * `stop` aborts it. The long-lived loop lives here, not in a channel `gateway.startAccount`.
485
+ */
486
+ function makeRineService(api) {
487
+ let controller;
488
+ return {
489
+ id: SERVICE_ID,
490
+ start(ctx) {
491
+ const logger = ctx.logger ?? api.logger;
492
+ const config = resolveRineConfig(api.pluginConfig ?? {});
493
+ const creds = readRineCredentials(config);
494
+ if (!creds.entry) {
495
+ logger.warn(`rine: no credentials.json at ${creds.configDir} — notify service idle (run rine_onboard or set RINE_CONFIG_DIR)`);
496
+ return;
497
+ }
498
+ const client = buildRineClient(creds);
499
+ controller = new AbortController();
500
+ const signal = controller.signal;
501
+ startNotifyLoop({
502
+ api,
503
+ config,
504
+ creds,
505
+ client,
506
+ signal,
507
+ logger
508
+ }).catch((err) => {
509
+ logger.error(`rine: notify transport exited: ${err instanceof Error ? err.message : String(err)}`);
510
+ });
511
+ },
512
+ stop() {
513
+ controller?.abort();
514
+ controller = void 0;
515
+ }
516
+ };
517
+ }
518
+ //#endregion
519
+ //#region src/tools.ts
520
+ /** Tools exposed by the plugin (filtered from the mcp tool array). */
521
+ const EXPOSED_TOOLS = [
522
+ "rine_whoami",
523
+ "rine_discover",
524
+ "rine_send",
525
+ "rine_read",
526
+ "rine_inbox",
527
+ "rine_onboard"
528
+ ];
529
+ /** Mutating tools gated behind the allowlist (manifest `toolMetadata.optional`). */
530
+ const OPTIONAL_TOOLS = new Set(["rine_send", "rine_onboard"]);
531
+ const CIPHERTEXT_KEYS = ["encrypted_payload"];
532
+ /**
533
+ * Strip raw ciphertext from any tool result before it reaches a transcript.
534
+ * Recurses through objects/arrays so `rine_read`/`rine_inbox` (which spread the
535
+ * raw `MessageRead`) never surface `encrypted_payload`. Plaintext lives on the
536
+ * `decrypted`/`verified` fields the mcp handlers add.
537
+ */
538
+ function stripCiphertext(value) {
539
+ if (Array.isArray(value)) return value.map((v) => stripCiphertext(v));
540
+ if (value && typeof value === "object") {
541
+ const out = {};
542
+ for (const [k, v] of Object.entries(value)) {
543
+ if (CIPHERTEXT_KEYS.includes(k)) continue;
544
+ out[k] = stripCiphertext(v);
545
+ }
546
+ return out;
547
+ }
548
+ return value;
549
+ }
550
+ /** Assert one mcp tool name is present; throw a version-skew error otherwise. */
551
+ function requireTool(byName, name, all) {
552
+ const def = byName.get(name);
553
+ if (!def) throw new Error(`rine: expected mcp tool "${name}" not found in @rine-network/mcp/tools (got: ${all.map((t) => t.name).join(", ")}) — version skew`);
554
+ return def;
555
+ }
556
+ /** Filter mcp tools to the exposed set; fail loud on a missing name (version skew). */
557
+ function selectExposedTools(all = tools) {
558
+ const byName = new Map(all.map((t) => [t.name, t]));
559
+ return EXPOSED_TOOLS.map((name) => requireTool(byName, name, all));
560
+ }
561
+ /**
562
+ * Validate the undeclared internal tools the plugin relies on (e.g. `rine_reply`,
563
+ * which backs the inbound→reply path). Throws at startup on a version skew so a rename
564
+ * of an mcp tool fails fast at load — not silently at first reply.
565
+ */
566
+ function assertInternalTools(all = tools) {
567
+ const byName = new Map(all.map((t) => [t.name, t]));
568
+ for (const name of INTERNAL_TOOLS) requireTool(byName, name, all);
569
+ }
570
+ /**
571
+ * Adapt one mcp ToolDef into an OpenClaw AnyAgentTool with ciphertext stripping.
572
+ * `parameters` is rine-mcp's JSON-schema `inputSchema`; `registerTool` stores it
573
+ * verbatim (no TypeBox validation — see SDK_CONTRACT.md), so the whole tool object
574
+ * is cast to `AnyAgentTool` at one documented seam. (typebox is a transitive dep of
575
+ * openclaw, not directly importable, so we never reference its `TSchema` type.)
576
+ *
577
+ * A thrown handler (e.g. an optional tool called on a headless install, or a transient
578
+ * API failure) is converted into an actionable `jsonResult` error string — the agent
579
+ * gets a result it can reason about instead of the turn crashing or hanging.
580
+ */
581
+ function toOpenClawTool(def, ctx) {
582
+ return {
583
+ name: def.name,
584
+ label: def.name,
585
+ description: def.description,
586
+ parameters: def.inputSchema,
587
+ execute: async (_toolCallId, params, signal) => {
588
+ signal?.throwIfAborted();
589
+ try {
590
+ return jsonResult(stripCiphertext(await def.handler(ctx, params ?? {})));
591
+ } catch (err) {
592
+ if (err instanceof DOMException && err.name === "AbortError") throw err;
593
+ const message = err instanceof Error ? err.message : String(err);
594
+ return jsonResult({ error: `rine: ${def.name} failed: ${message}` });
595
+ }
596
+ }
597
+ };
598
+ }
599
+ /**
600
+ * Register the exposed rine tools on the plugin api. `rine_send`/`rine_onboard`
601
+ * are registered `{ optional: true }` (allowlist gating per manifest toolMetadata).
602
+ * Internal deps (`rine_reply`) are validated here too so version skew fails at load.
603
+ */
604
+ function registerRineTools(api, ctx) {
605
+ assertInternalTools();
606
+ for (const def of selectExposedTools()) {
607
+ const opts = OPTIONAL_TOOLS.has(def.name) ? {
608
+ name: def.name,
609
+ optional: true
610
+ } : { name: def.name };
611
+ api.registerTool(toOpenClawTool(def, ctx), opts);
612
+ }
613
+ }
614
+ //#endregion
615
+ //#region index.ts
616
+ /**
617
+ * Channel plugin entry. `defineChannelPluginEntry` registers the rine channel in every
618
+ * load mode; `registerFull` runs only in full (non-setup) mode and wires the tools,
619
+ * the always-on EXPOSE route, and the notify service. Top-level module eval stays
620
+ * side-effect-free (no network, no cred reads) — all work happens inside registration.
621
+ */
622
+ var rine_openclaw_default = defineChannelPluginEntry({
623
+ id: "rine",
624
+ name: "rine",
625
+ description,
626
+ plugin: rinePlugin,
627
+ configSchema: {
628
+ schema: configSchema,
629
+ uiHints
630
+ },
631
+ registerFull(api) {
632
+ registerRineTools(api, buildRineClient(readRineCredentials(resolveRineConfig(api.pluginConfig ?? {}))).toolContext);
633
+ registerExposeRoute(api);
634
+ api.registerService(makeRineService(api));
635
+ }
636
+ });
637
+ //#endregion
638
+ export { rine_openclaw_default as default };
@@ -0,0 +1,42 @@
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 };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Setup-safe entry for the rine channel. Loaded in setup/discovery registration modes.
3
+ * Side-effect-free at module top level. The transport posture is a config enum (SPEC
4
+ * §2.5) surfaced via the manifest configSchema + uiHints — OpenClaw has no native
5
+ * radio-select setup primitive, so the human sets `channels.rine.transport` directly.
6
+ *
7
+ * Credential auto-detection (no re-auth for an already-onboarded agent): the wizard
8
+ * checks `resolveConfigDir()` ($RINE_CONFIG_DIR > ~/.config/rine > cwd/.rine) for an
9
+ * existing credentials.json. `detectRineSetup` exposes that decision for the wizard UI.
10
+ */
11
+ export interface RineSetupDetection {
12
+ configDir: string;
13
+ apiUrl: string;
14
+ hasCredentials: boolean;
15
+ pollUrl?: string;
16
+ }
17
+ /** Auto-detect existing rine creds for the setup flow. Pure read; no writes. */
18
+ export declare function detectRineSetup(raw?: Record<string, unknown>): RineSetupDetection;
19
+ declare const _default: {
20
+ plugin: import("openclaw/plugin-sdk/channel-core").ChannelPlugin<import("./src/channel.js").RineAccount>;
21
+ };
22
+ export default _default;