@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 +30 -0
- package/dist/index.js +20 -0
- package/dist/openclaw-plugin.js +72 -0
- package/dist/relay-client.js +218 -0
- package/openclaw.plugin.json +22 -0
- package/package.json +46 -0
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
|
+
}
|