@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 +5 -0
- package/native/harness-core.darwin-arm64.node +0 -0
- package/native/index.d.ts +55 -0
- package/native/index.js +320 -0
- package/package.json +2 -1
- package/scripts/p2p-test.js +45 -0
- package/src/adapters/cli.js +7 -2
- package/src/bridge.js +6 -0
- package/src/transport/core.js +41 -0
- package/src/transport/p2p.js +75 -0
- package/src/worker.js +59 -6
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)',
|
|
Binary file
|
|
@@ -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
|
+
}
|
package/native/index.js
ADDED
|
@@ -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.
|
|
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 };
|
package/src/adapters/cli.js
CHANGED
|
@@ -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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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 };
|