@iml1s/claw-link 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,30 @@
1
+ # @openclaw/claw-link
2
+
3
+ Cloud relay bridge plugin for [OpenClaw](https://github.com/anthropics/openclaw) — connects mobile devices to local gateways via a WebSocket relay server.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ openclaw plugins install @openclaw/claw-link
9
+ ```
10
+
11
+ ## How it works
12
+
13
+ 1. Plugin registers with the cloud relay server on gateway startup
14
+ 2. A QR code / connection string is printed in the terminal
15
+ 3. Scan the QR code with the OpenClaw Dashboard mobile app
16
+ 4. Mobile app connects through the relay to your local gateway
17
+
18
+ ## Configuration
19
+
20
+ **Zero configuration required** — the plugin connects to the default cloud relay automatically.
21
+
22
+ To use a custom relay server, set the environment variable:
23
+
24
+ ```bash
25
+ CLAW_LINK_RELAY_URL=wss://your-relay.example.com/register openclaw gateway
26
+ ```
27
+
28
+ ## License
29
+
30
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.start = start;
4
+ exports.stop = stop;
5
+ const relay_client_js_1 = require("./relay-client.js");
6
+ let client = null;
7
+ function start() {
8
+ console.log('Claw Link Plugin started');
9
+ // Uses production Cloud Relay by default; override with CLAW_LINK_RELAY_URL env var for local dev.
10
+ const gatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN;
11
+ client = new relay_client_js_1.RelayClient(undefined, gatewayToken);
12
+ client.connect();
13
+ }
14
+ function stop() {
15
+ console.log('Claw Link Plugin stopped');
16
+ if (client) {
17
+ client.disconnect();
18
+ client = null;
19
+ }
20
+ }
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * OpenClaw Plugin Adapter for Claw Link
5
+ *
6
+ * This module exports the OpenClaw plugin definition that integrates
7
+ * the Claw Link relay service into the OpenClaw gateway lifecycle.
8
+ * When the gateway starts, the plugin registers as a service that
9
+ * connects to the Cloud Relay server and bridges traffic to the
10
+ * local gateway.
11
+ */
12
+ const relay_client_js_1 = require("./relay-client.js");
13
+ let client = null;
14
+ const plugin = {
15
+ id: 'claw-link',
16
+ name: 'Claw Link',
17
+ description: 'Cloud relay bridge \u2014 connects mobile devices to the local gateway via a relay server.',
18
+ configSchema: {
19
+ safeParse: (value) => ({ success: true, data: value }),
20
+ parse: (value) => value,
21
+ uiHints: {
22
+ relayUrl: {
23
+ label: 'Relay Server URL',
24
+ description: 'WebSocket URL of the Cloud Relay server (e.g. ws://relay.example.com:8080)',
25
+ type: 'string',
26
+ },
27
+ },
28
+ },
29
+ register(api) {
30
+ const logger = api.logger ?? console;
31
+ api.registerService({
32
+ id: 'claw-link',
33
+ start(_ctx) {
34
+ const relayUrl = api.pluginConfig?.relayUrl ??
35
+ process.env.CLAW_LINK_RELAY_URL ??
36
+ undefined;
37
+ // Try multiple sources for the gateway token:
38
+ // 1. OpenClaw plugin API (may not expose it)
39
+ // 2. Environment variable
40
+ // 3. Read from config file directly
41
+ let gatewayToken = api.config?.get?.('gateway.auth.token') ??
42
+ process.env.OPENCLAW_GATEWAY_TOKEN ??
43
+ undefined;
44
+ if (!gatewayToken) {
45
+ try {
46
+ const os = require('os');
47
+ const path = require('path');
48
+ const fs = require('fs');
49
+ const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json');
50
+ const raw = fs.readFileSync(configPath, 'utf-8');
51
+ const config = JSON.parse(raw);
52
+ gatewayToken = config?.gateway?.auth?.token ?? undefined;
53
+ }
54
+ catch {
55
+ // Config file not readable — token will be omitted from QR code
56
+ }
57
+ }
58
+ logger.info('[claw-link] Starting Cloud Relay bridge...');
59
+ client = new relay_client_js_1.RelayClient(relayUrl, gatewayToken);
60
+ client.connect();
61
+ },
62
+ stop(_ctx) {
63
+ logger.info('[claw-link] Stopping Cloud Relay bridge...');
64
+ if (client) {
65
+ client.disconnect();
66
+ client = null;
67
+ }
68
+ },
69
+ });
70
+ },
71
+ };
72
+ exports.default = plugin;
@@ -0,0 +1,218 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.RelayClient = void 0;
7
+ exports.buildRegisterUrl = buildRegisterUrl;
8
+ exports.buildJoinUrlFromRegister = buildJoinUrlFromRegister;
9
+ exports.buildConnectionString = buildConnectionString;
10
+ const ws_1 = __importDefault(require("ws"));
11
+ const qrcode_terminal_1 = require("qrcode-terminal");
12
+ const node_crypto_1 = require("node:crypto");
13
+ function buildRegisterUrl(relayUrl, sessionId) {
14
+ const registerUrl = new URL(relayUrl);
15
+ registerUrl.searchParams.set('session_id', sessionId);
16
+ return registerUrl.toString();
17
+ }
18
+ function buildJoinUrlFromRegister(relayUrl, sessionId) {
19
+ const joinUrl = new URL(relayUrl);
20
+ const path = joinUrl.pathname;
21
+ if (path.endsWith('/register')) {
22
+ joinUrl.pathname = path.substring(0, path.length - '/register'.length) + '/join';
23
+ }
24
+ else if (path.endsWith('/register/')) {
25
+ joinUrl.pathname =
26
+ path.substring(0, path.length - '/register/'.length) + '/join';
27
+ }
28
+ else if (!path.endsWith('/join')) {
29
+ joinUrl.pathname = path.endsWith('/') ? `${path}join` : `${path}/join`;
30
+ }
31
+ joinUrl.searchParams.set('session_id', sessionId);
32
+ return joinUrl.toString();
33
+ }
34
+ function buildConnectionString(relayUrl, sessionId, gatewayToken) {
35
+ const joinUrl = buildJoinUrlFromRegister(relayUrl, sessionId);
36
+ const outer = new URL('claw://connect');
37
+ outer.searchParams.set('relay', joinUrl.toString());
38
+ if (gatewayToken != null && gatewayToken.length > 0) {
39
+ outer.searchParams.set('gateway_token', gatewayToken);
40
+ }
41
+ return outer.toString();
42
+ }
43
+ class RelayClient {
44
+ static localQueueLimit = 64;
45
+ relayWs = null;
46
+ localWs = null;
47
+ sessionId;
48
+ pendingToLocal = [];
49
+ relayUrl;
50
+ localGatewayUrl = 'ws://localhost:18789';
51
+ gatewayToken;
52
+ isDisconnecting = false;
53
+ hasPrintedQrCode = false;
54
+ constructor(relayUrl = 'wss://claw-cloud-relay-374931447815.asia-east1.run.app/register', gatewayToken) {
55
+ this.sessionId = (0, node_crypto_1.randomUUID)();
56
+ this.relayUrl = relayUrl;
57
+ this.gatewayToken = gatewayToken;
58
+ }
59
+ connect() {
60
+ this.isDisconnecting = false;
61
+ console.log(`[RelayClient] Connecting to Relay Server at ${this.relayUrl}...`);
62
+ // Append session_id using URL API to avoid malformed query strings.
63
+ this.relayWs = new ws_1.default(buildRegisterUrl(this.relayUrl, this.sessionId));
64
+ this.relayWs.on('open', () => {
65
+ console.log('[RelayClient] Connected to Relay Server.');
66
+ if (!this.hasPrintedQrCode) {
67
+ this.displayQrCode();
68
+ this.hasPrintedQrCode = true;
69
+ }
70
+ this.ensureLocalGatewayConnected();
71
+ });
72
+ this.relayWs.on('message', (data, isBinary) => {
73
+ if (isBinary) {
74
+ // Binary data: forward to local gateway preserving binary type
75
+ this.forwardToLocal(data, true);
76
+ }
77
+ else {
78
+ // Text data could be control messages or text traffic
79
+ const message = data.toString();
80
+ try {
81
+ const parsed = JSON.parse(message);
82
+ if (parsed.type === 'pipe_start') {
83
+ this.ensureLocalGatewayConnected();
84
+ }
85
+ else {
86
+ this.forwardToLocal(data, false);
87
+ }
88
+ }
89
+ catch (e) {
90
+ // Not JSON, treat as data
91
+ this.forwardToLocal(data, false);
92
+ }
93
+ }
94
+ });
95
+ this.relayWs.on('close', () => {
96
+ console.log('[RelayClient] Disconnected from Relay Server.');
97
+ this.cleanup();
98
+ if (!this.isDisconnecting) {
99
+ console.log('[RelayClient] Connection lost. Attempting to reconnect in 1 second...');
100
+ setTimeout(() => {
101
+ this.connect();
102
+ }, 1000);
103
+ }
104
+ });
105
+ this.relayWs.on('error', (error) => {
106
+ console.error('[RelayClient] Relay WebSocket error:', error.message);
107
+ });
108
+ }
109
+ ensureLocalGatewayConnected() {
110
+ if (this.localWs) {
111
+ if (this.localWs.readyState === ws_1.default.OPEN ||
112
+ this.localWs.readyState === ws_1.default.CONNECTING) {
113
+ return;
114
+ }
115
+ this.localWs = null;
116
+ }
117
+ console.log('[RelayClient] Connecting to Local Gateway for byte forwarding...');
118
+ this.localWs = new ws_1.default(this.localGatewayUrl);
119
+ this.localWs.on('open', () => {
120
+ console.log(`[RelayClient] Connected to Local Gateway at ${this.localGatewayUrl}`);
121
+ this.flushPendingToLocal();
122
+ });
123
+ this.localWs.on('message', (data, isBinary) => {
124
+ // Forward data from Local Gateway to Relay, preserving text/binary type
125
+ if (this.relayWs && this.relayWs.readyState === ws_1.default.OPEN) {
126
+ // CRITICAL: preserve the message type (text vs binary)
127
+ // ws library sends Buffer as binary by default, which Flutter drops
128
+ this.relayWs.send(data, { binary: isBinary });
129
+ }
130
+ });
131
+ this.localWs.on('close', () => {
132
+ console.log('[RelayClient] Local Gateway disconnected.');
133
+ this.localWs = null;
134
+ // Keep relay socket alive; reconnect local gateway lazily when data arrives.
135
+ });
136
+ this.localWs.on('error', (error) => {
137
+ console.error('[RelayClient] Local Gateway WebSocket error:', error.message);
138
+ // Reset to allow retry on next event.
139
+ this.localWs = null;
140
+ });
141
+ }
142
+ forwardToLocal(data, isBinary = false) {
143
+ // Recover from protocol mismatch: server currently behaves as dumb byte pipe
144
+ // and may not send control messages like `pipe_start`.
145
+ this.ensureLocalGatewayConnected();
146
+ if (this.localWs && this.localWs.readyState === ws_1.default.OPEN) {
147
+ // CRITICAL: preserve text/binary type so Gateway processes correctly
148
+ this.localWs.send(data, { binary: isBinary });
149
+ return;
150
+ }
151
+ // Queue frames while local gateway is connecting.
152
+ const frame = this.toBuffer(data);
153
+ if (this.pendingToLocal.length >= RelayClient.localQueueLimit) {
154
+ this.pendingToLocal.shift();
155
+ }
156
+ this.pendingToLocal.push({ data: frame, isBinary });
157
+ }
158
+ flushPendingToLocal() {
159
+ if (!this.localWs || this.localWs.readyState !== ws_1.default.OPEN) {
160
+ return;
161
+ }
162
+ while (this.pendingToLocal.length > 0) {
163
+ const item = this.pendingToLocal.shift();
164
+ if (item == null) {
165
+ continue;
166
+ }
167
+ // Preserve text/binary type from original message
168
+ this.localWs.send(item.data, { binary: item.isBinary });
169
+ }
170
+ }
171
+ toBuffer(data) {
172
+ if (Buffer.isBuffer(data)) {
173
+ return data;
174
+ }
175
+ if (data instanceof ArrayBuffer) {
176
+ return Buffer.from(data);
177
+ }
178
+ if (Array.isArray(data)) {
179
+ return Buffer.concat(data);
180
+ }
181
+ if (typeof data === 'string') {
182
+ return Buffer.from(data);
183
+ }
184
+ return Buffer.alloc(0);
185
+ }
186
+ displayQrCode() {
187
+ const connectionString = buildConnectionString(this.relayUrl, this.sessionId, this.gatewayToken);
188
+ console.log('\nScan this QR Code with your mobile app to connect:\n');
189
+ try {
190
+ // For very long URLs, qrcode-terminal can crash with { small: true }
191
+ (0, qrcode_terminal_1.generate)(connectionString);
192
+ }
193
+ catch (e) {
194
+ console.log('(Terminal too small or string too long to render QR code)');
195
+ }
196
+ console.log(`\nConnection String: ${connectionString}\n`);
197
+ }
198
+ disconnect() {
199
+ this.isDisconnecting = true;
200
+ this.cleanup();
201
+ }
202
+ cleanup() {
203
+ if (this.relayWs) {
204
+ // Remove generic 'close' listener so we don't trigger reconnect logic
205
+ // during a manual disconnect/cleanup process. Note: We only do this
206
+ // if `isDisconnecting` is true, otherwise we let the standard `on('close')`
207
+ // handle the reconnect. Actually, we shouldn't remove it here, the
208
+ // 'close' handler already checks `this.isDisconnecting`.
209
+ this.relayWs.close();
210
+ this.relayWs = null;
211
+ }
212
+ if (this.localWs) {
213
+ this.localWs.close();
214
+ this.localWs = null;
215
+ }
216
+ }
217
+ }
218
+ exports.RelayClient = RelayClient;
@@ -0,0 +1,22 @@
1
+ {
2
+ "id": "claw-link",
3
+ "name": "Claw Link",
4
+ "version": "0.1.0",
5
+ "description": "Cloud relay bridge — connects mobile devices to the local gateway via a relay server.",
6
+ "configSchema": {
7
+ "type": "object",
8
+ "properties": {
9
+ "relayUrl": {
10
+ "type": "string",
11
+ "description": "WebSocket URL of the Cloud Relay server"
12
+ }
13
+ }
14
+ },
15
+ "uiHints": {
16
+ "relayUrl": {
17
+ "label": "Relay Server URL",
18
+ "description": "WebSocket URL of the Cloud Relay server (e.g. ws://relay.example.com:8080)",
19
+ "type": "string"
20
+ }
21
+ }
22
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@iml1s/claw-link",
3
+ "version": "0.1.0",
4
+ "description": "Cloud relay bridge plugin for OpenClaw — connects mobile devices to local gateways via a relay server.",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist/**/*.js",
9
+ "!dist/**/*.test.js",
10
+ "openclaw.plugin.json"
11
+ ],
12
+ "keywords": [
13
+ "openclaw",
14
+ "plugin",
15
+ "relay",
16
+ "websocket",
17
+ "mobile"
18
+ ],
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/anthropics/openclaw-dashboard",
23
+ "directory": "plugins/claw-link"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc",
27
+ "watch": "tsc -w",
28
+ "test": "vitest run"
29
+ },
30
+ "dependencies": {
31
+ "qrcode-terminal": "^0.12.0",
32
+ "ws": "^8.16.0"
33
+ },
34
+ "openclaw": {
35
+ "extensions": [
36
+ "./dist/openclaw-plugin.js"
37
+ ]
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^20.0.0",
41
+ "@types/qrcode-terminal": "^0.12.2",
42
+ "@types/ws": "^8.5.10",
43
+ "typescript": "^5.0.0",
44
+ "vitest": "^2.1.8"
45
+ }
46
+ }