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.
Files changed (3) hide show
  1. package/README.md +113 -0
  2. package/index.js +271 -0
  3. 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
+ }