agent-hub-relay 1.0.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 +113 -0
- package/index.js +271 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# agent-hub-relay
|
|
2
|
+
|
|
3
|
+
A small Node.js relay that exposes your local **Craft Agents** and **OpenClaw Gateway** to your Tailscale network, so the [Agent Hub](../../) mobile app can auto-discover and connect from anywhere on your tailnet.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
| Port | Service | What it does |
|
|
8
|
+
|------|---------|--------------|
|
|
9
|
+
| `9100` | Craft Agents | Forwards WS traffic to the Craft Agents macOS app's local port |
|
|
10
|
+
| `18789` (or whatever OpenClaw uses) | OpenClaw Gateway | Forwards WS traffic to OpenClaw |
|
|
11
|
+
| `9999` | Config server | HTTP endpoint serving connection credentials (URL + token) for the mobile app |
|
|
12
|
+
|
|
13
|
+
All three are bound to your **Tailscale IP only** (not `0.0.0.0`), so they're reachable from your phone over the tailnet but never exposed to the public internet.
|
|
14
|
+
|
|
15
|
+
The relay auto-detects:
|
|
16
|
+
- Your Tailscale IP (via `tailscale ip -4`)
|
|
17
|
+
- Craft Agents port/token from `~/.craft-agent/config.json` (server mode) or `~/.craft-agent/rpc.json` (packaged app)
|
|
18
|
+
- OpenClaw port/credentials from `~/.openclaw/openclaw.json`
|
|
19
|
+
|
|
20
|
+
## Requirements
|
|
21
|
+
|
|
22
|
+
- **Node.js ≥ 18** (uses native `net`, `http`, `child_process` — no dependencies)
|
|
23
|
+
- **Tailscale** running on the host
|
|
24
|
+
- One or both of: Craft Agents (running) or OpenClaw (configured)
|
|
25
|
+
|
|
26
|
+
## Install & run
|
|
27
|
+
|
|
28
|
+
### Option A — npx (no install)
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx agent-hub-relay
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Option B — Global install from this folder
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cd packages/relay
|
|
38
|
+
npm link # makes `agent-hub-relay` available globally
|
|
39
|
+
agent-hub-relay
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Option C — Direct invocation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
node packages/relay/index.js
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Output
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
agent-hub-relay
|
|
52
|
+
───────────────────────────────────────────────
|
|
53
|
+
✓ Craft Agents ws://100.64.0.2:9100 → 127.0.0.1:53124
|
|
54
|
+
token rpc_abc123...
|
|
55
|
+
✓ OpenClaw ws://100.64.0.2:18789 → 127.0.0.1:18789
|
|
56
|
+
✓ Config server http://100.64.0.2:9999/config
|
|
57
|
+
───────────────────────────────────────────────
|
|
58
|
+
Mobile app → tap "Auto-detect from Mac"
|
|
59
|
+
Tailscale IP → 100.64.0.2
|
|
60
|
+
───────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
Press Ctrl+C to stop.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Usage with the Agent Hub mobile app
|
|
66
|
+
|
|
67
|
+
1. Run `agent-hub-relay` on your Mac
|
|
68
|
+
2. Open Agent Hub on your phone
|
|
69
|
+
3. Tap **"Auto-detect from Mac"** (or enter your Tailscale IP manually)
|
|
70
|
+
4. The app fetches `http://<tailscale-ip>:9999/config` and populates both profiles
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
The relay reads from fixed paths in `$HOME`:
|
|
75
|
+
|
|
76
|
+
| File | Purpose |
|
|
77
|
+
|------|---------|
|
|
78
|
+
| `~/.craft-agent/config.json` | Craft Agents server mode (source-built) — preferred when present |
|
|
79
|
+
| `~/.craft-agent/rpc.json` | Craft Agents packaged app — fallback |
|
|
80
|
+
| `~/.openclaw/openclaw.json` | OpenClaw Gateway config |
|
|
81
|
+
|
|
82
|
+
If a config file is missing, the corresponding relay is skipped (not fatal — OpenClaw and Craft Agents are both optional).
|
|
83
|
+
|
|
84
|
+
## Standalone manual relays
|
|
85
|
+
|
|
86
|
+
For quick manual fallbacks, see [`../../scripts/`](../../scripts/):
|
|
87
|
+
|
|
88
|
+
- `tailscale-relay.sh` — socat-based Craft Agents relay (no Node required)
|
|
89
|
+
- `openclaw-relay.sh` — socat-based OpenClaw relay (no Node required)
|
|
90
|
+
|
|
91
|
+
These are kept for environments where Node isn't available, but the npm package is the recommended approach.
|
|
92
|
+
|
|
93
|
+
## Publishing to npm
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# 1. Make sure you're logged in
|
|
97
|
+
npm login
|
|
98
|
+
|
|
99
|
+
# 2. From this folder:
|
|
100
|
+
npm publish --dry-run # preview what gets published
|
|
101
|
+
npm publish # actually publish (becomes `npx agent-hub-relay`)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The `package.json` declares:
|
|
105
|
+
- `name`: `agent-hub-relay`
|
|
106
|
+
- `bin`: maps `agent-hub-relay` → `./index.js`
|
|
107
|
+
- `files`: only ships `index.js` (no extraneous files)
|
|
108
|
+
- `engines.node`: `>=18`
|
|
109
|
+
- Zero runtime dependencies
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* agent-hub-relay
|
|
4
|
+
*
|
|
5
|
+
* Relays Craft Agents + OpenClaw to your Tailscale IP so the Agent Hub
|
|
6
|
+
* mobile app can auto-detect and connect from anywhere on your tailnet.
|
|
7
|
+
*
|
|
8
|
+
* Usage: npx agent-hub-relay
|
|
9
|
+
* bunx agent-hub-relay
|
|
10
|
+
* pnpm dlx agent-hub-relay
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import net from 'net';
|
|
14
|
+
import http from 'http';
|
|
15
|
+
import { execSync } from 'child_process';
|
|
16
|
+
import { readFileSync, existsSync } from 'fs';
|
|
17
|
+
import { homedir } from 'os';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
|
|
20
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function getTailscaleIp() {
|
|
23
|
+
const cmds = [
|
|
24
|
+
'tailscale ip -4',
|
|
25
|
+
'/Applications/Tailscale.app/Contents/MacOS/Tailscale ip -4',
|
|
26
|
+
'/usr/local/bin/tailscale ip -4',
|
|
27
|
+
];
|
|
28
|
+
for (const cmd of cmds) {
|
|
29
|
+
try {
|
|
30
|
+
const ip = execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
31
|
+
.toString()
|
|
32
|
+
.trim()
|
|
33
|
+
.split('\n')[0]
|
|
34
|
+
.trim();
|
|
35
|
+
if (ip && /^\d+\.\d+\.\d+\.\d+$/.test(ip)) return ip;
|
|
36
|
+
} catch {}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readJson(filePath) {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getCraftAgents() {
|
|
50
|
+
const configPath = join(homedir(), '.craft-agent', 'config.json');
|
|
51
|
+
const rpcPath = join(homedir(), '.craft-agent', 'rpc.json');
|
|
52
|
+
|
|
53
|
+
if (existsSync(configPath)) {
|
|
54
|
+
const cfg = readJson(configPath);
|
|
55
|
+
const sc = cfg?.serverConfig;
|
|
56
|
+
if (sc?.enabled && sc?.port) {
|
|
57
|
+
return { localPort: Number(sc.port), token: sc.token || '' };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (existsSync(rpcPath)) {
|
|
62
|
+
const rpc = readJson(rpcPath);
|
|
63
|
+
if (rpc?.url) {
|
|
64
|
+
const localPort = Number(rpc.url.split(':').pop());
|
|
65
|
+
return { localPort, token: rpc.token || '' };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getOpenclaw() {
|
|
73
|
+
const cfgPath = join(homedir(), '.openclaw', 'openclaw.json');
|
|
74
|
+
if (!existsSync(cfgPath)) return null;
|
|
75
|
+
|
|
76
|
+
const cfg = readJson(cfgPath);
|
|
77
|
+
const gw = cfg?.gateway || {};
|
|
78
|
+
const auth = gw.auth || {};
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
localPort: Number(gw.port) || 18789,
|
|
82
|
+
password: auth.password || '',
|
|
83
|
+
token: auth.token || '',
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── TCP relay ─────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function createRelay(bindIp, publicPort, localPort) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const server = net.createServer((client) => {
|
|
92
|
+
const upstream = net.connect(localPort, '127.0.0.1');
|
|
93
|
+
|
|
94
|
+
client.pipe(upstream);
|
|
95
|
+
upstream.pipe(client);
|
|
96
|
+
|
|
97
|
+
const cleanup = () => {
|
|
98
|
+
try { client.destroy(); } catch {}
|
|
99
|
+
try { upstream.destroy(); } catch {}
|
|
100
|
+
};
|
|
101
|
+
client.on('error', cleanup);
|
|
102
|
+
upstream.on('error', cleanup);
|
|
103
|
+
client.on('close', () => { try { upstream.destroy(); } catch {} });
|
|
104
|
+
upstream.on('close', () => { try { client.destroy(); } catch {} });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
server.on('error', reject);
|
|
108
|
+
server.listen(publicPort, bindIp, () => resolve(server));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Config HTTP server ────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
function createConfigServer(tailscaleIp, configPort, craftConfig, openclawConfig) {
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const craftPayload = craftConfig
|
|
117
|
+
? { url: `ws://${tailscaleIp}:9100`, token: craftConfig.token }
|
|
118
|
+
: { error: 'Craft Agents not found (~/.craft-agent/)' };
|
|
119
|
+
|
|
120
|
+
const openclawPayload = openclawConfig
|
|
121
|
+
? {
|
|
122
|
+
url: `ws://${tailscaleIp}:${openclawConfig.localPort}`,
|
|
123
|
+
password: openclawConfig.password,
|
|
124
|
+
token: openclawConfig.token,
|
|
125
|
+
}
|
|
126
|
+
: { error: 'OpenClaw not found (~/.openclaw/)' };
|
|
127
|
+
|
|
128
|
+
const body = JSON.stringify({
|
|
129
|
+
craftAgents: craftPayload,
|
|
130
|
+
openclaw: openclawPayload,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const server = http.createServer((req, res) => {
|
|
134
|
+
if (req.method === 'OPTIONS') {
|
|
135
|
+
res.writeHead(204, { 'Access-Control-Allow-Origin': '*' });
|
|
136
|
+
res.end();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (req.url !== '/config') {
|
|
140
|
+
res.writeHead(404);
|
|
141
|
+
res.end();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
res.writeHead(200, {
|
|
145
|
+
'Content-Type': 'application/json',
|
|
146
|
+
'Content-Length': Buffer.byteLength(body),
|
|
147
|
+
'Access-Control-Allow-Origin': '*',
|
|
148
|
+
});
|
|
149
|
+
res.end(body);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
server.on('error', reject);
|
|
153
|
+
server.listen(configPort, '0.0.0.0', () => resolve(server));
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Kill anything already using a port ───────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
function freePort(port) {
|
|
160
|
+
try {
|
|
161
|
+
const pids = execSync(`lsof -ti:${port} 2>/dev/null`, { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
162
|
+
.toString()
|
|
163
|
+
.trim();
|
|
164
|
+
if (pids) {
|
|
165
|
+
execSync(`kill ${pids.split('\n').join(' ')} 2>/dev/null || true`, { stdio: 'ignore' });
|
|
166
|
+
}
|
|
167
|
+
} catch {}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
const CONFIG_PORT = 9999;
|
|
173
|
+
const CRAFT_PORT = 9100;
|
|
174
|
+
|
|
175
|
+
const servers = [];
|
|
176
|
+
|
|
177
|
+
process.on('SIGINT', shutdown);
|
|
178
|
+
process.on('SIGTERM', shutdown);
|
|
179
|
+
|
|
180
|
+
function shutdown() {
|
|
181
|
+
process.stdout.write('\n\nStopping relays...\n');
|
|
182
|
+
for (const s of servers) {
|
|
183
|
+
try { s.close(); } catch {}
|
|
184
|
+
}
|
|
185
|
+
process.exit(0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function main() {
|
|
189
|
+
// ── Tailscale ──
|
|
190
|
+
const tailscaleIp = getTailscaleIp();
|
|
191
|
+
if (!tailscaleIp) {
|
|
192
|
+
console.error('ERROR: Could not get Tailscale IP. Is Tailscale running?');
|
|
193
|
+
console.error(' Install: https://tailscale.com/download');
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Read service configs ──
|
|
198
|
+
const craftConfig = getCraftAgents();
|
|
199
|
+
const openclawConfig = getOpenclaw();
|
|
200
|
+
|
|
201
|
+
if (!craftConfig && !openclawConfig) {
|
|
202
|
+
console.error('ERROR: No services found.');
|
|
203
|
+
console.error(' Expected: ~/.craft-agent/config.json or ~/.craft-agent/rpc.json');
|
|
204
|
+
console.error(' ~/.openclaw/openclaw.json');
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
console.log('');
|
|
209
|
+
console.log(' agent-hub-relay');
|
|
210
|
+
console.log(' ───────────────────────────────────────────────');
|
|
211
|
+
|
|
212
|
+
// ── Craft Agents relay ──
|
|
213
|
+
if (craftConfig) {
|
|
214
|
+
freePort(CRAFT_PORT);
|
|
215
|
+
try {
|
|
216
|
+
const srv = await createRelay(tailscaleIp, CRAFT_PORT, craftConfig.localPort);
|
|
217
|
+
servers.push(srv);
|
|
218
|
+
console.log(` ✓ Craft Agents ws://${tailscaleIp}:${CRAFT_PORT} → 127.0.0.1:${craftConfig.localPort}`);
|
|
219
|
+
if (craftConfig.token) {
|
|
220
|
+
console.log(` token ${craftConfig.token}`);
|
|
221
|
+
}
|
|
222
|
+
} catch (err) {
|
|
223
|
+
console.warn(` ✗ Craft Agents ${err.message}`);
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
console.log(' – Craft Agents not found (skipped)');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── OpenClaw relay ──
|
|
230
|
+
if (openclawConfig) {
|
|
231
|
+
const ocPort = openclawConfig.localPort;
|
|
232
|
+
freePort(ocPort);
|
|
233
|
+
try {
|
|
234
|
+
const srv = await createRelay(tailscaleIp, ocPort, ocPort);
|
|
235
|
+
servers.push(srv);
|
|
236
|
+
console.log(` ✓ OpenClaw ws://${tailscaleIp}:${ocPort} → 127.0.0.1:${ocPort}`);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
console.warn(` ✗ OpenClaw ${err.message}`);
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
console.log(' – OpenClaw not found (skipped)');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Config server ──
|
|
245
|
+
freePort(CONFIG_PORT);
|
|
246
|
+
try {
|
|
247
|
+
const srv = await createConfigServer(tailscaleIp, CONFIG_PORT, craftConfig, openclawConfig);
|
|
248
|
+
servers.push(srv);
|
|
249
|
+
console.log(` ✓ Config server http://${tailscaleIp}:${CONFIG_PORT}/config`);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
console.warn(` ✗ Config server ${err.message}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
console.log(' ───────────────────────────────────────────────');
|
|
255
|
+
console.log(' Mobile app → tap "Auto-detect from Mac"');
|
|
256
|
+
console.log(` Tailscale IP → ${tailscaleIp}`);
|
|
257
|
+
console.log(' ───────────────────────────────────────────────');
|
|
258
|
+
console.log('');
|
|
259
|
+
console.log(' Press Ctrl+C to stop.');
|
|
260
|
+
console.log('');
|
|
261
|
+
|
|
262
|
+
if (servers.length === 0) {
|
|
263
|
+
console.error(' No relays started — exiting.');
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
main().catch((err) => {
|
|
269
|
+
console.error('Fatal:', err.message);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-hub-relay",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Relay Craft Agents + OpenClaw to your Tailscale IP for the Agent Hub mobile app",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": "index.js",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=18"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"tailscale",
|
|
12
|
+
"relay",
|
|
13
|
+
"craft-agents",
|
|
14
|
+
"openclaw",
|
|
15
|
+
"agent-hub"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/ChrisKalathas/agent-hub#readme",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/ChrisKalathas/agent-hub.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/ChrisKalathas/agent-hub/issues"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"files": [
|
|
27
|
+
"index.js",
|
|
28
|
+
"README.md"
|
|
29
|
+
]
|
|
30
|
+
}
|