@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/harness-core.darwin-arm64.node +0 -0
- package/native/index.d.ts +23 -8
- package/package.json +1 -1
- package/scripts/p2p-test.js +8 -4
- package/src/bridge.js +1 -1
- package/src/transport/p2p.js +61 -46
- package/src/worker.js +8 -5
|
Binary file
|
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
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 `
|
|
50
|
-
fetch(cid: string,
|
|
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.
|
|
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": {
|
package/scripts/p2p-test.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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',
|
|
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 }); }
|
package/src/transport/p2p.js
CHANGED
|
@@ -1,84 +1,98 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Host-side H2H transport orchestration
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
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
|
|
11
|
-
const
|
|
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
|
-
//
|
|
15
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
50
|
+
if (this.addr) return this.addr;
|
|
42
51
|
if (!this.starting) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
})
|
|
52
|
-
|
|
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.
|
|
65
|
+
return this.addr;
|
|
56
66
|
}
|
|
57
67
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
69
|
-
*
|
|
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
|
-
|
|
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:
|
|
73
|
-
|
|
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
|
-
|
|
79
|
-
if (
|
|
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}`);
|