@claw-link/gateway-host 0.3.2 → 0.3.4

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/native/index.d.ts CHANGED
@@ -34,20 +34,35 @@ export declare function assemble(parts: Array<Buffer>, expectedCid: string): Buf
34
34
  /** Native-core version (so the host can log which acceleration tier is active). */
35
35
  export declare function coreVersion(): string
36
36
  /**
37
- * A QUIC peer-to-peer Capsule-byte transport (AHP H2H data plane).
37
+ * An **iroh** peer-to-peer Capsule-byte transport (AHP H2H data plane).
38
38
  *
39
39
  * Bytes are AEAD-encrypted, content-addressed (`b3_<blake3>`), stored locally, and fetched from a
40
- * peer's announced socket address over QUIC. See `transport.rs` for the full design + TLS posture.
40
+ * peer over iroh's mutually-authenticated, NAT-traversing QUIC (hole-punch + relay fallback). Peers
41
+ * are addressed by their full `EndpointAddr` (NodeId + relay + direct addrs), handed over your own
42
+ * signaling — NOT via public discovery. See `transport.rs` for the full security posture.
41
43
  */
42
44
  export declare class Transport {
43
- /** Create a transport: bind a QUIC endpoint on an OS-assigned port and start serving. */
44
- static create(): Promise<Transport>
45
- /** The socket address to announce to peers, e.g. "127.0.0.1:54321". */
46
- localAddr(): string
45
+ /**
46
+ * Build the iroh endpoint and start serving.
47
+ *
48
+ * * `secretKeyHex` — 32-byte Ed25519 secret key as hex (the node's persistent identity; its
49
+ * public key is the iroh NodeId). If omitted, a fresh key is generated — read it back via
50
+ * `secretKeyHex()` and persist it for a stable identity across restarts.
51
+ * * `relayUrl` — optional relay base URL for connectivity / hole-punch. The relay is UNTRUSTED
52
+ * (it only ever relays E2E-encrypted QUIC). Omit to use iroh's standard n0 relays.
53
+ */
54
+ static create(secretKeyHex?: string | undefined | null, relayUrl?: string | undefined | null): Promise<Transport>
55
+ /**
56
+ * This node's full reachability as a JSON string (NodeId + relay url + known direct socket
57
+ * addrs). Store it and hand it to a peer so it can dial us — no public discovery involved.
58
+ */
59
+ nodeAddr(): Promise<string>
60
+ /** The node's Ed25519 secret key as hex (so the host can persist a stable identity). */
61
+ secretKeyHex(): string
47
62
  /** AEAD-encrypt `bytes` with the 32-byte `key`, store the ciphertext, and return its cid. */
48
63
  add(bytes: Buffer, key: Buffer): string
49
- /** Fetch `cid` from `peerAddr` over QUIC, verify integrity, and AEAD-decrypt with `key`. */
50
- fetch(cid: string, peerAddr: string, key: Buffer): Promise<Buffer>
64
+ /** Fetch `cid` from `peerNodeAddr` over iroh, verify integrity, and AEAD-decrypt with `key`. */
65
+ fetch(cid: string, peerNodeAddr: string, key: Buffer): Promise<Buffer>
51
66
  /** Does the local store hold this cid? */
52
67
  has(cid: string): boolean
53
68
  /** Close the endpoint (stops serving; in-flight transfers drain). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claw-link/gateway-host",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "ClawLink Host Gateway — a secure, outbound-only worker that bridges a local agent CLI (OpenClaw, Hermes, Claude, Codex, Cursor) to your ClawLink agents. No inbound ports; authenticated per-agent by a Host Token.",
5
5
  "homepage": "https://claw-link.co",
6
6
  "bin": {
@@ -6,22 +6,26 @@ const crypto = require('crypto');
6
6
  const core = require('../src/transport/core');
7
7
 
8
8
  async function run() {
9
+ // The self-test transfers over loopback, which the SSRF address-filter blocks by default — allow
10
+ // LAN/loopback just for this in-process test (does not affect the running service).
11
+ process.env.CLAWHOST_ALLOW_LAN = process.env.CLAWHOST_ALLOW_LAN || '1';
9
12
  if (!core.available || !core.Transport) {
10
13
  console.log('Native H2H core is NOT installed for this platform.');
11
14
  console.log('The host works fine — Capsule bytes route through the central relay (no P2P speedup).');
12
15
  console.log('To enable acceleration, install the @claw-link/harness-core binary for your platform.');
13
16
  return;
14
17
  }
15
- console.log(`Native H2H core v${core.version} — running QUIC P2P self-test (loopback)…\n`);
18
+ console.log(`Native H2H core v${core.version} — running iroh QUIC P2P self-test (loopback)…\n`);
16
19
  const a = await core.Transport.create();
17
20
  const b = await core.Transport.create();
21
+ const aAddr = await a.nodeAddr();
18
22
  try {
19
23
  const key = crypto.randomBytes(32);
20
24
  for (const n of [4 * 1024, 1024 * 1024, 8 * 1024 * 1024]) {
21
25
  const data = crypto.randomBytes(n);
22
26
  const cid = a.add(data, key);
23
27
  const t0 = Date.now();
24
- const got = await b.fetch(cid, a.localAddr(), key);
28
+ const got = await b.fetch(cid, aAddr, key);
25
29
  const ms = Date.now() - t0;
26
30
  if (Buffer.compare(got, data) !== 0) throw new Error(`integrity mismatch at ${n} bytes`);
27
31
  const mib = (n / (1024 * 1024));
@@ -29,9 +33,9 @@ async function run() {
29
33
  }
30
34
  const cid = a.add(Buffer.from('secret'), key);
31
35
  let wrongKey = false;
32
- try { await b.fetch(cid, a.localAddr(), crypto.randomBytes(32)); } catch { wrongKey = true; }
36
+ try { await b.fetch(cid, aAddr, crypto.randomBytes(32)); } catch { wrongKey = true; }
33
37
  let unknown = false;
34
- try { await b.fetch('b3_0000', a.localAddr(), key); } catch { unknown = true; }
38
+ try { await b.fetch('b3_0000', aAddr, key); } catch { unknown = true; }
35
39
  console.log(`\n AEAD: wrong key rejected ${wrongKey ? 'OK' : 'FAIL'}`);
36
40
  console.log(` integrity: unknown cid rejected ${unknown ? 'OK' : 'FAIL'}`);
37
41
  if (!wrongKey || !unknown) throw new Error('security checks failed');
package/src/bridge.js CHANGED
@@ -46,7 +46,7 @@ class Bridge {
46
46
  capsuleGet(path) { return this._post('capsule_get', { path }); }
47
47
  // H2H peer discovery for the QUIC P2P transport (central relay is the fallback).
48
48
  transportAnnounce(cid, addr) { return this._post('transport_announce', { cid, addr }); }
49
- transportResolve(cid) { return this._post('transport_resolve', { cid }); }
49
+ transportResolve(cid, jobId) { return this._post('transport_resolve', { cid, job_id: jobId }); }
50
50
  transportSession(cid, mode, bytes) { return this._post('transport_session', { cid, mode, bytes }); }
51
51
  // DB floor for a transport ref: fetch the inline plaintext for a transport_cid this job may see.
52
52
  capsuleContent(cid, jobId) { return this._post('capsule_content', { cid, job_id: jobId }); }
@@ -1,84 +1,98 @@
1
1
  'use strict';
2
- // Host-side H2H transport orchestration: wraps the native QUIC Transport (when a prebuilt core is
3
- // present) with central-relay discovery + fallback. ACCELERATION, NEVER A DEPENDENCY if the core
4
- // is absent or a P2P fetch fails, callers fall back to host-bridge capsule_put/capsule_get.
2
+ // Host-side H2H transport orchestration over the native iroh core: secure, NAT-traversing QUIC.
3
+ // Peers are addressed by their iroh NodeAddr (NodeId = Ed25519 public key mutual auth, no MITM;
4
+ // hole-punch + relay fallback handle NAT). Discovery rides our authenticated host-bridge signaling
5
+ // (transport_announce/resolve), NOT iroh's public DNS. ACCELERATION, NEVER A DEPENDENCY — no core,
6
+ // or a fetch that can't connect, falls back to the central relay (the caller handles that).
5
7
  //
6
- // Flow:
7
- // producer: cid = await p2p.offer(bridge, plaintext, key) // encrypt+store locally, announce
8
- // consumer: buf = await p2p.fetch(bridge, cid, key) // resolve a peer + P2P fetch, else null
8
+ // producer: cid = await p2p.offer(bridge, plaintext, key) // store + announce our NodeAddr
9
+ // consumer: buf = await p2p.fetch(bridge, cid, key) // resolve peer NodeAddr + fetch, else null
9
10
  //
10
- // `key` is a 32-byte Buffer (the per-transfer AEAD key, derived from the capsule's authorization).
11
- const os = require('os');
11
+ // `key` is the 32-byte per-blob AEAD key (the authorization layer on top of the authenticated link).
12
+ const fs = require('fs');
13
+ const path = require('path');
12
14
  const core = require('./core');
15
+ const config = require('../config');
13
16
 
14
- // The QUIC socket binds 0.0.0.0 (all interfaces), but the native local_addr() reports 127.0.0.1 —
15
- // dialable only on THIS machine. For Mac↔Mac on a LAN we must announce a routable IPv4 instead.
16
- // (Cross-network/NAT hole-punching is a later rung; a peer that can't be reached falls back to relay.)
17
- function lanIpv4() {
18
- for (const list of Object.values(os.networkInterfaces())) {
19
- for (const i of list || []) {
20
- if (i && i.family === 'IPv4' && !i.internal) return i.address;
21
- }
22
- }
23
- return null;
24
- }
17
+ // Stable node identity (Ed25519 secret key) so a host keeps the same NodeId across restarts.
18
+ const KEY_PATH = path.join(config.HOME_DIR, 'transport.key');
25
19
 
26
20
  class P2P {
27
21
  constructor(logger) {
28
22
  this.logger = logger || { info() {}, warn() {} };
29
23
  this.node = null;
30
24
  this.starting = null;
31
- this.announceAddr = null; // routable addr we hand to peers (LAN IP : QUIC port)
25
+ this.addr = null; // our NodeAddr (JSON string) to hand to peers
32
26
  }
33
27
 
34
28
  available() {
35
29
  return !!(core.available && core.Transport);
36
30
  }
37
31
 
38
- /** Start the native QUIC node once (idempotent). Returns the routable announce addr, or null. */
32
+ _loadKey() {
33
+ try {
34
+ const st = fs.statSync(KEY_PATH);
35
+ // The node secret is impersonation-grade — tighten perms if a backup/umask left it loose.
36
+ if (st.mode & 0o077) { try { fs.chmodSync(KEY_PATH, 0o600); this.logger.warn('tightened transport.key permissions to 600'); } catch { /* best effort */ } }
37
+ const k = fs.readFileSync(KEY_PATH, 'utf8').trim();
38
+ if (/^[0-9a-f]{64}$/i.test(k)) return k;
39
+ } catch { /* none */ }
40
+ return null;
41
+ }
42
+ _saveKey(hex) {
43
+ try { if (config.ensureHomeDir) config.ensureHomeDir(); fs.writeFileSync(KEY_PATH, hex, { mode: 0o600 }); }
44
+ catch (e) { this.logger.warn('could not persist transport identity:', e.message); }
45
+ }
46
+
47
+ /** Start the iroh node once (idempotent). Returns our NodeAddr string, or null. */
39
48
  async start() {
40
49
  if (!this.available()) return null;
41
- if (this.announceAddr) return this.announceAddr;
50
+ if (this.addr) return this.addr;
42
51
  if (!this.starting) {
43
- this.starting = core.Transport.create()
44
- .then((n) => {
45
- this.node = n;
46
- const port = String(n.localAddr()).split(':').pop();
47
- const ip = lanIpv4();
48
- this.announceAddr = ip ? `${ip}:${port}` : n.localAddr(); // routable from other LAN hosts
49
- this.logger.info(`p2p transport up (core ${core.version}) listening :${port}, announce ${this.announceAddr}`);
50
- return n;
51
- })
52
- .catch((e) => { this.logger.warn('p2p transport start failed (using central relay):', e.message); this.node = null; return null; });
52
+ const relayUrl = process.env.CLAWHOST_RELAY_URL || undefined; // undefined → default relays
53
+ this.starting = (async () => {
54
+ const keyHex = this._loadKey() || undefined;
55
+ const node = await core.Transport.create(keyHex, relayUrl);
56
+ this.node = node;
57
+ if (!keyHex) this._saveKey(node.secretKeyHex()); // persist a freshly-generated identity
58
+ this.addr = await node.nodeAddr();
59
+ let id = '?'; try { id = JSON.parse(this.addr).id.slice(0, 12); } catch { /* keep ? */ }
60
+ this.logger.info(`p2p transport up (core ${core.version}) node ${id}…${relayUrl ? ` relay ${relayUrl}` : ' (default relays)'}`);
61
+ return node;
62
+ })().catch((e) => { this.logger.warn('p2p transport start failed (using central relay):', e.message); this.node = null; this.addr = null; return null; });
53
63
  }
54
64
  await this.starting;
55
- return this.node ? this.announceAddr : null;
65
+ return this.addr;
56
66
  }
57
67
 
58
- /** Offer a blob for P2P fetch: AEAD-encrypt + store locally, announce cid→addr. Returns cid|null. */
59
- async offer(bridge, plaintext, key) {
68
+ /** AEAD-encrypt + store a blob locally for P2P serving; returns its cid. Does NOT announce yet
69
+ * the capsule must be persisted first so the server can authorize the announce. null if unavailable. */
70
+ async addBlob(plaintext, key) {
60
71
  const addr = await this.start();
61
72
  if (!addr || !this.node) return null;
62
- let cid;
63
- try { cid = this.node.add(plaintext, key); } catch (e) { this.logger.warn('p2p add failed:', e.message); return null; }
64
- try { await bridge.transportAnnounce(cid, addr); } catch (e) { this.logger.warn('p2p announce failed:', e.message); }
65
- return cid;
73
+ try { return this.node.add(plaintext, key); } catch (e) { this.logger.warn('p2p add failed:', e.message); return null; }
74
+ }
75
+
76
+ /** Announce a previously-added cid to the directory (call AFTER the capsule is persisted, so the
77
+ * server can verify this host is the producer). */
78
+ async announce(bridge, cid) {
79
+ if (!this.addr || !cid) return;
80
+ try { await bridge.transportAnnounce(cid, this.addr); } catch (e) { this.logger.warn('p2p announce failed:', e.message); }
66
81
  }
67
82
 
68
- /** Fetch a blob by cid via P2P (resolve peer fetch). Returns the plaintext Buffer, or null to
69
- * signal the caller should fall back to the central relay. */
70
- async fetch(bridge, cid, key) {
83
+ /** Fetch a blob by cid for a given job: resolve a peer NodeAddr (authorized via job.sees) + fetch
84
+ * over iroh. Returns plaintext Buffer, or null to fall back to the central relay. */
85
+ async fetch(bridge, cid, key, jobId) {
71
86
  if (!this.available()) return null;
72
87
  await this.start();
73
88
  if (!this.node) return null;
74
89
  let peer = null;
75
- try { const r = await bridge.transportResolve(cid); peer = r && r.peer; } catch { peer = null; }
76
- if (!peer || !peer.addr) return null; // no P2P source → relay
90
+ try { const r = await bridge.transportResolve(cid, jobId); peer = r && r.peer; } catch { peer = null; }
91
+ if (!peer || !peer.addr) return null; // no authorized P2P source → relay
77
92
  try {
78
93
  const t0 = Date.now();
79
94
  const buf = await this.node.fetch(cid, peer.addr, key);
80
- const ms = Date.now() - t0;
81
- this.logger.info(`p2p fetch ${cid.slice(0, 14)}… ${buf.length}B from ${peer.addr} in ${ms}ms`);
95
+ this.logger.info(`p2p fetch ${cid.slice(0, 14)}… ${buf.length}B in ${Date.now() - t0}ms`);
82
96
  bridge.transportSession(cid, 'p2p', buf.length).catch(() => {});
83
97
  return buf;
84
98
  } catch (e) {
@@ -90,6 +104,7 @@ class P2P {
90
104
  close() {
91
105
  if (this.node) { try { this.node.close(); } catch { /* ignore */ } this.node = null; }
92
106
  this.starting = null;
107
+ this.addr = null;
93
108
  }
94
109
  }
95
110
 
package/src/worker.js CHANGED
@@ -37,7 +37,7 @@ async function resolveTransport(bridge, p2p, job) {
37
37
  let text = null;
38
38
  if (p2p && p2p.available()) {
39
39
  try {
40
- const buf = await p2p.fetch(bridge, ref.cid, Buffer.from(ref.key, 'hex'));
40
+ const buf = await p2p.fetch(bridge, ref.cid, Buffer.from(ref.key, 'hex'), job.id);
41
41
  if (buf) text = buf.toString('utf8');
42
42
  } catch (e) { logger.warn(`p2p resolve ${ref.cid.slice(0, 14)}… failed: ${e.message}`); }
43
43
  }
@@ -69,20 +69,23 @@ async function processJob(bridge, agentCfg, job, p2p) {
69
69
  try { await bridge.stream(job.id, seq++, evt.type, String(evt.content ?? '')); }
70
70
  catch (e) { logger.warn(`stream seq ${seq} failed: ${e.message}`); }
71
71
  }
72
- // H2H: offer a large result for P2P fetch by the next node's host (inline copy stays the floor).
73
- let transport;
72
+ // H2H: store a large result for P2P fetch by the next node's host (inline copy stays the floor).
73
+ // We add+ref it before complete(), but ANNOUNCE only AFTER complete() persists the capsule — so
74
+ // the directory can verify this host is the producer before listing it.
75
+ let transport, offerCid;
74
76
  try {
75
77
  const buf = Buffer.from(finalText, 'utf8');
76
78
  if (p2p && p2p.available() && job.harness && buf.length > TRANSPORT_MIN_BYTES) {
77
79
  const key = crypto.randomBytes(32);
78
- const cid = await p2p.offer(bridge, buf, key);
79
- if (cid) transport = { cid, key: key.toString('hex'), size: buf.length };
80
+ offerCid = await p2p.addBlob(buf, key);
81
+ if (offerCid) transport = { cid: offerCid, key: key.toString('hex'), size: buf.length };
80
82
  }
81
83
  } catch (e) { logger.warn(`p2p offer failed: ${e.message}`); }
82
84
  const meta = {};
83
85
  if (toolCalls.length) meta.tool_calls = toolCalls;
84
86
  if (transport) meta.transport = transport;
85
87
  await withRetry(() => bridge.complete(job.id, finalText, meta));
88
+ if (offerCid) await p2p.announce(bridge, offerCid);
86
89
  logger.info(`job ${job.id} done (${finalText.length} chars${transport ? `, P2P-offered ${transport.cid.slice(0, 14)}…` : ''})`);
87
90
  } catch (e) {
88
91
  logger.error(`job ${job.id} failed: ${e.message}`);