@claw-link/gateway-host 0.3.3 → 0.3.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claw-link/gateway-host",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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": {
@@ -6,6 +6,9 @@ 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).');
@@ -45,7 +45,7 @@ function buildArgv(template, vars) {
45
45
  * This returns an async iterator of events. The prompt is passed via stdin when
46
46
  * `stdin` is true (preferred — keeps it out of argv/process listings entirely).
47
47
  */
48
- async function* spawnStreaming({ binary, argv, cwd, env, timeoutMs = 600000, stdinInput = null, parseLine }) {
48
+ async function* spawnStreaming({ binary, argv, cwd, env, timeoutMs = 600000, stdinInput = null, parseLine, signal = null }) {
49
49
  if (!binary) throw new Error('no runtime binary configured');
50
50
 
51
51
  // Windows: npm CLIs are `.cmd`/`.bat` shims, and modern Node REFUSES to spawn those
@@ -73,6 +73,17 @@ async function* spawnStreaming({ binary, argv, cwd, env, timeoutMs = 600000, std
73
73
  ? setTimeout(() => { killed = true; try { child.kill('SIGKILL'); } catch { /* gone */ } }, timeoutMs)
74
74
  : null;
75
75
 
76
+ // Abort (the run was cancelled): stop the agent promptly — SIGTERM, then SIGKILL if it lingers.
77
+ const onAbort = () => {
78
+ killed = true;
79
+ try { child.kill('SIGTERM'); } catch { /* gone */ }
80
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, 3000).unref();
81
+ };
82
+ if (signal) {
83
+ if (signal.aborted) onAbort();
84
+ else signal.addEventListener('abort', onAbort, { once: true });
85
+ }
86
+
76
87
  if (stdinInput != null) {
77
88
  try { child.stdin.write(stdinInput); } catch { /* ignore */ }
78
89
  }
@@ -90,7 +90,7 @@ function makeCliAdapter(cfg) {
90
90
  name: cfg.name,
91
91
  binaries: cfg.binaries,
92
92
 
93
- async *run({ agent, job, logger }) {
93
+ async *run({ agent, job, logger, signal }) {
94
94
  const binary = agent.binary || cfg.binaries[0];
95
95
  const userPrompt = job.content || '';
96
96
  const systemPrompt = job.system_prompt || '';
@@ -135,7 +135,7 @@ function makeCliAdapter(cfg) {
135
135
  : genericLineParser;
136
136
  for await (const evt of spawnStreaming({
137
137
  binary, argv: argvFor(rid), cwd: agent.work_dir, timeoutMs,
138
- stdinInput: cfg.promptViaStdin ? fullPrompt : null, parseLine,
138
+ stdinInput: cfg.promptViaStdin ? fullPrompt : null, parseLine, signal,
139
139
  })) {
140
140
  if (evt.type === 'chunk') emittedAny = true;
141
141
  yield evt;
package/src/bridge.js CHANGED
@@ -41,12 +41,14 @@ 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
+ // Poll a running job's status so the worker can abort the agent if the run was cancelled.
45
+ jobStatus(jobId) { return this._post('job_status', { job_id: jobId }); }
44
46
  // AHP data plane: request short-lived signed URLs to sync content-addressed Capsule bytes.
45
47
  capsulePut(path) { return this._post('capsule_put', { path }); }
46
48
  capsuleGet(path) { return this._post('capsule_get', { path }); }
47
49
  // H2H peer discovery for the QUIC P2P transport (central relay is the fallback).
48
50
  transportAnnounce(cid, addr) { return this._post('transport_announce', { cid, addr }); }
49
- transportResolve(cid) { return this._post('transport_resolve', { cid }); }
51
+ transportResolve(cid, jobId) { return this._post('transport_resolve', { cid, job_id: jobId }); }
50
52
  transportSession(cid, mode, bytes) { return this._post('transport_session', { cid, mode, bytes }); }
51
53
  // DB floor for a transport ref: fetch the inline plaintext for a transport_cid this job may see.
52
54
  capsuleContent(cid, jobId) { return this._post('capsule_content', { cid, job_id: jobId }); }
@@ -30,7 +30,13 @@ class P2P {
30
30
  }
31
31
 
32
32
  _loadKey() {
33
- try { const k = fs.readFileSync(KEY_PATH, 'utf8').trim(); if (/^[0-9a-f]{64}$/i.test(k)) return k; } catch { /* none */ }
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 */ }
34
40
  return null;
35
41
  }
36
42
  _saveKey(hex) {
@@ -59,25 +65,30 @@ class P2P {
59
65
  return this.addr;
60
66
  }
61
67
 
62
- /** Offer a blob for P2P fetch: AEAD-encrypt + store locally, announce cid→our NodeAddr. Returns cid|null. */
63
- async offer(bridge, plaintext, key) {
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) {
64
71
  const addr = await this.start();
65
72
  if (!addr || !this.node) return null;
66
- let cid;
67
- try { cid = this.node.add(plaintext, key); } catch (e) { this.logger.warn('p2p add failed:', e.message); return null; }
68
- try { await bridge.transportAnnounce(cid, addr); } catch (e) { this.logger.warn('p2p announce failed:', e.message); }
69
- return cid;
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); }
70
81
  }
71
82
 
72
- /** Fetch a blob by cid: resolve a peer NodeAddr + fetch over iroh. Returns plaintext Buffer, or null
73
- * to signal the caller should fall back to the central relay. */
74
- 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) {
75
86
  if (!this.available()) return null;
76
87
  await this.start();
77
88
  if (!this.node) return null;
78
89
  let peer = null;
79
- try { const r = await bridge.transportResolve(cid); peer = r && r.peer; } catch { peer = null; }
80
- 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
81
92
  try {
82
93
  const t0 = Date.now();
83
94
  const buf = await this.node.fetch(cid, peer.addr, key);
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
  }
@@ -56,11 +56,24 @@ async function processJob(bridge, agentCfg, job, p2p) {
56
56
  // H2H: pull in any large upstream bytes (P2P, DB floor) before the runtime sees the prompt.
57
57
  await resolveTransport(bridge, p2p, job);
58
58
  const adapter = getAdapter(agentCfg.runtime);
59
+ // Cancellation: poll the job's status while it runs; if the run was cancelled (or the job errored/
60
+ // expired server-side), abort the child so the agent STOPS promptly instead of grinding on.
61
+ const ac = new AbortController();
62
+ const cancelPoll = setInterval(() => {
63
+ bridge.jobStatus(job.id)
64
+ .then((r) => {
65
+ if (r && ['cancelled', 'error', 'expired'].includes(String(r.status)) && !ac.signal.aborted) {
66
+ logger.info(`job ${job.id} ${r.status} — stopping the agent`);
67
+ ac.abort();
68
+ }
69
+ })
70
+ .catch(() => { /* transient — keep polling */ });
71
+ }, 4000);
59
72
  let seq = 0;
60
73
  let finalText = '';
61
74
  let toolCalls = [];
62
75
  try {
63
- for await (const evt of adapter.run({ agent: agentCfg, job, logger })) {
76
+ for await (const evt of adapter.run({ agent: agentCfg, job, logger, signal: ac.signal })) {
64
77
  if (!evt || !evt.type) continue;
65
78
  // Structured tool calls aren't streamed as a chunk — collected for the final
66
79
  // metadata so the server can extract rich UI (clawlink_ui).
@@ -69,24 +82,31 @@ async function processJob(bridge, agentCfg, job, p2p) {
69
82
  try { await bridge.stream(job.id, seq++, evt.type, String(evt.content ?? '')); }
70
83
  catch (e) { logger.warn(`stream seq ${seq} failed: ${e.message}`); }
71
84
  }
72
- // H2H: offer a large result for P2P fetch by the next node's host (inline copy stays the floor).
73
- let transport;
85
+ if (ac.signal.aborted) { logger.info(`job ${job.id} cancelled not completing`); return; }
86
+ // H2H: store a large result for P2P fetch by the next node's host (inline copy stays the floor).
87
+ // We add+ref it before complete(), but ANNOUNCE only AFTER complete() persists the capsule — so
88
+ // the directory can verify this host is the producer before listing it.
89
+ let transport, offerCid;
74
90
  try {
75
91
  const buf = Buffer.from(finalText, 'utf8');
76
92
  if (p2p && p2p.available() && job.harness && buf.length > TRANSPORT_MIN_BYTES) {
77
93
  const key = crypto.randomBytes(32);
78
- const cid = await p2p.offer(bridge, buf, key);
79
- if (cid) transport = { cid, key: key.toString('hex'), size: buf.length };
94
+ offerCid = await p2p.addBlob(buf, key);
95
+ if (offerCid) transport = { cid: offerCid, key: key.toString('hex'), size: buf.length };
80
96
  }
81
97
  } catch (e) { logger.warn(`p2p offer failed: ${e.message}`); }
82
98
  const meta = {};
83
99
  if (toolCalls.length) meta.tool_calls = toolCalls;
84
100
  if (transport) meta.transport = transport;
85
101
  await withRetry(() => bridge.complete(job.id, finalText, meta));
102
+ if (offerCid) await p2p.announce(bridge, offerCid);
86
103
  logger.info(`job ${job.id} done (${finalText.length} chars${transport ? `, P2P-offered ${transport.cid.slice(0, 14)}…` : ''})`);
87
104
  } catch (e) {
105
+ if (ac.signal.aborted) { logger.info(`job ${job.id} cancelled — agent stopped`); return; }
88
106
  logger.error(`job ${job.id} failed: ${e.message}`);
89
107
  try { await bridge.fail(job.id, e.message); } catch { /* ignore */ }
108
+ } finally {
109
+ clearInterval(cancelPoll);
90
110
  }
91
111
  }
92
112