@relaymessenger/openclaw-plugin 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Companion Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # Relay channel plugin for OpenClaw
2
+
3
+ Backs a Relay contact with an OpenClaw agent: install the plugin, point it at
4
+ an owner-only Agent Token file, and your OpenClaw appears in Relay as a contact
5
+ you text like a friend.
6
+
7
+ Requires `openclaw >= 2026.7.2-beta.5`, which today means the OpenClaw beta
8
+ channel: stable `2026.7.1-2` is too old, because the plugin derives its
9
+ idempotency keys from the per-part delivery context that only the beta line
10
+ provides. Install it with `npm install -g openclaw@beta`.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ npm install -g @relaymessenger/cli
16
+ relaymessenger pair
17
+ relaymessenger install-openclaw
18
+ ```
19
+
20
+ The installer uses the OpenClaw archive bundled in the installed `relaymessenger`
21
+ package, persists that archive in the paired account's private Relay runtime,
22
+ and invokes OpenClaw's managed `npm-pack:` installer on the stable copy so
23
+ declared runtime dependencies are installed with the plugin. It surgically
24
+ adds Relay to `~/.openclaw/openclaw.json`, preserves unrelated configuration,
25
+ writes the token to an owner-only file, and never prints it. It refuses to
26
+ replace a different configured Relay identity.
27
+
28
+ For integration development from this checkout only:
29
+
30
+ ```sh
31
+ cd integrations/openclaw
32
+ npm install
33
+ npm pack
34
+ openclaw plugins install npm-pack:./relaymessenger-openclaw-plugin-0.1.0.tgz --force
35
+ ```
36
+
37
+ The installer produces the equivalent Relay-specific configuration:
38
+
39
+ ```json
40
+ {
41
+ "plugins": {
42
+ "allow": ["relay"],
43
+ "entries": { "relay": { "enabled": true } }
44
+ },
45
+ "channels": {
46
+ "relay": {
47
+ "enabled": true,
48
+ "tokenFile": "~/.openclaw/secrets/relay-agent-token",
49
+ "baseUrl": "https://api.relayapp.im"
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ `RELAY_AGENT_TOKEN` / `RELAY_BASE_URL` are honored for the default account
56
+ when the config fields are absent. Multiple agents run as named accounts under
57
+ `channels.relay.accounts.<id>`, one token each.
58
+
59
+ ## Sender security
60
+
61
+ Relay agents can be discoverable, so adding an agent as a contact is not an
62
+ authorization boundary. The plugin accepts inbound turns only from the
63
+ authenticated agent's `owner_user_id` returned by `GET /v1/agents/me` and any
64
+ Relay user ids the operator explicitly adds to `allowFrom`. Wildcards are
65
+ ignored. If neither an API owner nor an explicit allowlist is available, the
66
+ account fails closed and does not start polling.
67
+
68
+ ## One consumer per token
69
+
70
+ Relay's `GET /v1/events` long poll allows **exactly one consumer per Agent
71
+ Token**:
72
+
73
+ - A newer poll takes the slot; the older consumer's request ends with
74
+ `409 terminated_by_other_consumer`. Running this plugin and another
75
+ long-poll consumer (for example `relaymessenger start`, or a second OpenClaw) on
76
+ the same token makes them steal the slot from each other forever. Give each
77
+ consumer its own agent/token.
78
+ - Long polling is **XOR with webhooks**: while a webhook endpoint is enabled
79
+ for the agent, `/v1/events` returns `409 conflict` and the channel stops
80
+ with a terminal disconnect. Disable or delete the agent's webhooks to poll.
81
+
82
+ The plugin also refuses to start a second configured account that resolves to
83
+ the same agent id as a running one.
84
+
85
+ ## Config reference (per account)
86
+
87
+ | Field | Meaning |
88
+ | --- | --- |
89
+ | `token` / `tokenFile` | Agent Token (or file containing it) |
90
+ | `baseUrl` | Relay API origin (default `https://api.relayapp.im`). Remote origins must use HTTPS; HTTP is accepted only for loopback development. Paths, credentials, queries, and fragments are rejected. |
91
+ | `allowFrom` | Additional Relay user ids (`usr_…`) allowed alongside the API-pinned owner. These identities may also run control commands. Wildcards are ignored. |
92
+ | `pollTimeoutSeconds` | Long-poll hold time, 1–30 (default 30) |
93
+
94
+ ## Harness
95
+
96
+ Run `npm run gateway:harness` from the repository root for the clean installed-
97
+ runtime proof. It packs the plugin, installs it into an isolated `HOME`, starts
98
+ a real OpenClaw gateway against `harness/mock-relay-server.mjs`, receives a
99
+ Relay event, completes a mock model turn, and verifies the reply reaches
100
+ Relay. The release workflow requires this proof.
101
+
102
+ ## Delivery and crash semantics
103
+
104
+ - Every outbound platform send has a logical-send idempotency key. Durable
105
+ queue retries reuse the same key, while intentional identical messages and
106
+ identical chunks remain distinct.
107
+ - Admission, route/session resolution, envelope building, and context
108
+ finalization run before an inbound event is marked attempted. Failures in
109
+ that replay-safe preflight release the claim and retry the event. The marker
110
+ is committed durably immediately before OpenClaw can dispatch the agent or
111
+ its tools; a failure after that boundary does not silently replay possible
112
+ tool side effects. The user can resend the message deliberately.
113
+ - The long-poll cursor and inbound attempt keys are bound to the canonical
114
+ Relay API origin plus Relay agent id, not to a mutable local account label.
115
+ Renaming an account therefore retains its cursor. A missing identity starts
116
+ at cursor zero; corrupt, mismatched, unreadable, or unwritable state fails
117
+ closed instead of replaying retained history. The cursor namespace rejects
118
+ new identities at capacity rather than evicting an older cursor. Cursor
119
+ state is independent of the bounded 30-day attempt-dedupe horizon. Both
120
+ namespaces are private, lock-protected, atomically replaced Relay-owned
121
+ files under `$OPENCLAW_STATE_DIR/relay/state` (normally
122
+ `~/.openclaw/relay/state`); the plugin never requests trusted-only OpenClaw
123
+ host SQLite access.
124
+ - Before polling, the plugin takes an atomic per-origin/per-agent filesystem
125
+ lock under `~/.openclaw/relay/consumer-locks`. A second OpenClaw process
126
+ fails closed; a lock whose recorded PID is dead is recovered on startup.
127
+ Shutdown aborts the active long poll and releases both process-local and
128
+ filesystem ownership before a replacement starts.
129
+ - Every API operation has a deadline (15 seconds for ordinary calls; the
130
+ configured long-poll hold plus 15 seconds for event polling). Retrying a
131
+ message send reuses its logical delivery idempotency key.
132
+
133
+ ## v1 scope
134
+
135
+ Direct conversations only. Inbound text renders as-is; inbound media and
136
+ voice memos render as a labeled fetchable capability URL (the URL is itself
137
+ the authorization, so no Agent Token is needed to fetch the bytes) rather
138
+ than the agent seeing the file inline; reactions are observe-only; receipts
139
+ (`message.delivered`/`message.read`) never start a turn. Final agent replies
140
+ are delivered durably (chunked to Relay's 8 KiB per-part cap,
141
+ idempotency-keyed, retry-safe).
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // Relay channel plugin entrypoint registers the OpenClaw integration.
2
+ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core";
3
+ import { relayChannelPlugin } from "./src/channel.js";
4
+ import { setRelayRuntime } from "./src/runtime.js";
5
+ export default defineChannelPluginEntry({
6
+ id: "relay",
7
+ name: "Relay",
8
+ description: "Relay channel plugin. Text your OpenClaw like a friend.",
9
+ plugin: relayChannelPlugin,
10
+ setRuntime: setRelayRuntime,
11
+ });
@@ -0,0 +1,5 @@
1
+ // Lightweight setup entry: loaded instead of the full entry while the channel
2
+ // is disabled/unconfigured, so status/config surfaces avoid runtime imports.
3
+ import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core";
4
+ import { relayChannelPlugin } from "./src/channel.js";
5
+ export default defineSetupPluginEntry(relayChannelPlugin);
@@ -0,0 +1,91 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ function processIsLive(pid) {
6
+ if (!Number.isSafeInteger(pid) || pid <= 0)
7
+ return false;
8
+ try {
9
+ process.kill(pid, 0);
10
+ return true;
11
+ }
12
+ catch (error) {
13
+ return error?.code === "EPERM";
14
+ }
15
+ }
16
+ function readOwner(path) {
17
+ try {
18
+ const value = JSON.parse(readFileSync(path, "utf8"));
19
+ if (Number.isSafeInteger(value.pid) &&
20
+ typeof value.nonce === "string" &&
21
+ typeof value.account_id === "string" &&
22
+ typeof value.created_at === "string") {
23
+ return value;
24
+ }
25
+ }
26
+ catch {
27
+ // Missing/malformed ownership is never deleted in place by a contender.
28
+ }
29
+ return undefined;
30
+ }
31
+ /** Atomic filesystem lease preventing two OpenClaw processes polling one agent. */
32
+ export class RelayAccountLock {
33
+ accountId;
34
+ lockPath;
35
+ ownerPath;
36
+ nonce = randomUUID();
37
+ held = false;
38
+ constructor(baseUrl, agentId, accountId, baseDir = join(homedir(), ".openclaw", "relay", "consumer-locks")) {
39
+ this.accountId = accountId;
40
+ const key = createHash("sha256").update(`${baseUrl}\0${agentId}`).digest("hex");
41
+ this.lockPath = join(baseDir, key);
42
+ this.ownerPath = join(this.lockPath, "owner.json");
43
+ }
44
+ acquire() {
45
+ mkdirSync(join(this.lockPath, ".."), { recursive: true, mode: 0o700 });
46
+ for (let attempt = 0; attempt < 2; attempt += 1) {
47
+ try {
48
+ mkdirSync(this.lockPath, { mode: 0o700 });
49
+ const owner = {
50
+ pid: process.pid,
51
+ nonce: this.nonce,
52
+ account_id: this.accountId,
53
+ created_at: new Date().toISOString(),
54
+ };
55
+ writeFileSync(this.ownerPath, `${JSON.stringify(owner)}\n`, { mode: 0o600 });
56
+ this.held = true;
57
+ return;
58
+ }
59
+ catch (error) {
60
+ if (error?.code !== "EEXIST")
61
+ throw error;
62
+ const owner = readOwner(this.ownerPath);
63
+ if (!owner || processIsLive(owner.pid)) {
64
+ const claimant = owner
65
+ ? `account "${owner.account_id}" (pid ${owner.pid})`
66
+ : "an existing process with unreadable ownership";
67
+ throw new Error(`relay: this agent already has an active consumer in ${claimant}`);
68
+ }
69
+ const stalePath = `${this.lockPath}.stale-${Date.now()}-${randomUUID()}`;
70
+ try {
71
+ renameSync(this.lockPath, stalePath);
72
+ rmSync(stalePath, { recursive: true, force: true });
73
+ }
74
+ catch (renameError) {
75
+ if (renameError?.code !== "ENOENT")
76
+ throw renameError;
77
+ }
78
+ }
79
+ }
80
+ throw new Error("relay: could not acquire the agent consumer lock");
81
+ }
82
+ release() {
83
+ if (!this.held)
84
+ return;
85
+ const owner = readOwner(this.ownerPath);
86
+ if (owner?.nonce === this.nonce && existsSync(this.lockPath)) {
87
+ rmSync(this.lockPath, { recursive: true, force: true });
88
+ }
89
+ this.held = false;
90
+ }
91
+ }
@@ -0,0 +1,67 @@
1
+ // Multi-account resolution: channels.relay.accounts.<id> with a
2
+ // default-account fallback, so one OpenClaw can back several Relay contacts
3
+ // (one Agent Token each). Env vars cover the single-account quickstart.
4
+ import { createAccountListHelpers, resolveMergedAccountConfig, } from "openclaw/plugin-sdk/account-helpers";
5
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
6
+ import { tryReadSecretFileSync } from "openclaw/plugin-sdk/channel-core";
7
+ import { DEFAULT_RELAY_BASE_URL, normalizeRelayBaseUrl } from "./client.js";
8
+ export const RELAY_TOKEN_ENV_VAR = "RELAY_AGENT_TOKEN";
9
+ export const RELAY_BASE_URL_ENV_VAR = "RELAY_BASE_URL";
10
+ const DEFAULT_POLL_TIMEOUT_SECONDS = 30;
11
+ const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("relay", {
12
+ normalizeAccountId,
13
+ implicitDefaultAccount: {
14
+ channelKeys: ["token", "tokenFile"],
15
+ envVars: [RELAY_TOKEN_ENV_VAR],
16
+ },
17
+ });
18
+ export { listAccountIds as listRelayAccountIds, resolveDefaultAccountId as resolveDefaultRelayAccountId, DEFAULT_ACCOUNT_ID, };
19
+ function resolveMergedRelayAccountConfig(cfg, accountId) {
20
+ return resolveMergedAccountConfig({
21
+ channelConfig: cfg.channels?.relay,
22
+ accounts: cfg.channels?.relay?.accounts,
23
+ accountId,
24
+ omitKeys: ["defaultAccount"],
25
+ normalizeAccountId,
26
+ });
27
+ }
28
+ function resolveToken(params) {
29
+ const direct = params.merged.token?.trim();
30
+ if (direct) {
31
+ return direct;
32
+ }
33
+ const fromFile = params.merged.tokenFile
34
+ ? tryReadSecretFileSync(params.merged.tokenFile, "relay tokenFile")?.trim()
35
+ : undefined;
36
+ if (fromFile) {
37
+ return fromFile;
38
+ }
39
+ // Env token applies to the default account only, so named accounts cannot
40
+ // silently share one token.
41
+ if (params.accountId === DEFAULT_ACCOUNT_ID) {
42
+ return params.env[RELAY_TOKEN_ENV_VAR]?.trim() ?? "";
43
+ }
44
+ return "";
45
+ }
46
+ export function resolveRelayAccount(params) {
47
+ const env = params.env ?? process.env;
48
+ const accountId = normalizeAccountId(params.accountId);
49
+ const merged = resolveMergedRelayAccountConfig(params.cfg, accountId);
50
+ const baseEnabled = params.cfg.channels?.relay?.enabled !== false;
51
+ const enabled = baseEnabled && merged.enabled !== false;
52
+ const token = resolveToken({ merged, accountId, env });
53
+ const baseUrl = normalizeRelayBaseUrl(merged.baseUrl?.trim() ||
54
+ (accountId === DEFAULT_ACCOUNT_ID ? env[RELAY_BASE_URL_ENV_VAR]?.trim() : undefined) ||
55
+ DEFAULT_RELAY_BASE_URL);
56
+ const pollTimeoutSeconds = Math.min(Math.max(merged.pollTimeoutSeconds ?? DEFAULT_POLL_TIMEOUT_SECONDS, 1), 30);
57
+ return {
58
+ accountId,
59
+ enabled,
60
+ configured: Boolean(token),
61
+ ...(merged.name?.trim() ? { name: merged.name.trim() } : {}),
62
+ token,
63
+ baseUrl,
64
+ pollTimeoutSeconds,
65
+ config: merged,
66
+ };
67
+ }