@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 +106 -0
- package/dist/backoff-BMNABavv.js +33 -0
- package/dist/config-BsdV6THh.js +113 -0
- package/dist/hmac-BDQF87Wz.js +16 -0
- package/dist/inbound-G0JD7YmI.js +85 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +638 -0
- package/dist/poll-BvmG87ve.js +42 -0
- package/dist/setup.d.ts +22 -0
- package/dist/setup.js +16 -0
- package/dist/src/channel.d.ts +14 -0
- package/dist/src/config.d.ts +17 -0
- package/dist/src/constants.d.ts +15 -0
- package/dist/src/dispatch.d.ts +54 -0
- package/dist/src/inbound.d.ts +27 -0
- package/dist/src/outbound.d.ts +8 -0
- package/dist/src/rine-client.d.ts +25 -0
- package/dist/src/service.d.ts +26 -0
- package/dist/src/tools.d.ts +40 -0
- package/dist/src/transports/backoff.d.ts +9 -0
- package/dist/src/transports/context.d.ts +17 -0
- package/dist/src/transports/cursor.d.ts +5 -0
- package/dist/src/transports/expose.d.ts +16 -0
- package/dist/src/transports/hmac.d.ts +5 -0
- package/dist/src/transports/poll.d.ts +11 -0
- package/dist/src/transports/sse.d.ts +18 -0
- package/dist/src/types.d.ts +45 -0
- package/dist/sse-DqQGOjpI.js +170 -0
- package/openclaw.plugin.json +171 -0
- package/package.json +98 -0
- package/skills/rine/SKILL.md +259 -0
- package/skills/rine/references/openclaw.md +79 -0
package/dist/setup.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { n as resolveRineConfig, r as rinePlugin, t as readRineCredentials } from "./config-BsdV6THh.js";
|
|
2
|
+
import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core";
|
|
3
|
+
//#region setup.ts
|
|
4
|
+
/** Auto-detect existing rine creds for the setup flow. Pure read; no writes. */
|
|
5
|
+
function detectRineSetup(raw = {}) {
|
|
6
|
+
const creds = readRineCredentials(resolveRineConfig(raw));
|
|
7
|
+
return {
|
|
8
|
+
configDir: creds.configDir,
|
|
9
|
+
apiUrl: creds.apiUrl,
|
|
10
|
+
hasCredentials: Boolean(creds.entry),
|
|
11
|
+
pollUrl: creds.pollUrl
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
var setup_default = defineSetupPluginEntry(rinePlugin);
|
|
15
|
+
//#endregion
|
|
16
|
+
export { setup_default as default, detectRineSetup };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ChannelPlugin } from "openclaw/plugin-sdk";
|
|
2
|
+
/** Single-account model for the rine channel (the credentialed agent). */
|
|
3
|
+
export interface RineAccount {
|
|
4
|
+
accountId: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Minimal but type-valid `ChannelPlugin` for rine. Required fields only:
|
|
8
|
+
* id / meta / capabilities / config. Inbound delivery + outbound replies are owned by
|
|
9
|
+
* the notify service + dispatch seam (the canonical reply path lives on the runtime
|
|
10
|
+
* singleton, available to the service), so the channel object stays thin — it advertises
|
|
11
|
+
* the `rine` channel so sessions key as `agent:<id>:rine:<kind>:<peer>` and the channel
|
|
12
|
+
* surfaces in `plugins inspect`. See SDK_CONTRACT.md.
|
|
13
|
+
*/
|
|
14
|
+
export declare const rinePlugin: ChannelPlugin<RineAccount>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CredentialEntry } from "@rine-network/core";
|
|
2
|
+
import type { RineConfig } from "./types.js";
|
|
3
|
+
/** Resolve the typed config from a raw `api.pluginConfig` (or `{}`), applying defaults. */
|
|
4
|
+
export declare function resolveRineConfig(raw?: Record<string, unknown>): RineConfig;
|
|
5
|
+
export interface ResolvedCreds {
|
|
6
|
+
configDir: string;
|
|
7
|
+
apiUrl: string;
|
|
8
|
+
entry: CredentialEntry | undefined;
|
|
9
|
+
pollUrl: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Resolve rine credentials using core's 3-level config-dir fallback
|
|
13
|
+
* ($RINE_CONFIG_DIR > ~/.config/rine > $PWD/.rine). An explicit `cfg.configDir`
|
|
14
|
+
* override wins and is threaded directly — we never mutate `process.env`. Reuses core
|
|
15
|
+
* helpers; no host keychain access, no token writes.
|
|
16
|
+
*/
|
|
17
|
+
export declare function readRineCredentials(cfg: RineConfig): ResolvedCreds;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single `"default"` profile/account literal, shared so a rename touches one place.
|
|
3
|
+
*
|
|
4
|
+
* It is the rine credential *profile* key passed to core's
|
|
5
|
+
* `getCredentialEntry`/`getOrRefreshToken` (the entry under `.default` in
|
|
6
|
+
* credentials.json) and, identically, the OpenClaw single-account id for the rine
|
|
7
|
+
* channel. Both are `"default"` by convention.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_ACCOUNT_ID = "default";
|
|
10
|
+
/**
|
|
11
|
+
* Internal mcp tools the plugin depends on at runtime but does NOT expose to the agent.
|
|
12
|
+
* Validated at startup (alongside `selectExposedTools`) so a rename of an mcp tool fails
|
|
13
|
+
* fast at load, not at first reply. `rine_reply` backs the inbound→reply dispatch path.
|
|
14
|
+
*/
|
|
15
|
+
export declare const INTERNAL_TOOLS: readonly ["rine_reply"];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { OpenClawConfig, PluginLogger, PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { RineClient } from "./rine-client.js";
|
|
3
|
+
import type { RineConfig, RineInbound } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* The agent-turn dispatch fn — injected so the dedupe/ordering logic is unit-testable
|
|
6
|
+
* without the SDK. In production it wraps `dispatchInboundDirectDmWithRuntime`
|
|
7
|
+
* (`openclaw/plugin-sdk/channel-inbound`), the canonical inbound→agent-turn helper that
|
|
8
|
+
* routes, finalizes the PascalCase ctx, records the session, and dispatches.
|
|
9
|
+
*/
|
|
10
|
+
export type DispatchFn = (msg: RineInbound, signal: AbortSignal) => Promise<void>;
|
|
11
|
+
export interface OnMessageDeps {
|
|
12
|
+
client: RineClient;
|
|
13
|
+
config: RineConfig;
|
|
14
|
+
agentId: string;
|
|
15
|
+
logger: PluginLogger;
|
|
16
|
+
dispatch: DispatchFn;
|
|
17
|
+
signal: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A single inbound → exactly-one agent-turn handler with at-least-once mark-delivered.
|
|
21
|
+
*
|
|
22
|
+
* Returns `true` when the message reached a terminal handled state (delivered, already-seen,
|
|
23
|
+
* or quarantined) so the SSE cursor may advance past it; `false` only when dispatch FAILED —
|
|
24
|
+
* a transient error that must be retried, so the cursor must stay behind it.
|
|
25
|
+
*/
|
|
26
|
+
export type OnMessage = (msg: RineInbound) => Promise<boolean>;
|
|
27
|
+
/** The inline pointer body — ciphertext stays out of the transcript; agent calls `rine_read`. */
|
|
28
|
+
export declare function pointerText(msg: RineInbound): string;
|
|
29
|
+
/**
|
|
30
|
+
* Build the shared `onMessage` path all transports converge on.
|
|
31
|
+
*
|
|
32
|
+
* Invariants (acceptance §6):
|
|
33
|
+
* - dedupe by rine message id (Set, add-id-BEFORE-await race guard; delete-on-error
|
|
34
|
+
* so a failed dispatch is retried; hourly clear bounds memory).
|
|
35
|
+
* - mark-delivered ONLY after a successful dispatch (at-least-once).
|
|
36
|
+
* - disallowed sender → quarantine (logged exactly once), no dispatch, not marked
|
|
37
|
+
* delivered (recoverable after the operator fixes allowFrom + restarts the gateway).
|
|
38
|
+
*/
|
|
39
|
+
export declare function makeOnMessage(deps: OnMessageDeps): OnMessage;
|
|
40
|
+
/**
|
|
41
|
+
* Production dispatch fn: wakes an agent turn for one inbound rine message via the
|
|
42
|
+
* canonical `dispatchInboundDirectDmWithRuntime` helper. `api.config` (cfg) and
|
|
43
|
+
* `api.runtime` (the `DirectDmRuntime` facade) are threaded in from the service.
|
|
44
|
+
*
|
|
45
|
+
* Keeps the pointer-body design: `pointerText(msg)` carries only a "read with rine_read"
|
|
46
|
+
* pointer, so ciphertext never enters the transcript.
|
|
47
|
+
*/
|
|
48
|
+
export declare function makeRuntimeDispatcher(params: {
|
|
49
|
+
cfg: OpenClawConfig;
|
|
50
|
+
runtime: PluginRuntime;
|
|
51
|
+
client: RineClient;
|
|
52
|
+
agentId: string;
|
|
53
|
+
logger: PluginLogger;
|
|
54
|
+
}): DispatchFn;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { MessageRead } from "@rine-network/core";
|
|
2
|
+
import type { AllowDecision, RineInbound } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Normalize a rine SSE `event: message` payload (or a `/messages` item) — both are
|
|
5
|
+
* a full `MessageRead` JSON — into the transport-agnostic `RineInbound`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function normalizeRineEvent(raw: MessageRead): RineInbound;
|
|
8
|
+
/**
|
|
9
|
+
* Normalize a rine standard-webhook body. rine's webhook delivers the message
|
|
10
|
+
* record (possibly wrapped under `message`/`data`). Falls through to the same
|
|
11
|
+
* MessageRead shape as the SSE/messages path.
|
|
12
|
+
*/
|
|
13
|
+
export declare function normalizeStandardWebhook(body: unknown): RineInbound | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Normalize an A2A per-task push (`result.artifactUpdate` JSON-RPC envelope).
|
|
16
|
+
* taskId == contextId == conversationId. Returns undefined if the shape doesn't match.
|
|
17
|
+
*
|
|
18
|
+
* SECURITY: `from` (→ `fromHandle`) is self-reported by the A2A producer. The webhook
|
|
19
|
+
* HMAC proves the *channel* (the rine relay) is genuine, not that the asserted sender is
|
|
20
|
+
* who they claim — treat the handle as unverified for any trust decision.
|
|
21
|
+
*/
|
|
22
|
+
export declare function normalizeA2A(body: unknown): RineInbound | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Allowlist decision. `*` = allow all; `@org` = org-scoped; exact handle match.
|
|
25
|
+
* Disallowed senders are **quarantined** (caller logs), never silently dropped.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isAllowed(fromHandle: string, allowFrom: string[]): AllowDecision;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RineClient } from "./rine-client.js";
|
|
2
|
+
import type { RineInbound } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Route an agent reply back out as a rine message, reusing the lifted `rine_reply`
|
|
5
|
+
* handler (E2EE encrypt + POST /messages/{id}/reply, auto-routed to the original
|
|
6
|
+
* sender, conversation_id preserved). No crypto/HTTP reimplemented.
|
|
7
|
+
*/
|
|
8
|
+
export declare function sendRineReply(client: RineClient, inbound: RineInbound, text: string): Promise<unknown>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { HttpClient } from "@rine-network/core";
|
|
2
|
+
import type { MessageRead } from "@rine-network/core";
|
|
3
|
+
import type { ToolContext } from "@rine-network/mcp/tools";
|
|
4
|
+
import type { ResolvedCreds } from "./config.js";
|
|
5
|
+
export interface RineClient {
|
|
6
|
+
/** Shared `ToolContext` for the lifted mcp tool handlers. */
|
|
7
|
+
toolContext: ToolContext;
|
|
8
|
+
client: HttpClient;
|
|
9
|
+
configDir: string;
|
|
10
|
+
apiUrl: string;
|
|
11
|
+
/** Mint/refresh the OAuth JWT via core's token cache (auto-refresh on 401). */
|
|
12
|
+
getJwt: (force?: boolean) => Promise<string>;
|
|
13
|
+
/** Unauthenticated poll: `GET {pollUrl}` → count. Returns 0 on any failure. */
|
|
14
|
+
pollCount: (pollUrl: string, signal?: AbortSignal) => Promise<number>;
|
|
15
|
+
/** Fetch new messages for an agent (auth). */
|
|
16
|
+
fetchNewMessages: (agentId: string, limit?: number) => Promise<MessageRead[]>;
|
|
17
|
+
/** Mark messages delivered (auth) — at-least-once after successful dispatch. */
|
|
18
|
+
markDelivered: (agentId: string, ids: string[]) => Promise<number>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Build the rine HTTP client + ToolContext from resolved creds. Mirrors
|
|
22
|
+
* rine-mcp/src/server.ts bootstrap: tokenFn = getCredentialEntry + getOrRefreshToken;
|
|
23
|
+
* new HttpClient({ tokenFn, apiUrl, canRefresh }).
|
|
24
|
+
*/
|
|
25
|
+
export declare function buildRineClient(creds: ResolvedCreds): RineClient;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { OpenClawPluginApi, PluginLogger } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { OpenClawPluginService } from "openclaw/plugin-sdk/core";
|
|
3
|
+
import type { RineClient } from "./rine-client.js";
|
|
4
|
+
import type { TransportContext } from "./transports/context.js";
|
|
5
|
+
import type { RineConfig } from "./types.js";
|
|
6
|
+
/** Run the chosen transport. Exposed for testing with a stubbed transport map. */
|
|
7
|
+
export declare function runTransport(tc: TransportContext): Promise<void>;
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the rine agent id this install is bound to. `channels.rine.agentId` (a UUID,
|
|
10
|
+
* handle, or bare name) wins; otherwise the org's sole agent is auto-selected — the same
|
|
11
|
+
* resolution the CLI uses (`fetchAgents` + `resolveAgent`).
|
|
12
|
+
*
|
|
13
|
+
* The OAuth `client_id` is NOT an agent id: credentials.json stores `client_id`/`client_secret`,
|
|
14
|
+
* and binding the transport to it requests `/agents/{client_id}/stream|messages`, which 404s on
|
|
15
|
+
* every transport. Returns undefined (the service idles) on a network error or an ambiguous
|
|
16
|
+
* multi-agent org with no `agentId` set — both surface an actionable log; the gateway
|
|
17
|
+
* health-monitor restarts the idle service, re-resolving once connectivity/config is fixed.
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveBoundAgentId(client: RineClient, config: RineConfig, logger: PluginLogger): Promise<string | undefined>;
|
|
20
|
+
/**
|
|
21
|
+
* The single background notify service. `start(ctx)` resolves creds, owns the
|
|
22
|
+
* AbortController (the service ctx has NO abort signal — see SDK_CONTRACT.md), then
|
|
23
|
+
* hands off to `startNotifyLoop` which resolves the bound agent id and runs the transport.
|
|
24
|
+
* `stop` aborts it. The long-lived loop lives here, not in a channel `gateway.startAccount`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function makeRineService(api: OpenClawPluginApi): OpenClawPluginService;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { ToolContext, ToolDef } from "@rine-network/mcp/tools";
|
|
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"];
|
|
6
|
+
/** Mutating tools gated behind the allowlist (manifest `toolMetadata.optional`). */
|
|
7
|
+
export declare const OPTIONAL_TOOLS: Set<string>;
|
|
8
|
+
/**
|
|
9
|
+
* Strip raw ciphertext from any tool result before it reaches a transcript.
|
|
10
|
+
* Recurses through objects/arrays so `rine_read`/`rine_inbox` (which spread the
|
|
11
|
+
* raw `MessageRead`) never surface `encrypted_payload`. Plaintext lives on the
|
|
12
|
+
* `decrypted`/`verified` fields the mcp handlers add.
|
|
13
|
+
*/
|
|
14
|
+
export declare function stripCiphertext(value: unknown): unknown;
|
|
15
|
+
/** Filter mcp tools to the exposed set; fail loud on a missing name (version skew). */
|
|
16
|
+
export declare function selectExposedTools(all?: ToolDef[]): ToolDef[];
|
|
17
|
+
/**
|
|
18
|
+
* Validate the undeclared internal tools the plugin relies on (e.g. `rine_reply`,
|
|
19
|
+
* which backs the inbound→reply path). Throws at startup on a version skew so a rename
|
|
20
|
+
* of an mcp tool fails fast at load — not silently at first reply.
|
|
21
|
+
*/
|
|
22
|
+
export declare function assertInternalTools(all?: ToolDef[]): void;
|
|
23
|
+
/**
|
|
24
|
+
* Adapt one mcp ToolDef into an OpenClaw AnyAgentTool with ciphertext stripping.
|
|
25
|
+
* `parameters` is rine-mcp's JSON-schema `inputSchema`; `registerTool` stores it
|
|
26
|
+
* verbatim (no TypeBox validation — see SDK_CONTRACT.md), so the whole tool object
|
|
27
|
+
* is cast to `AnyAgentTool` at one documented seam. (typebox is a transitive dep of
|
|
28
|
+
* openclaw, not directly importable, so we never reference its `TSchema` type.)
|
|
29
|
+
*
|
|
30
|
+
* A thrown handler (e.g. an optional tool called on a headless install, or a transient
|
|
31
|
+
* API failure) is converted into an actionable `jsonResult` error string — the agent
|
|
32
|
+
* gets a result it can reason about instead of the turn crashing or hanging.
|
|
33
|
+
*/
|
|
34
|
+
export declare function toOpenClawTool(def: ToolDef, ctx: ToolContext): AnyAgentTool;
|
|
35
|
+
/**
|
|
36
|
+
* Register the exposed rine tools on the plugin api. `rine_send`/`rine_onboard`
|
|
37
|
+
* are registered `{ optional: true }` (allowlist gating per manifest toolMetadata).
|
|
38
|
+
* Internal deps (`rine_reply`) are validated here too so version skew fails at load.
|
|
39
|
+
*/
|
|
40
|
+
export declare function registerRineTools(api: OpenClawPluginApi, ctx: ToolContext): void;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Sleep for `ms`, resolving early (rejecting) if the signal aborts. */
|
|
2
|
+
export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
3
|
+
/**
|
|
4
|
+
* OpenClawcity exp-backoff + jitter:
|
|
5
|
+
* exp = base * 2^attempt; capped = min(exp, max);
|
|
6
|
+
* jitter = capped * 0.3 * (rand*2 - 1); return max(100, capped + jitter)
|
|
7
|
+
* Bounded to >=100ms and <= max + 30% jitter.
|
|
8
|
+
*/
|
|
9
|
+
export declare function backoff(attempt: number, baseMs: number, maxMs: number, rand?: () => number): number;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PluginLogger } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { OnMessage } from "../dispatch.js";
|
|
3
|
+
import type { RineClient } from "../rine-client.js";
|
|
4
|
+
import type { RineConfig } from "../types.js";
|
|
5
|
+
/** Everything a transport needs. Built once by the notify service. */
|
|
6
|
+
export interface TransportContext {
|
|
7
|
+
client: RineClient;
|
|
8
|
+
config: RineConfig;
|
|
9
|
+
agentId: string;
|
|
10
|
+
logger: PluginLogger;
|
|
11
|
+
/** Shared dedupe + mark-after-success dispatch path. */
|
|
12
|
+
onMessage: OnMessage;
|
|
13
|
+
/** Service-owned abort signal (created in start, aborted in stop). */
|
|
14
|
+
signal: AbortSignal;
|
|
15
|
+
/** Poll token URL from credentials.json (`.default.poll_url`). */
|
|
16
|
+
pollUrl?: string;
|
|
17
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { PluginLogger } from "openclaw/plugin-sdk";
|
|
2
|
+
/** Read the persisted Last-Event-ID for an agent, or undefined if absent/corrupt. */
|
|
3
|
+
export declare function loadCursor(configDir: string, agentId: string, logger?: PluginLogger): string | undefined;
|
|
4
|
+
/** Persist the Last-Event-ID for an agent via an atomic 0600 write. Best-effort. */
|
|
5
|
+
export declare function saveCursor(configDir: string, agentId: string, eventId: string, logger?: PluginLogger): void;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { TransportContext } from "./context.js";
|
|
4
|
+
export declare const RINE_INBOUND_PATH = "/rine/inbound";
|
|
5
|
+
/** The /rine/inbound handler: HMAC-verify, normalize (standard or A2A), dispatch. */
|
|
6
|
+
export declare function handleInbound(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
|
|
7
|
+
/** Register the /rine/inbound route globally (idempotent no-op until EXPOSE active). */
|
|
8
|
+
export declare function registerExposeRoute(api: OpenClawPluginApi): void;
|
|
9
|
+
/**
|
|
10
|
+
* EXPOSE transport. Requires a public base URL; enrolls a standard agent webhook
|
|
11
|
+
* (`POST /webhooks`, HMAC-signed, fires on all inbound). On any precondition failure
|
|
12
|
+
* (no URL / SSRF reject / enroll fail) it falls back to SSE. Never a hard failure.
|
|
13
|
+
* The enrolled webhook is DELETEd best-effort on teardown so it doesn't accumulate
|
|
14
|
+
* against the per-agent quota.
|
|
15
|
+
*/
|
|
16
|
+
export declare function runExposeTransport(tc: TransportContext): Promise<void>;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify a rine standard-webhook signature: `X-Rine-Signature: sha256=<hex>`,
|
|
3
|
+
* where hex = HMAC-SHA256(rawBody, secret). Constant-time compare.
|
|
4
|
+
*/
|
|
5
|
+
export declare function verifyRineSignature(rawBody: Buffer | string, header: string | undefined, secret: string): boolean;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TransportContext } from "./context.js";
|
|
2
|
+
/**
|
|
3
|
+
* POLL transport — least token-intensive. Fixed-interval `GET /poll/{token}` (unauth);
|
|
4
|
+
* only on `count > 0` does it mint a JWT, fetch `/messages?status=new`, and dispatch.
|
|
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.)
|
|
10
|
+
*/
|
|
11
|
+
export declare function runPollTransport(tc: TransportContext): Promise<void>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { TransportContext } from "./context.js";
|
|
2
|
+
interface SseEvent {
|
|
3
|
+
event: string;
|
|
4
|
+
id?: string;
|
|
5
|
+
data: string;
|
|
6
|
+
}
|
|
7
|
+
/** Parse a complete SSE event block (lines split on `\n`). */
|
|
8
|
+
export declare function parseSseBlock(block: string): SseEvent | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* SSE transport — the safe default. Holds an authenticated outbound stream to
|
|
11
|
+
* `GET /agents/{id}/stream?persistent=true`, resumes via `Last-Event-ID`, dispatches
|
|
12
|
+
* `event: message` payloads, reconnects with exp-backoff + jitter.
|
|
13
|
+
*
|
|
14
|
+
* Graceful fallback: after repeated connect/stream errors it imports and runs the POLL
|
|
15
|
+
* transport (SSE → poll rung of the ladder). Never crashes the service.
|
|
16
|
+
*/
|
|
17
|
+
export declare function runSseTransport(tc: TransportContext): Promise<void>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Normalized inbound rine message, transport-agnostic. */
|
|
2
|
+
export interface RineInbound {
|
|
3
|
+
/** rine message id — the dedupe + mark-delivered key. */
|
|
4
|
+
id: string;
|
|
5
|
+
/** rine conversation uuid (thread id / A2A taskId). */
|
|
6
|
+
conversationId: string;
|
|
7
|
+
/**
|
|
8
|
+
* Sender handle `name@org`, or agent id when handle is absent.
|
|
9
|
+
*
|
|
10
|
+
* SECURITY: for A2A-sourced inbound this is the *self-reported* `from` field. The
|
|
11
|
+
* webhook HMAC authenticates the channel (the rine relay), NOT the asserted sender,
|
|
12
|
+
* so an A2A `fromHandle` is untrusted for authorization — gate on `allowFrom` only as
|
|
13
|
+
* a coarse filter, never as proof of identity.
|
|
14
|
+
*/
|
|
15
|
+
fromHandle: string;
|
|
16
|
+
/** rine message type, e.g. `rine.v1.dm`. */
|
|
17
|
+
type: string;
|
|
18
|
+
/** Whether this is a group/broadcast message. */
|
|
19
|
+
isGroup: boolean;
|
|
20
|
+
/** Group handle `#name@org` when `isGroup`. */
|
|
21
|
+
groupHandle?: string;
|
|
22
|
+
/** Raw ISO-8601 created timestamp. */
|
|
23
|
+
createdAt?: string;
|
|
24
|
+
/** Opaque ciphertext — decrypted on demand, never surfaced verbatim. */
|
|
25
|
+
encryptedPayload: string;
|
|
26
|
+
/** E2EE scheme tag, e.g. `hpke-v1`, `mls-v1`. */
|
|
27
|
+
encryptionVersion: string;
|
|
28
|
+
/** Group id (uuid) for group decrypt. */
|
|
29
|
+
groupId?: string;
|
|
30
|
+
}
|
|
31
|
+
/** Resolved plugin config (after defaults). Mirrors `openclaw.plugin.json` configSchema. */
|
|
32
|
+
export interface RineConfig {
|
|
33
|
+
transport: "expose" | "sse" | "poll";
|
|
34
|
+
configDir?: string;
|
|
35
|
+
agentId?: string;
|
|
36
|
+
baseUrl?: string;
|
|
37
|
+
pollIntervalMs: number;
|
|
38
|
+
reconnectBaseMs: number;
|
|
39
|
+
reconnectMaxMs: number;
|
|
40
|
+
exposeBaseUrl?: string;
|
|
41
|
+
a2aAcceptCleartext: boolean;
|
|
42
|
+
allowFrom: string[];
|
|
43
|
+
}
|
|
44
|
+
/** Disposition for a sender vs the allowlist. */
|
|
45
|
+
export type AllowDecision = "allowed" | "quarantined";
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { r as normalizeRineEvent } from "./inbound-G0JD7YmI.js";
|
|
2
|
+
import { n as sleep, t as backoff } from "./backoff-BMNABavv.js";
|
|
3
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
//#region src/transports/cursor.ts
|
|
6
|
+
/** Per-agent cursor path; agentId is sanitized so an operator-typed handle can't escape configDir. */
|
|
7
|
+
function cursorPath(configDir, agentId) {
|
|
8
|
+
return join(configDir, `sse_cursor_${(agentId || "default").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 128)}.json`);
|
|
9
|
+
}
|
|
10
|
+
function msgOf(err) {
|
|
11
|
+
return err instanceof Error ? err.message : String(err);
|
|
12
|
+
}
|
|
13
|
+
/** Read the persisted Last-Event-ID for an agent, or undefined if absent/corrupt. */
|
|
14
|
+
function loadCursor(configDir, agentId, logger) {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(readFileSync(cursorPath(configDir, agentId), "utf-8"));
|
|
17
|
+
return typeof parsed.lastEventId === "string" && parsed.lastEventId !== "" ? parsed.lastEventId : void 0;
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (err.code !== "ENOENT") logger?.debug?.(`rine: cursor load failed (${msgOf(err)}) — resuming without it`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Persist the Last-Event-ID for an agent via an atomic 0600 write. Best-effort. */
|
|
24
|
+
function saveCursor(configDir, agentId, eventId, logger) {
|
|
25
|
+
const path = cursorPath(configDir, agentId);
|
|
26
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
27
|
+
try {
|
|
28
|
+
mkdirSync(configDir, {
|
|
29
|
+
recursive: true,
|
|
30
|
+
mode: 448
|
|
31
|
+
});
|
|
32
|
+
writeFileSync(tmp, JSON.stringify({ lastEventId: eventId }), { mode: 384 });
|
|
33
|
+
renameSync(tmp, path);
|
|
34
|
+
chmodSync(path, 384);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
logger?.debug?.(`rine: cursor save failed for ${eventId} (${msgOf(err)})`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region src/transports/sse.ts
|
|
41
|
+
const MAX_ATTEMPTS_BEFORE_FALLBACK = 5;
|
|
42
|
+
/** ~3× the server heartbeat (~30s): no bytes for this long => dead socket, reconnect. */
|
|
43
|
+
const IDLE_TIMEOUT_MS = 9e4;
|
|
44
|
+
/** Parse a complete SSE event block (lines split on `\n`). */
|
|
45
|
+
function parseSseBlock(block) {
|
|
46
|
+
let event = "message";
|
|
47
|
+
let id;
|
|
48
|
+
const dataLines = [];
|
|
49
|
+
for (const line of block.split("\n")) {
|
|
50
|
+
if (line.startsWith(":")) continue;
|
|
51
|
+
const idx = line.indexOf(":");
|
|
52
|
+
const field = idx === -1 ? line : line.slice(0, idx);
|
|
53
|
+
const value = idx === -1 ? "" : line.slice(idx + 1).replace(/^ /, "");
|
|
54
|
+
if (field === "event") event = value;
|
|
55
|
+
else if (field === "id") id = value;
|
|
56
|
+
else if (field === "data") dataLines.push(value);
|
|
57
|
+
}
|
|
58
|
+
if (dataLines.length === 0 && !id) return void 0;
|
|
59
|
+
return {
|
|
60
|
+
event,
|
|
61
|
+
id,
|
|
62
|
+
data: dataLines.join("\n")
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* SSE transport — the safe default. Holds an authenticated outbound stream to
|
|
67
|
+
* `GET /agents/{id}/stream?persistent=true`, resumes via `Last-Event-ID`, dispatches
|
|
68
|
+
* `event: message` payloads, reconnects with exp-backoff + jitter.
|
|
69
|
+
*
|
|
70
|
+
* Graceful fallback: after repeated connect/stream errors it imports and runs the POLL
|
|
71
|
+
* transport (SSE → poll rung of the ladder). Never crashes the service.
|
|
72
|
+
*/
|
|
73
|
+
async function runSseTransport(tc) {
|
|
74
|
+
const { client, config, agentId, logger, onMessage, signal } = tc;
|
|
75
|
+
let attempt = 0;
|
|
76
|
+
let lastEventId = loadCursor(client.configDir, agentId, logger);
|
|
77
|
+
while (!signal.aborted) try {
|
|
78
|
+
await streamOnce({
|
|
79
|
+
tc,
|
|
80
|
+
lastEventId,
|
|
81
|
+
onDelivered: (id) => {
|
|
82
|
+
lastEventId = id;
|
|
83
|
+
saveCursor(client.configDir, agentId, id, logger);
|
|
84
|
+
},
|
|
85
|
+
onReset: () => {
|
|
86
|
+
attempt = 0;
|
|
87
|
+
},
|
|
88
|
+
onRaw: async (raw) => onMessage(normalizeRineEvent(raw))
|
|
89
|
+
});
|
|
90
|
+
attempt = 0;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
attempt += 1;
|
|
93
|
+
logger.warn(`rine: SSE stream error (attempt ${attempt}): ${err instanceof Error ? err.message : String(err)}`);
|
|
94
|
+
if (attempt >= MAX_ATTEMPTS_BEFORE_FALLBACK) {
|
|
95
|
+
logger.warn("rine: SSE failed repeatedly — falling back to POLL transport");
|
|
96
|
+
const { runPollTransport } = await import("./poll-BvmG87ve.js");
|
|
97
|
+
await runPollTransport(tc);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await waitBackoff(attempt, config, signal);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function waitBackoff(attempt, config, signal) {
|
|
104
|
+
const delay = backoff(attempt, config.reconnectBaseMs, config.reconnectMaxMs);
|
|
105
|
+
try {
|
|
106
|
+
await sleep(delay, signal);
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
async function streamOnce(params) {
|
|
110
|
+
const { tc, lastEventId, onDelivered, onReset, onRaw } = params;
|
|
111
|
+
const { client, agentId, signal } = tc;
|
|
112
|
+
const headers = {
|
|
113
|
+
Authorization: `Bearer ${await client.getJwt()}`,
|
|
114
|
+
Accept: "text/event-stream"
|
|
115
|
+
};
|
|
116
|
+
if (lastEventId) headers["Last-Event-ID"] = lastEventId;
|
|
117
|
+
const url = `${client.apiUrl}/agents/${agentId}/stream?persistent=true`;
|
|
118
|
+
const res = await fetch(url, {
|
|
119
|
+
headers,
|
|
120
|
+
signal
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok || !res.body) throw new Error(`stream HTTP ${res.status}`);
|
|
123
|
+
onReset();
|
|
124
|
+
const reader = res.body.getReader();
|
|
125
|
+
const decoder = new TextDecoder();
|
|
126
|
+
let buffer = "";
|
|
127
|
+
let gapSeen = false;
|
|
128
|
+
while (!signal.aborted) {
|
|
129
|
+
const { done, value } = await readWithIdleWatchdog(reader);
|
|
130
|
+
if (done) return;
|
|
131
|
+
buffer += decoder.decode(value, { stream: true });
|
|
132
|
+
let sep;
|
|
133
|
+
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
|
134
|
+
const block = buffer.slice(0, sep);
|
|
135
|
+
buffer = buffer.slice(sep + 2);
|
|
136
|
+
const evt = parseSseBlock(block);
|
|
137
|
+
if (!evt || evt.event !== "message" || !evt.data) continue;
|
|
138
|
+
let delivered = false;
|
|
139
|
+
try {
|
|
140
|
+
delivered = await onRaw(JSON.parse(evt.data));
|
|
141
|
+
} catch {
|
|
142
|
+
if (evt.id) gapSeen = true;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (delivered && !gapSeen && evt.id) onDelivered(evt.id);
|
|
146
|
+
else if (!delivered) gapSeen = true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* `reader.read()` with an idle watchdog: if no chunk arrives within `IDLE_TIMEOUT_MS`,
|
|
152
|
+
* cancel the reader and throw so the reconnect loop takes over (a silently-dead socket
|
|
153
|
+
* never surfaces a `done`). Resets on every chunk because each call starts a fresh timer.
|
|
154
|
+
*/
|
|
155
|
+
async function readWithIdleWatchdog(reader) {
|
|
156
|
+
let timer;
|
|
157
|
+
const idle = new Promise((_, reject) => {
|
|
158
|
+
timer = setTimeout(() => {
|
|
159
|
+
reader.cancel().catch(() => {});
|
|
160
|
+
reject(/* @__PURE__ */ new Error(`SSE idle timeout (${IDLE_TIMEOUT_MS}ms, no bytes)`));
|
|
161
|
+
}, IDLE_TIMEOUT_MS);
|
|
162
|
+
});
|
|
163
|
+
try {
|
|
164
|
+
return await Promise.race([reader.read(), idle]);
|
|
165
|
+
} finally {
|
|
166
|
+
if (timer) clearTimeout(timer);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
export { runSseTransport };
|