@claw-link/gateway-host 0.2.8 → 0.2.10
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/package.json +1 -1
- package/scripts/install.js +44 -3
- package/scripts/transport-test.js +41 -0
- package/src/bridge.js +3 -0
- package/src/transport/relay.js +51 -0
package/bin/cli.js
CHANGED
|
@@ -48,6 +48,10 @@ async function main() {
|
|
|
48
48
|
case 'status':
|
|
49
49
|
return require('../scripts/install').status();
|
|
50
50
|
|
|
51
|
+
case 'transport-test':
|
|
52
|
+
case 'transport':
|
|
53
|
+
return require('../scripts/transport-test').run();
|
|
54
|
+
|
|
51
55
|
case 'rotate':
|
|
52
56
|
console.log([
|
|
53
57
|
'To rotate a Host Token (also how you move an agent to a new machine):',
|
|
@@ -72,6 +76,7 @@ async function main() {
|
|
|
72
76
|
' retoken Re-enter a new Host Token for an existing agent (clhost retoken <id>)',
|
|
73
77
|
' agents List mapped agents',
|
|
74
78
|
' status Show bridge, service state, and mapped agents',
|
|
79
|
+
' transport-test Verify content-addressed Capsule relay (host ⇄ central sync + integrity)',
|
|
75
80
|
' start Start the background service',
|
|
76
81
|
' stop Stop the background service',
|
|
77
82
|
' restart Restart the background service (apply config changes)',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claw-link/gateway-host",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.10",
|
|
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/install.js
CHANGED
|
@@ -96,12 +96,27 @@ function saveAgent(cfg, agent) {
|
|
|
96
96
|
|
|
97
97
|
// `setup` — install the service (if needed) + add one or more agents.
|
|
98
98
|
async function run() {
|
|
99
|
-
console.log('\n ClawLink Host Gateway — setup\n ─────────────────────────────\n');
|
|
100
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
101
99
|
const cfg = config.load() || config.defaultConfig();
|
|
102
100
|
ensureBridgeUrl(cfg);
|
|
103
101
|
config.save(cfg);
|
|
104
102
|
|
|
103
|
+
// Backward-compat: a bare `npx @claw-link/gateway-host@latest` on an ALREADY-configured
|
|
104
|
+
// host is an UPDATE, not first-run setup — don't re-prompt for agents. Refresh the
|
|
105
|
+
// background service so the new version takes effect, and show status.
|
|
106
|
+
const existing = (cfg.agents || []).length;
|
|
107
|
+
if (existing > 0) {
|
|
108
|
+
console.log(`\n ClawLink Host Gateway v${VERSION} — already set up (${existing} agent${existing > 1 ? 's' : ''}).`);
|
|
109
|
+
const installed = installService();
|
|
110
|
+
console.log(installed
|
|
111
|
+
? ' ✓ Service refreshed to the latest version.'
|
|
112
|
+
: ' ⚠ Could not refresh the service automatically — run: clhost restart');
|
|
113
|
+
console.log('\n Commands: clhost status · clhost agents · clhost add-agent · clhost transport-test\n');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
console.log('\n ClawLink Host Gateway — setup\n ─────────────────────────────\n');
|
|
118
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
119
|
+
|
|
105
120
|
let added = 0, more = true;
|
|
106
121
|
while (more) {
|
|
107
122
|
console.log('\n Add an agent:');
|
|
@@ -258,11 +273,37 @@ function serviceControlCmd(action) {
|
|
|
258
273
|
}
|
|
259
274
|
|
|
260
275
|
function nodeBin() { return process.execPath; }
|
|
261
|
-
function
|
|
276
|
+
function pkgRoot() { return path.join(__dirname, '..'); }
|
|
277
|
+
function appDir() { return path.join(config.HOME_DIR, 'app'); }
|
|
278
|
+
|
|
279
|
+
// The background service must run from a STABLE path — not the version-pinned, GC-able npx
|
|
280
|
+
// cache (`__dirname`), which is why republishing never changed the running version. Copy the
|
|
281
|
+
// currently-running package into ~/.clawlink-host/app on every install/update so a newer
|
|
282
|
+
// `npx @claw-link/gateway-host@latest` actually swaps the code the service runs.
|
|
283
|
+
function syncAppDir() {
|
|
284
|
+
const src = pkgRoot();
|
|
285
|
+
const dest = appDir();
|
|
286
|
+
if (path.resolve(src) === path.resolve(dest)) return; // already running from the stable dir
|
|
287
|
+
try {
|
|
288
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
289
|
+
fs.cpSync(src, dest, {
|
|
290
|
+
recursive: true,
|
|
291
|
+
filter: (s) => { const b = path.basename(s); return b !== 'node_modules' && b !== '.git'; },
|
|
292
|
+
});
|
|
293
|
+
try { fs.chmodSync(path.join(dest, 'bin', 'cli.js'), 0o755); } catch { /* best effort */ }
|
|
294
|
+
} catch { /* non-fatal — cliEntry() falls back to the current path */ }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function cliEntry() {
|
|
298
|
+
const stable = path.join(appDir(), 'bin', 'cli.js');
|
|
299
|
+
try { if (fs.existsSync(stable)) return stable; } catch { /* */ }
|
|
300
|
+
return path.join(pkgRoot(), 'bin', 'cli.js'); // fallback if the stable copy failed
|
|
301
|
+
}
|
|
262
302
|
|
|
263
303
|
function installService() {
|
|
264
304
|
const platform = process.platform;
|
|
265
305
|
try {
|
|
306
|
+
syncAppDir(); // refresh the stable app dir so the service runs the just-updated version
|
|
266
307
|
if (platform === 'darwin') {
|
|
267
308
|
const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
268
309
|
fs.mkdirSync(plistDir, { recursive: true });
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// `clhost transport-test` — proves the AHP content-addressed relay works end to end:
|
|
2
|
+
// generates random bytes, content-addresses them, uploads to central Storage via a signed
|
|
3
|
+
// URL, fetches them back, and verifies the bytes are byte-identical and the cid matches.
|
|
4
|
+
// Isolated from the harness run loop — safe to run any time against a connected agent.
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
const config = require('../src/config');
|
|
9
|
+
const { Bridge } = require('../src/bridge');
|
|
10
|
+
const { cidOf, putBlob, getBlob } = require('../src/transport/relay');
|
|
11
|
+
|
|
12
|
+
async function run() {
|
|
13
|
+
const cfg = config.load();
|
|
14
|
+
const agent = (cfg.agents || [])[0];
|
|
15
|
+
if (!agent || !agent.host_token) {
|
|
16
|
+
console.error('No agent configured. Run: clhost add-agent');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
const bridge = new Bridge(cfg.bridge_url, agent, cfg.instance_id);
|
|
20
|
+
const sizeKB = Number(process.argv[3]) || 64;
|
|
21
|
+
const payload = crypto.randomBytes(sizeKB * 1024);
|
|
22
|
+
const expectCid = cidOf(payload);
|
|
23
|
+
|
|
24
|
+
console.log(`→ ${(payload.length / 1024).toFixed(0)} KB, cid ${expectCid.slice(0, 18)}…`);
|
|
25
|
+
try {
|
|
26
|
+
const t0 = Date.now();
|
|
27
|
+
const put = await putBlob(bridge, 'selftest', payload);
|
|
28
|
+
console.log(` uploaded → ${put.path} (${Date.now() - t0}ms)`);
|
|
29
|
+
const t1 = Date.now();
|
|
30
|
+
const got = await getBlob(bridge, put.path, expectCid);
|
|
31
|
+
const ok = got.length === payload.length && Buffer.compare(got, payload) === 0;
|
|
32
|
+
console.log(` fetched + verified (${Date.now() - t1}ms)`);
|
|
33
|
+
if (!ok) throw new Error('round-trip mismatch');
|
|
34
|
+
console.log('✓ content-addressed relay OK — host ⇄ central sync + integrity verified.');
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error(`✗ transport-test failed: ${e.message}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { run };
|
package/src/bridge.js
CHANGED
|
@@ -41,6 +41,9 @@ class Bridge {
|
|
|
41
41
|
stream(jobId, seq, type, content) { return this._post('stream', { job_id: jobId, seq, type, content }); }
|
|
42
42
|
complete(jobId, finalContent, metadata) { return this._post('complete', { job_id: jobId, final_content: finalContent, metadata: metadata || {} }); }
|
|
43
43
|
fail(jobId, error) { return this._post('fail', { job_id: jobId, error: String(error).slice(0, 1000) }); }
|
|
44
|
+
// AHP data plane: request short-lived signed URLs to sync content-addressed Capsule bytes.
|
|
45
|
+
capsulePut(path) { return this._post('capsule_put', { path }); }
|
|
46
|
+
capsuleGet(path) { return this._post('capsule_get', { path }); }
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
// Retry wrapper for transient network errors (not auth errors).
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// AHP data plane — Phase 1 (content-addressed central relay, pure JS).
|
|
2
|
+
//
|
|
3
|
+
// A Capsule's bytes are content-addressed (cid = sha256) and synced to central Storage
|
|
4
|
+
// via short-lived signed URLs requested over the host's existing outbound bridge — no
|
|
5
|
+
// inbound port, no extra dependency (Node built-in crypto + global fetch). Every fetch is
|
|
6
|
+
// re-hashed and verified against the cid, so a tampered/corrupt object is rejected. This is
|
|
7
|
+
// the relay rung of the fetch ladder and the foundation the Rust P2P core later accelerates;
|
|
8
|
+
// if any step fails the caller falls back to the inline path — never worse than today.
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
|
|
13
|
+
/** Content address: a stable, self-verifying id derived from the bytes. */
|
|
14
|
+
function cidOf(buf) {
|
|
15
|
+
return 'blob_' + crypto.createHash('sha256').update(buf).digest('hex');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Upload bytes to central Storage under <namespace>/<cid>; returns {cid, path, size}. */
|
|
19
|
+
async function putBlob(bridge, namespace, buf) {
|
|
20
|
+
const data = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
|
|
21
|
+
const cid = cidOf(data);
|
|
22
|
+
const r = await bridge.capsulePut(`${namespace}/${cid}`);
|
|
23
|
+
if (!r || !r.signed_url) throw new Error('capsule_put: no signed url');
|
|
24
|
+
const res = await fetch(r.signed_url, {
|
|
25
|
+
method: 'PUT',
|
|
26
|
+
headers: {
|
|
27
|
+
'content-type': 'application/octet-stream',
|
|
28
|
+
'x-upsert': 'true', // content-addressed → re-uploading the same cid is idempotent
|
|
29
|
+
...(r.token ? { authorization: `Bearer ${r.token}` } : {}),
|
|
30
|
+
},
|
|
31
|
+
body: data,
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok && res.status !== 409) throw new Error(`capsule upload failed (${res.status})`);
|
|
34
|
+
return { cid, path: r.path, size: data.length };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Fetch bytes by storage path and VERIFY them against expectedCid before returning. */
|
|
38
|
+
async function getBlob(bridge, path, expectedCid) {
|
|
39
|
+
const r = await bridge.capsuleGet(path);
|
|
40
|
+
if (!r || !r.url) throw new Error('capsule_get: no signed url');
|
|
41
|
+
const res = await fetch(r.url);
|
|
42
|
+
if (!res.ok) throw new Error(`capsule download failed (${res.status})`);
|
|
43
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
44
|
+
const got = cidOf(buf);
|
|
45
|
+
if (expectedCid && got !== expectedCid) {
|
|
46
|
+
throw new Error(`integrity check failed: ${got} != ${expectedCid}`);
|
|
47
|
+
}
|
|
48
|
+
return buf;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { cidOf, putBlob, getBlob };
|