@claw-link/gateway-host 0.3.1 → 0.3.3

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.1",
3
+ "version": "0.3.3",
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": {
@@ -12,16 +12,17 @@ async function run() {
12
12
  console.log('To enable acceleration, install the @claw-link/harness-core binary for your platform.');
13
13
  return;
14
14
  }
15
- console.log(`Native H2H core v${core.version} — running QUIC P2P self-test (loopback)…\n`);
15
+ console.log(`Native H2H core v${core.version} — running iroh QUIC P2P self-test (loopback)…\n`);
16
16
  const a = await core.Transport.create();
17
17
  const b = await core.Transport.create();
18
+ const aAddr = await a.nodeAddr();
18
19
  try {
19
20
  const key = crypto.randomBytes(32);
20
21
  for (const n of [4 * 1024, 1024 * 1024, 8 * 1024 * 1024]) {
21
22
  const data = crypto.randomBytes(n);
22
23
  const cid = a.add(data, key);
23
24
  const t0 = Date.now();
24
- const got = await b.fetch(cid, a.localAddr(), key);
25
+ const got = await b.fetch(cid, aAddr, key);
25
26
  const ms = Date.now() - t0;
26
27
  if (Buffer.compare(got, data) !== 0) throw new Error(`integrity mismatch at ${n} bytes`);
27
28
  const mib = (n / (1024 * 1024));
@@ -29,9 +30,9 @@ async function run() {
29
30
  }
30
31
  const cid = a.add(Buffer.from('secret'), key);
31
32
  let wrongKey = false;
32
- try { await b.fetch(cid, a.localAddr(), crypto.randomBytes(32)); } catch { wrongKey = true; }
33
+ try { await b.fetch(cid, aAddr, crypto.randomBytes(32)); } catch { wrongKey = true; }
33
34
  let unknown = false;
34
- try { await b.fetch('b3_0000', a.localAddr(), key); } catch { unknown = true; }
35
+ try { await b.fetch('b3_0000', aAddr, key); } catch { unknown = true; }
35
36
  console.log(`\n AEAD: wrong key rejected ${wrongKey ? 'OK' : 'FAIL'}`);
36
37
  console.log(` integrity: unknown cid rejected ${unknown ? 'OK' : 'FAIL'}`);
37
38
  if (!wrongKey || !unknown) throw new Error('security checks failed');
@@ -1,61 +1,65 @@
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 { const k = fs.readFileSync(KEY_PATH, 'utf8').trim(); if (/^[0-9a-f]{64}$/i.test(k)) return k; } catch { /* none */ }
34
+ return null;
35
+ }
36
+ _saveKey(hex) {
37
+ try { if (config.ensureHomeDir) config.ensureHomeDir(); fs.writeFileSync(KEY_PATH, hex, { mode: 0o600 }); }
38
+ catch (e) { this.logger.warn('could not persist transport identity:', e.message); }
39
+ }
40
+
41
+ /** Start the iroh node once (idempotent). Returns our NodeAddr string, or null. */
39
42
  async start() {
40
43
  if (!this.available()) return null;
41
- if (this.announceAddr) return this.announceAddr;
44
+ if (this.addr) return this.addr;
42
45
  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; });
46
+ const relayUrl = process.env.CLAWHOST_RELAY_URL || undefined; // undefined → default relays
47
+ this.starting = (async () => {
48
+ const keyHex = this._loadKey() || undefined;
49
+ const node = await core.Transport.create(keyHex, relayUrl);
50
+ this.node = node;
51
+ if (!keyHex) this._saveKey(node.secretKeyHex()); // persist a freshly-generated identity
52
+ this.addr = await node.nodeAddr();
53
+ let id = '?'; try { id = JSON.parse(this.addr).id.slice(0, 12); } catch { /* keep ? */ }
54
+ this.logger.info(`p2p transport up (core ${core.version}) node ${id}…${relayUrl ? ` relay ${relayUrl}` : ' (default relays)'}`);
55
+ return node;
56
+ })().catch((e) => { this.logger.warn('p2p transport start failed (using central relay):', e.message); this.node = null; this.addr = null; return null; });
53
57
  }
54
58
  await this.starting;
55
- return this.node ? this.announceAddr : null;
59
+ return this.addr;
56
60
  }
57
61
 
58
- /** Offer a blob for P2P fetch: AEAD-encrypt + store locally, announce cid→addr. Returns cid|null. */
62
+ /** Offer a blob for P2P fetch: AEAD-encrypt + store locally, announce cid→our NodeAddr. Returns cid|null. */
59
63
  async offer(bridge, plaintext, key) {
60
64
  const addr = await this.start();
61
65
  if (!addr || !this.node) return null;
@@ -65,8 +69,8 @@ class P2P {
65
69
  return cid;
66
70
  }
67
71
 
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. */
72
+ /** Fetch a blob by cid: resolve a peer NodeAddr + fetch over iroh. Returns plaintext Buffer, or null
73
+ * to signal the caller should fall back to the central relay. */
70
74
  async fetch(bridge, cid, key) {
71
75
  if (!this.available()) return null;
72
76
  await this.start();
@@ -77,8 +81,7 @@ class P2P {
77
81
  try {
78
82
  const t0 = Date.now();
79
83
  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`);
84
+ this.logger.info(`p2p fetch ${cid.slice(0, 14)}… ${buf.length}B in ${Date.now() - t0}ms`);
82
85
  bridge.transportSession(cid, 'p2p', buf.length).catch(() => {});
83
86
  return buf;
84
87
  } catch (e) {
@@ -90,6 +93,7 @@ class P2P {
90
93
  close() {
91
94
  if (this.node) { try { this.node.close(); } catch { /* ignore */ } this.node = null; }
92
95
  this.starting = null;
96
+ this.addr = null;
93
97
  }
94
98
  }
95
99
 
package/src/worker.js CHANGED
@@ -19,8 +19,9 @@ const VERSION = (() => { try { return require('../package.json').version; } catc
19
19
  // Generous (matches the server's 15-min reaper) so legitimate long generations,
20
20
  // which keep producing output, are never the ones that hit it.
21
21
  const JOB_MAX_MS = Number(process.env.CLAWHOST_JOB_MAX_MS) || 15 * 60 * 1000;
22
- // Offer outputs larger than this over P2P (matches the engine's TRANSPORT_MIN_BYTES).
23
- const TRANSPORT_MIN_BYTES = 32 * 1024;
22
+ // Offer outputs larger than this over P2P (must match the engine's TRANSPORT_MIN_BYTES).
23
+ // Lowered to 1 KiB for validation so a normal reply triggers P2P; raise for production.
24
+ const TRANSPORT_MIN_BYTES = Number(process.env.CLAWHOST_TRANSPORT_MIN) || 1024;
24
25
 
25
26
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
26
27
  let stopping = false;