@claw-link/gateway-host 0.2.12 → 0.3.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/bin/cli.js CHANGED
@@ -70,6 +70,10 @@ async function main() {
70
70
  case 'transport':
71
71
  return require('../scripts/transport-test').run();
72
72
 
73
+ case 'p2p-test':
74
+ case 'p2p':
75
+ return require('../scripts/p2p-test').run();
76
+
73
77
  case 'rotate':
74
78
  console.log([
75
79
  'To rotate a Host Token (also how you move an agent to a new machine):',
@@ -98,6 +102,7 @@ async function main() {
98
102
  ' version Show CLI version + the version the service is running',
99
103
  ' logs Tail the background service log (clhost logs [N])',
100
104
  ' transport-test Verify content-addressed Capsule relay (host ⇄ central sync + integrity)',
105
+ ' p2p-test Verify the native QUIC P2P transport end-to-end on this machine (loopback)',
101
106
  ' start Start the background service',
102
107
  ' stop Stop the background service',
103
108
  ' restart Restart the background service (apply config changes)',
@@ -0,0 +1,55 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+
4
+ /* auto-generated by NAPI-RS */
5
+
6
+ /** One chunk of a blob: its content id + where it sits in the whole. */
7
+ export interface Chunk {
8
+ cid: string
9
+ /** byte offset within the blob (f64 = JS number, exact to 2^53 bytes) */
10
+ offset: number
11
+ len: number
12
+ }
13
+ /**
14
+ * The chunk manifest for a blob — the whole-blob cid plus its ordered chunks. Two hosts exchange
15
+ * manifests first, then transfer only the chunks the receiver is missing.
16
+ */
17
+ export interface Manifest {
18
+ cid: string
19
+ size: number
20
+ chunkSize: number
21
+ chunks: Array<Chunk>
22
+ }
23
+ /** Content-address a whole blob → `b3_<hex>`. */
24
+ export declare function blobCid(bytes: Buffer): string
25
+ /** Split a blob into content-addressed chunks. `chunk_size` is clamped to [64 KiB, 16 MiB]. */
26
+ export declare function chunk(bytes: Buffer, chunkSize?: number | undefined | null): Manifest
27
+ /** Verify a blob/chunk's bytes match an expected content id (integrity check on receive). */
28
+ export declare function verifyBlob(bytes: Buffer, expectedCid: string): boolean
29
+ /**
30
+ * Reassemble verified chunk bytes (in order) into the full blob, checking the result against the
31
+ * expected whole-blob cid. Returns the bytes, or an error if integrity fails.
32
+ */
33
+ export declare function assemble(parts: Array<Buffer>, expectedCid: string): Buffer
34
+ /** Native-core version (so the host can log which acceleration tier is active). */
35
+ export declare function coreVersion(): string
36
+ /**
37
+ * A QUIC peer-to-peer Capsule-byte transport (AHP H2H data plane).
38
+ *
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.
41
+ */
42
+ 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
47
+ /** AEAD-encrypt `bytes` with the 32-byte `key`, store the ciphertext, and return its cid. */
48
+ 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>
51
+ /** Does the local store hold this cid? */
52
+ has(cid: string): boolean
53
+ /** Close the endpoint (stops serving; in-flight transfers drain). */
54
+ close(): void
55
+ }
@@ -0,0 +1,320 @@
1
+ /* tslint:disable */
2
+ /* eslint-disable */
3
+ /* prettier-ignore */
4
+
5
+ /* auto-generated by NAPI-RS */
6
+
7
+ const { existsSync, readFileSync } = require('fs')
8
+ const { join } = require('path')
9
+
10
+ const { platform, arch } = process
11
+
12
+ let nativeBinding = null
13
+ let localFileExisted = false
14
+ let loadError = null
15
+
16
+ function isMusl() {
17
+ // For Node 10
18
+ if (!process.report || typeof process.report.getReport !== 'function') {
19
+ try {
20
+ const lddPath = require('child_process').execSync('which ldd').toString().trim()
21
+ return readFileSync(lddPath, 'utf8').includes('musl')
22
+ } catch (e) {
23
+ return true
24
+ }
25
+ } else {
26
+ const { glibcVersionRuntime } = process.report.getReport().header
27
+ return !glibcVersionRuntime
28
+ }
29
+ }
30
+
31
+ switch (platform) {
32
+ case 'android':
33
+ switch (arch) {
34
+ case 'arm64':
35
+ localFileExisted = existsSync(join(__dirname, 'harness-core.android-arm64.node'))
36
+ try {
37
+ if (localFileExisted) {
38
+ nativeBinding = require('./harness-core.android-arm64.node')
39
+ } else {
40
+ nativeBinding = require('@claw-link/harness-core-android-arm64')
41
+ }
42
+ } catch (e) {
43
+ loadError = e
44
+ }
45
+ break
46
+ case 'arm':
47
+ localFileExisted = existsSync(join(__dirname, 'harness-core.android-arm-eabi.node'))
48
+ try {
49
+ if (localFileExisted) {
50
+ nativeBinding = require('./harness-core.android-arm-eabi.node')
51
+ } else {
52
+ nativeBinding = require('@claw-link/harness-core-android-arm-eabi')
53
+ }
54
+ } catch (e) {
55
+ loadError = e
56
+ }
57
+ break
58
+ default:
59
+ throw new Error(`Unsupported architecture on Android ${arch}`)
60
+ }
61
+ break
62
+ case 'win32':
63
+ switch (arch) {
64
+ case 'x64':
65
+ localFileExisted = existsSync(
66
+ join(__dirname, 'harness-core.win32-x64-msvc.node')
67
+ )
68
+ try {
69
+ if (localFileExisted) {
70
+ nativeBinding = require('./harness-core.win32-x64-msvc.node')
71
+ } else {
72
+ nativeBinding = require('@claw-link/harness-core-win32-x64-msvc')
73
+ }
74
+ } catch (e) {
75
+ loadError = e
76
+ }
77
+ break
78
+ case 'ia32':
79
+ localFileExisted = existsSync(
80
+ join(__dirname, 'harness-core.win32-ia32-msvc.node')
81
+ )
82
+ try {
83
+ if (localFileExisted) {
84
+ nativeBinding = require('./harness-core.win32-ia32-msvc.node')
85
+ } else {
86
+ nativeBinding = require('@claw-link/harness-core-win32-ia32-msvc')
87
+ }
88
+ } catch (e) {
89
+ loadError = e
90
+ }
91
+ break
92
+ case 'arm64':
93
+ localFileExisted = existsSync(
94
+ join(__dirname, 'harness-core.win32-arm64-msvc.node')
95
+ )
96
+ try {
97
+ if (localFileExisted) {
98
+ nativeBinding = require('./harness-core.win32-arm64-msvc.node')
99
+ } else {
100
+ nativeBinding = require('@claw-link/harness-core-win32-arm64-msvc')
101
+ }
102
+ } catch (e) {
103
+ loadError = e
104
+ }
105
+ break
106
+ default:
107
+ throw new Error(`Unsupported architecture on Windows: ${arch}`)
108
+ }
109
+ break
110
+ case 'darwin':
111
+ localFileExisted = existsSync(join(__dirname, 'harness-core.darwin-universal.node'))
112
+ try {
113
+ if (localFileExisted) {
114
+ nativeBinding = require('./harness-core.darwin-universal.node')
115
+ } else {
116
+ nativeBinding = require('@claw-link/harness-core-darwin-universal')
117
+ }
118
+ break
119
+ } catch {}
120
+ switch (arch) {
121
+ case 'x64':
122
+ localFileExisted = existsSync(join(__dirname, 'harness-core.darwin-x64.node'))
123
+ try {
124
+ if (localFileExisted) {
125
+ nativeBinding = require('./harness-core.darwin-x64.node')
126
+ } else {
127
+ nativeBinding = require('@claw-link/harness-core-darwin-x64')
128
+ }
129
+ } catch (e) {
130
+ loadError = e
131
+ }
132
+ break
133
+ case 'arm64':
134
+ localFileExisted = existsSync(
135
+ join(__dirname, 'harness-core.darwin-arm64.node')
136
+ )
137
+ try {
138
+ if (localFileExisted) {
139
+ nativeBinding = require('./harness-core.darwin-arm64.node')
140
+ } else {
141
+ nativeBinding = require('@claw-link/harness-core-darwin-arm64')
142
+ }
143
+ } catch (e) {
144
+ loadError = e
145
+ }
146
+ break
147
+ default:
148
+ throw new Error(`Unsupported architecture on macOS: ${arch}`)
149
+ }
150
+ break
151
+ case 'freebsd':
152
+ if (arch !== 'x64') {
153
+ throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
154
+ }
155
+ localFileExisted = existsSync(join(__dirname, 'harness-core.freebsd-x64.node'))
156
+ try {
157
+ if (localFileExisted) {
158
+ nativeBinding = require('./harness-core.freebsd-x64.node')
159
+ } else {
160
+ nativeBinding = require('@claw-link/harness-core-freebsd-x64')
161
+ }
162
+ } catch (e) {
163
+ loadError = e
164
+ }
165
+ break
166
+ case 'linux':
167
+ switch (arch) {
168
+ case 'x64':
169
+ if (isMusl()) {
170
+ localFileExisted = existsSync(
171
+ join(__dirname, 'harness-core.linux-x64-musl.node')
172
+ )
173
+ try {
174
+ if (localFileExisted) {
175
+ nativeBinding = require('./harness-core.linux-x64-musl.node')
176
+ } else {
177
+ nativeBinding = require('@claw-link/harness-core-linux-x64-musl')
178
+ }
179
+ } catch (e) {
180
+ loadError = e
181
+ }
182
+ } else {
183
+ localFileExisted = existsSync(
184
+ join(__dirname, 'harness-core.linux-x64-gnu.node')
185
+ )
186
+ try {
187
+ if (localFileExisted) {
188
+ nativeBinding = require('./harness-core.linux-x64-gnu.node')
189
+ } else {
190
+ nativeBinding = require('@claw-link/harness-core-linux-x64-gnu')
191
+ }
192
+ } catch (e) {
193
+ loadError = e
194
+ }
195
+ }
196
+ break
197
+ case 'arm64':
198
+ if (isMusl()) {
199
+ localFileExisted = existsSync(
200
+ join(__dirname, 'harness-core.linux-arm64-musl.node')
201
+ )
202
+ try {
203
+ if (localFileExisted) {
204
+ nativeBinding = require('./harness-core.linux-arm64-musl.node')
205
+ } else {
206
+ nativeBinding = require('@claw-link/harness-core-linux-arm64-musl')
207
+ }
208
+ } catch (e) {
209
+ loadError = e
210
+ }
211
+ } else {
212
+ localFileExisted = existsSync(
213
+ join(__dirname, 'harness-core.linux-arm64-gnu.node')
214
+ )
215
+ try {
216
+ if (localFileExisted) {
217
+ nativeBinding = require('./harness-core.linux-arm64-gnu.node')
218
+ } else {
219
+ nativeBinding = require('@claw-link/harness-core-linux-arm64-gnu')
220
+ }
221
+ } catch (e) {
222
+ loadError = e
223
+ }
224
+ }
225
+ break
226
+ case 'arm':
227
+ if (isMusl()) {
228
+ localFileExisted = existsSync(
229
+ join(__dirname, 'harness-core.linux-arm-musleabihf.node')
230
+ )
231
+ try {
232
+ if (localFileExisted) {
233
+ nativeBinding = require('./harness-core.linux-arm-musleabihf.node')
234
+ } else {
235
+ nativeBinding = require('@claw-link/harness-core-linux-arm-musleabihf')
236
+ }
237
+ } catch (e) {
238
+ loadError = e
239
+ }
240
+ } else {
241
+ localFileExisted = existsSync(
242
+ join(__dirname, 'harness-core.linux-arm-gnueabihf.node')
243
+ )
244
+ try {
245
+ if (localFileExisted) {
246
+ nativeBinding = require('./harness-core.linux-arm-gnueabihf.node')
247
+ } else {
248
+ nativeBinding = require('@claw-link/harness-core-linux-arm-gnueabihf')
249
+ }
250
+ } catch (e) {
251
+ loadError = e
252
+ }
253
+ }
254
+ break
255
+ case 'riscv64':
256
+ if (isMusl()) {
257
+ localFileExisted = existsSync(
258
+ join(__dirname, 'harness-core.linux-riscv64-musl.node')
259
+ )
260
+ try {
261
+ if (localFileExisted) {
262
+ nativeBinding = require('./harness-core.linux-riscv64-musl.node')
263
+ } else {
264
+ nativeBinding = require('@claw-link/harness-core-linux-riscv64-musl')
265
+ }
266
+ } catch (e) {
267
+ loadError = e
268
+ }
269
+ } else {
270
+ localFileExisted = existsSync(
271
+ join(__dirname, 'harness-core.linux-riscv64-gnu.node')
272
+ )
273
+ try {
274
+ if (localFileExisted) {
275
+ nativeBinding = require('./harness-core.linux-riscv64-gnu.node')
276
+ } else {
277
+ nativeBinding = require('@claw-link/harness-core-linux-riscv64-gnu')
278
+ }
279
+ } catch (e) {
280
+ loadError = e
281
+ }
282
+ }
283
+ break
284
+ case 's390x':
285
+ localFileExisted = existsSync(
286
+ join(__dirname, 'harness-core.linux-s390x-gnu.node')
287
+ )
288
+ try {
289
+ if (localFileExisted) {
290
+ nativeBinding = require('./harness-core.linux-s390x-gnu.node')
291
+ } else {
292
+ nativeBinding = require('@claw-link/harness-core-linux-s390x-gnu')
293
+ }
294
+ } catch (e) {
295
+ loadError = e
296
+ }
297
+ break
298
+ default:
299
+ throw new Error(`Unsupported architecture on Linux: ${arch}`)
300
+ }
301
+ break
302
+ default:
303
+ throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
304
+ }
305
+
306
+ if (!nativeBinding) {
307
+ if (loadError) {
308
+ throw loadError
309
+ }
310
+ throw new Error(`Failed to load native binding`)
311
+ }
312
+
313
+ const { blobCid, chunk, verifyBlob, assemble, coreVersion, Transport } = nativeBinding
314
+
315
+ module.exports.blobCid = blobCid
316
+ module.exports.chunk = chunk
317
+ module.exports.verifyBlob = verifyBlob
318
+ module.exports.assemble = assemble
319
+ module.exports.coreVersion = coreVersion
320
+ module.exports.Transport = Transport
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claw-link/gateway-host",
3
- "version": "0.2.12",
3
+ "version": "0.3.0",
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": {
@@ -18,6 +18,7 @@
18
18
  "bin/",
19
19
  "scripts/",
20
20
  "src/",
21
+ "native/",
21
22
  "templates/",
22
23
  "README.md",
23
24
  "SECURITY.md",
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+ // `clhost p2p-test` — verify the native H2H QUIC transport end-to-end on THIS machine: two
3
+ // transports, AEAD-encrypted blob transfer + integrity check, all through the native core. Proves
4
+ // the JS↔Rust↔QUIC↔AEAD stack works here before you rely on it across machines.
5
+ const crypto = require('crypto');
6
+ const core = require('../src/transport/core');
7
+
8
+ async function run() {
9
+ if (!core.available || !core.Transport) {
10
+ console.log('Native H2H core is NOT installed for this platform.');
11
+ console.log('The host works fine — Capsule bytes route through the central relay (no P2P speedup).');
12
+ console.log('To enable acceleration, install the @claw-link/harness-core binary for your platform.');
13
+ return;
14
+ }
15
+ console.log(`Native H2H core v${core.version} — running QUIC P2P self-test (loopback)…\n`);
16
+ const a = await core.Transport.create();
17
+ const b = await core.Transport.create();
18
+ try {
19
+ const key = crypto.randomBytes(32);
20
+ for (const n of [4 * 1024, 1024 * 1024, 8 * 1024 * 1024]) {
21
+ const data = crypto.randomBytes(n);
22
+ const cid = a.add(data, key);
23
+ const t0 = Date.now();
24
+ const got = await b.fetch(cid, a.localAddr(), key);
25
+ const ms = Date.now() - t0;
26
+ if (Buffer.compare(got, data) !== 0) throw new Error(`integrity mismatch at ${n} bytes`);
27
+ const mib = (n / (1024 * 1024));
28
+ console.log(` ${(n / 1024).toFixed(0).padStart(7)} KiB ${ms.toString().padStart(4)} ms ${(mib / (ms / 1000)).toFixed(0).padStart(5)} MiB/s OK`);
29
+ }
30
+ const cid = a.add(Buffer.from('secret'), key);
31
+ let wrongKey = false;
32
+ try { await b.fetch(cid, a.localAddr(), crypto.randomBytes(32)); } catch { wrongKey = true; }
33
+ let unknown = false;
34
+ try { await b.fetch('b3_0000', a.localAddr(), key); } catch { unknown = true; }
35
+ console.log(`\n AEAD: wrong key rejected ${wrongKey ? 'OK' : 'FAIL'}`);
36
+ console.log(` integrity: unknown cid rejected ${unknown ? 'OK' : 'FAIL'}`);
37
+ if (!wrongKey || !unknown) throw new Error('security checks failed');
38
+ console.log('\nP2P transport: PASS. Cross-machine transfer is exercised when a harness runs across two connected hosts.');
39
+ } finally {
40
+ a.close();
41
+ b.close();
42
+ }
43
+ }
44
+
45
+ module.exports = { run };
@@ -116,8 +116,13 @@ function makeCliAdapter(cfg) {
116
116
  // edit or run shell. permArgs is handed to defaultArgs so each adapter can place
117
117
  // it correctly (e.g. Codex must put flags BEFORE its `--` end-of-options).
118
118
  // Operator args_template overrides take full control.
119
- const scope = job.scope || 'customer';
120
- const permArgs = (!Array.isArray(agent.args_template) && cfg.permissions) ? cfg.permissions(scope) : [];
119
+ // Harness nodes do collaborative work (write code/files, run commands), so they get
120
+ // workspace-write like an admin "configure" turn even though they're carried on the
121
+ // 'customer' scope. Real customer chats stay read-only.
122
+ const writable = !!job.harness || job.scope === 'configure' || job.scope === 'sub_configure';
123
+ const permArgs = (!Array.isArray(agent.args_template) && cfg.permissions)
124
+ ? cfg.permissions(writable ? 'configure' : (job.scope || 'customer'))
125
+ : [];
121
126
  const argvFor = (rid) => Array.isArray(agent.args_template)
122
127
  ? buildArgv(agent.args_template, { prompt: fullPrompt, sessionId: rid || '', systemPrompt })
123
128
  : cfg.defaultArgs(fullPrompt, rid, cfg.promptViaStdin, permArgs);
package/src/bridge.js CHANGED
@@ -44,6 +44,12 @@ class Bridge {
44
44
  // AHP data plane: request short-lived signed URLs to sync content-addressed Capsule bytes.
45
45
  capsulePut(path) { return this._post('capsule_put', { path }); }
46
46
  capsuleGet(path) { return this._post('capsule_get', { path }); }
47
+ // H2H peer discovery for the QUIC P2P transport (central relay is the fallback).
48
+ transportAnnounce(cid, addr) { return this._post('transport_announce', { cid, addr }); }
49
+ transportResolve(cid) { return this._post('transport_resolve', { cid }); }
50
+ transportSession(cid, mode, bytes) { return this._post('transport_session', { cid, mode, bytes }); }
51
+ // DB floor for a transport ref: fetch the inline plaintext for a transport_cid this job may see.
52
+ capsuleContent(cid, jobId) { return this._post('capsule_content', { cid, job_id: jobId }); }
47
53
  }
48
54
 
49
55
  // Retry wrapper for transient network errors (not auth errors).
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+ // Optional native H2H data-plane core (@claw-link/harness-core): content-addressing + chunking,
3
+ // and — from H1 — direct host-to-host QUIC transport. ACCELERATION, NEVER A DEPENDENCY: if no
4
+ // prebuilt binary exists for this platform (or the package isn't installed), every function is null
5
+ // and the caller falls back to the central relay (host-bridge capsule_put/capsule_get). Loaded once.
6
+
7
+ let native = null;
8
+ // The native H2H core is compiled and BUNDLED inside this package at native/ (one package, no
9
+ // separate dependency). Load the bundle for the running platform; fall back to a monorepo source
10
+ // build for dev; else null → central relay floor. (The native/ binary is produced at release time
11
+ // by scripts/release-host.sh / CI from packages/harness-core.)
12
+ const path = require('path');
13
+ for (const id of [path.join(__dirname, '..', '..', 'native'), path.join(__dirname, '..', '..', '..', 'harness-core')]) {
14
+ try {
15
+ // eslint-disable-next-line global-require, import/no-unresolved, import/no-dynamic-require
16
+ native = require(id);
17
+ if (native && typeof native.blobCid === 'function') break;
18
+ native = null;
19
+ } catch {
20
+ native = null; // no bundled binary for this platform → central relay floor
21
+ }
22
+ }
23
+
24
+ const available = !!(native && typeof native.blobCid === 'function');
25
+
26
+ module.exports = {
27
+ /** true when the native core is loaded and usable on this platform. */
28
+ available,
29
+ /** native core version, or null. */
30
+ version: available && typeof native.coreVersion === 'function' ? native.coreVersion() : null,
31
+ /** Buffer -> "b3_<hex>" content id, or null when unavailable. */
32
+ blobCid: available ? native.blobCid : null,
33
+ /** Buffer -> chunk manifest { cid, size, chunkSize, chunks[] }, or null. */
34
+ chunk: available ? native.chunk : null,
35
+ /** (Buffer, cid) -> bool integrity check, or null. */
36
+ verifyBlob: available ? native.verifyBlob : null,
37
+ /** (Buffer[], cid) -> Buffer reassembled+verified blob, or null. */
38
+ assemble: available ? native.assemble : null,
39
+ /** The native QUIC P2P Transport class (H1), or null. Static factory: Transport.create(). */
40
+ Transport: native && native.Transport ? native.Transport : null,
41
+ };
@@ -0,0 +1,75 @@
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.
5
+ //
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
9
+ //
10
+ // `key` is a 32-byte Buffer (the per-transfer AEAD key, derived from the capsule's authorization).
11
+ const core = require('./core');
12
+
13
+ class P2P {
14
+ constructor(logger) {
15
+ this.logger = logger || { info() {}, warn() {} };
16
+ this.node = null;
17
+ this.starting = null;
18
+ }
19
+
20
+ available() {
21
+ return !!(core.available && core.Transport);
22
+ }
23
+
24
+ /** Start the native QUIC node once (idempotent). Returns the local transport addr, or null. */
25
+ async start() {
26
+ if (!this.available()) return null;
27
+ if (this.node) return this.node.localAddr();
28
+ if (!this.starting) {
29
+ this.starting = core.Transport.create()
30
+ .then((n) => { this.node = n; this.logger.info(`p2p transport up (core ${core.version}) at ${n.localAddr()}`); return n; })
31
+ .catch((e) => { this.logger.warn('p2p transport start failed (using central relay):', e.message); this.node = null; return null; });
32
+ }
33
+ await this.starting;
34
+ return this.node ? this.node.localAddr() : null;
35
+ }
36
+
37
+ /** Offer a blob for P2P fetch: AEAD-encrypt + store locally, announce cid→addr. Returns cid|null. */
38
+ async offer(bridge, plaintext, key) {
39
+ const addr = await this.start();
40
+ if (!addr || !this.node) return null;
41
+ let cid;
42
+ try { cid = this.node.add(plaintext, key); } catch (e) { this.logger.warn('p2p add failed:', e.message); return null; }
43
+ try { await bridge.transportAnnounce(cid, addr); } catch (e) { this.logger.warn('p2p announce failed:', e.message); }
44
+ return cid;
45
+ }
46
+
47
+ /** Fetch a blob by cid via P2P (resolve peer → fetch). Returns the plaintext Buffer, or null to
48
+ * signal the caller should fall back to the central relay. */
49
+ async fetch(bridge, cid, key) {
50
+ if (!this.available()) return null;
51
+ await this.start();
52
+ if (!this.node) return null;
53
+ let peer = null;
54
+ try { const r = await bridge.transportResolve(cid); peer = r && r.peer; } catch { peer = null; }
55
+ if (!peer || !peer.addr) return null; // no P2P source → relay
56
+ try {
57
+ const t0 = Date.now();
58
+ const buf = await this.node.fetch(cid, peer.addr, key);
59
+ const ms = Date.now() - t0;
60
+ this.logger.info(`p2p fetch ${cid.slice(0, 14)}… ${buf.length}B from ${peer.addr} in ${ms}ms`);
61
+ bridge.transportSession(cid, 'p2p', buf.length).catch(() => {});
62
+ return buf;
63
+ } catch (e) {
64
+ this.logger.warn('p2p fetch failed, falling back to relay:', e.message);
65
+ return null;
66
+ }
67
+ }
68
+
69
+ close() {
70
+ if (this.node) { try { this.node.close(); } catch { /* ignore */ } this.node = null; }
71
+ this.starting = null;
72
+ }
73
+ }
74
+
75
+ module.exports = { P2P };
package/src/worker.js CHANGED
@@ -5,11 +5,13 @@
5
5
  // Each job runs the agent's runtime adapter; output is streamed back chunk-by-chunk.
6
6
 
7
7
  const path = require('path');
8
+ const crypto = require('crypto');
8
9
  const logger = require('./logger');
9
10
  const config = require('./config');
10
11
  const { Bridge, withRetry } = require('./bridge');
11
12
  const { getAdapter } = require('./adapters');
12
13
  const { detectRuntimes } = require('./detect');
14
+ const { P2P } = require('./transport/p2p');
13
15
 
14
16
  const VERSION = (() => { try { return require('../package.json').version; } catch { return '0.0.0'; } })();
15
17
 
@@ -17,12 +19,41 @@ const VERSION = (() => { try { return require('../package.json').version; } catc
17
19
  // Generous (matches the server's 15-min reaper) so legitimate long generations,
18
20
  // which keep producing output, are never the ones that hit it.
19
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;
20
24
 
21
25
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
26
  let stopping = false;
23
27
 
24
- async function processJob(bridge, agentCfg, job) {
28
+ // Replace ⟦clawlink:transport:CID⟧ markers in an inbound job's content with the actual upstream
29
+ // bytes — fetched P2P from the producing host, falling back to the inline DB copy. Mutates job.content.
30
+ async function resolveTransport(bridge, p2p, job) {
31
+ const refs = Array.isArray(job.transport_refs) ? job.transport_refs : [];
32
+ if (!refs.length || !job.content) return;
33
+ for (const ref of refs) {
34
+ const marker = `⟦clawlink:transport:${ref.cid}⟧`;
35
+ if (!job.content.includes(marker)) continue;
36
+ let text = null;
37
+ if (p2p && p2p.available()) {
38
+ try {
39
+ const buf = await p2p.fetch(bridge, ref.cid, Buffer.from(ref.key, 'hex'));
40
+ if (buf) text = buf.toString('utf8');
41
+ } catch (e) { logger.warn(`p2p resolve ${ref.cid.slice(0, 14)}… failed: ${e.message}`); }
42
+ }
43
+ if (text == null) {
44
+ // Floor: the inline DB copy (always present). Records a 'relay' transfer for metrics.
45
+ try { const r = await bridge.capsuleContent(ref.cid, job.id); text = r && r.content != null ? r.content : null; } catch { text = null; }
46
+ if (text != null) bridge.transportSession(ref.cid, 'relay', text.length).catch(() => {});
47
+ }
48
+ if (text != null) job.content = job.content.split(marker).join(text);
49
+ else logger.warn(`transport ref ${ref.cid.slice(0, 14)}… unresolved — leaving marker`);
50
+ }
51
+ }
52
+
53
+ async function processJob(bridge, agentCfg, job, p2p) {
25
54
  logger.info(`job ${job.id} claimed (${agentCfg.runtime})`);
55
+ // H2H: pull in any large upstream bytes (P2P, DB floor) before the runtime sees the prompt.
56
+ await resolveTransport(bridge, p2p, job);
26
57
  const adapter = getAdapter(agentCfg.runtime);
27
58
  let seq = 0;
28
59
  let finalText = '';
@@ -37,15 +68,28 @@ async function processJob(bridge, agentCfg, job) {
37
68
  try { await bridge.stream(job.id, seq++, evt.type, String(evt.content ?? '')); }
38
69
  catch (e) { logger.warn(`stream seq ${seq} failed: ${e.message}`); }
39
70
  }
40
- await withRetry(() => bridge.complete(job.id, finalText, toolCalls.length ? { tool_calls: toolCalls } : {}));
41
- logger.info(`job ${job.id} done (${finalText.length} chars)`);
71
+ // H2H: offer a large result for P2P fetch by the next node's host (inline copy stays the floor).
72
+ let transport;
73
+ try {
74
+ const buf = Buffer.from(finalText, 'utf8');
75
+ if (p2p && p2p.available() && job.harness && buf.length > TRANSPORT_MIN_BYTES) {
76
+ const key = crypto.randomBytes(32);
77
+ const cid = await p2p.offer(bridge, buf, key);
78
+ if (cid) transport = { cid, key: key.toString('hex'), size: buf.length };
79
+ }
80
+ } catch (e) { logger.warn(`p2p offer failed: ${e.message}`); }
81
+ const meta = {};
82
+ if (toolCalls.length) meta.tool_calls = toolCalls;
83
+ if (transport) meta.transport = transport;
84
+ await withRetry(() => bridge.complete(job.id, finalText, meta));
85
+ logger.info(`job ${job.id} done (${finalText.length} chars${transport ? `, P2P-offered ${transport.cid.slice(0, 14)}…` : ''})`);
42
86
  } catch (e) {
43
87
  logger.error(`job ${job.id} failed: ${e.message}`);
44
88
  try { await bridge.fail(job.id, e.message); } catch { /* ignore */ }
45
89
  }
46
90
  }
47
91
 
48
- async function runAgentLoop(agentCfg, cfg) {
92
+ async function runAgentLoop(agentCfg, cfg, p2p) {
49
93
  const bridge = new Bridge(cfg.bridge_url, agentCfg, cfg.instance_id);
50
94
  logger.addSecret(agentCfg.host_token);
51
95
  if (agentCfg.local_gateway_token) logger.addSecret(agentCfg.local_gateway_token);
@@ -91,7 +135,7 @@ async function runAgentLoop(agentCfg, cfg) {
91
135
  // Watchdog: guarantees the slot is reclaimed and the job is failed back even
92
136
  // if the adapter never settles (no per-adapter timeout by default).
93
137
  const watchdog = new Promise((resolve) => setTimeout(() => resolve('__watchdog__'), JOB_MAX_MS).unref());
94
- Promise.race([processJob(bridge, agentCfg, job), watchdog])
138
+ Promise.race([processJob(bridge, agentCfg, job, p2p), watchdog])
95
139
  .then(async (r) => {
96
140
  if (r === '__watchdog__') {
97
141
  logger.error(`job ${job.id} exceeded ${JOB_MAX_MS}ms — reclaiming slot`);
@@ -120,16 +164,25 @@ async function startWorker() {
120
164
 
121
165
  logger.info(`ClawLink Host Gateway v${VERSION} — ${agents.length} agent(s): ${agents.map((a) => `${(a.agent_id || 'pending').slice(0, 8)}…(${a.runtime || 'resolving'})`).join(', ')}`);
122
166
 
167
+ // H2H data plane: bring up the native QUIC transport once per host (shared by all agents). If no
168
+ // prebuilt native core exists for this platform, every transfer routes through the central relay.
169
+ const p2p = new P2P(logger);
170
+ const p2pAddr = await p2p.start();
171
+ logger.info(p2pAddr
172
+ ? `H2H transport: native P2P active (${p2pAddr})`
173
+ : 'H2H transport: central relay (no native core for this platform — install acceleration later)');
174
+
123
175
  const onStop = (sig) => {
124
176
  if (stopping) return;
125
177
  stopping = true;
126
178
  logger.info(`${sig} received — draining in-flight jobs…`);
179
+ try { p2p.close(); } catch { /* ignore */ }
127
180
  setTimeout(() => process.exit(0), 8000).unref();
128
181
  };
129
182
  process.on('SIGINT', () => onStop('SIGINT'));
130
183
  process.on('SIGTERM', () => onStop('SIGTERM'));
131
184
 
132
- await Promise.all(agents.map((a) => runAgentLoop(a, raw)));
185
+ await Promise.all(agents.map((a) => runAgentLoop(a, raw, p2p)));
133
186
  }
134
187
 
135
188
  module.exports = { startWorker };