@claw-link/gateway-host 0.3.7 → 0.3.8

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.7",
3
+ "version": "0.3.8",
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": {
@@ -139,9 +139,9 @@ async function run() {
139
139
  onWait: (s) => process.stdout.write(`\r … still working: ${s.activeJobs} job(s) in flight `),
140
140
  });
141
141
  if (live && live.busy) process.stdout.write('\n');
142
- console.log(r.restarted
143
- ? (r.hadWorker && !r.timedOut ? 'Finished in-flight work, then refreshed to the latest version.' : ' ✓ Service refreshed to the latest version.')
144
- : 'Could not refresh the service automaticallyrun: clhost restart');
142
+ if (!r.restarted) console.log(' ⚠ Could not refresh the service automatically — run: clhost restart');
143
+ else if (r.confirmed) console.log(`Refreshed now running v${r.version} (pid ${r.pid}).`);
144
+ else console.log(`Restart triggered but the new worker hasn't come up on v${VERSION} yet check \`clhost status\` in a few seconds.`);
145
145
  reportCli();
146
146
  console.log('\n Commands: clhost status · clhost agents · clhost add-agent · clhost transport-test\n');
147
147
  return;
@@ -190,9 +190,10 @@ async function addAgentCmd() {
190
190
  rl.close();
191
191
  if (agent) {
192
192
  saveAgent(cfg, agent);
193
- const { restarted } = await gracefulServiceRestartCLI();
194
- console.log(`\n ✓ Added agent ${agent.agent_id} (${agent.runtime}).` +
195
- (restarted ? ' Service restarted.' : ' Run `clhost restart` (or start) to apply.') + '\n');
193
+ const r = await gracefulServiceRestartCLI();
194
+ const note = !r.restarted ? ' Run `clhost restart` (or start) to apply.'
195
+ : r.confirmed ? ' Service restarted.' : ' Restarting — check `clhost status`.';
196
+ console.log(`\n ✓ Added agent ${agent.agent_id} (${agent.runtime}).` + note + '\n');
196
197
  }
197
198
  }
198
199
 
@@ -212,8 +213,10 @@ async function removeAgentCmd() {
212
213
  cfg.agents = cfg.agents.filter((a) => !(a.agent_id && (a.agent_id === target || a.agent_id.startsWith(target))));
213
214
  if (cfg.agents.length === before) { console.log(` No agent matched '${target}'.`); return; }
214
215
  config.save(cfg);
215
- const { restarted } = await gracefulServiceRestartCLI();
216
- console.log(` ✓ Removed agent '${target}'.` + (restarted ? ' Service restarted.' : ' Run `clhost restart` to apply.'));
216
+ const r = await gracefulServiceRestartCLI();
217
+ const note = !r.restarted ? ' Run `clhost restart` to apply.'
218
+ : r.confirmed ? ' Service restarted.' : ' Restarting — check `clhost status`.';
219
+ console.log(` ✓ Removed agent '${target}'.` + note);
217
220
  }
218
221
 
219
222
  function printAgents(cfg) {
@@ -270,9 +273,10 @@ async function retokenCmd() {
270
273
  }
271
274
  config.save(cfg);
272
275
  rl.close(); // release stdin before the (possibly long) graceful drain so Ctrl-C is handled by our cleanup
273
- const { restarted } = await gracefulServiceRestartCLI();
274
- console.log(`\n ✓ Updated token for ${agent.agent_id} (…${token.slice(-4)}).` +
275
- (restarted ? ' Service restarted.' : ' Run `clhost restart` to apply.') + '\n');
276
+ const r = await gracefulServiceRestartCLI();
277
+ const note = !r.restarted ? ' Run `clhost restart` to apply.'
278
+ : r.confirmed ? ' Service restarted.' : ' Restarting — check `clhost status`.';
279
+ console.log(`\n ✓ Updated token for ${agent.agent_id} (…${token.slice(-4)}).` + note + '\n');
276
280
  } finally { rl.close(); }
277
281
  }
278
282
 
@@ -297,7 +301,17 @@ function serviceControl(action) {
297
301
  if (platform === 'win32') {
298
302
  if (action === 'stop') return execSafe('schtasks /End /TN ClawLinkHost') !== null;
299
303
  if (action === 'start') return execSafe('schtasks /Run /TN ClawLinkHost') !== null;
300
- if (action === 'restart') { execSafe('schtasks /End /TN ClawLinkHost'); return execSafe('schtasks /Run /TN ClawLinkHost') !== null; }
304
+ if (action === 'restart') {
305
+ execSafe('schtasks /End /TN ClawLinkHost');
306
+ // /End is async, and the task is MultipleInstancesPolicy=IgnoreNew — so an immediate /Run can be
307
+ // SILENTLY DROPPED while the old instance is still stopping (the Windows analogue of the macOS
308
+ // bootout→bootstrap race). Give it a brief, LANGUAGE-NEUTRAL pause to stop before /Run: `ping -n
309
+ // 4 127.0.0.1` ≈ 3s (parsing schtasks' status would depend on the localized word "Running"; and
310
+ // `timeout` needs an interactive console). If /Run still doesn't take, restartAndConfirm's
311
+ // confirm-then-reinstall fallback recovers.
312
+ execSafe('ping -n 4 127.0.0.1');
313
+ return execSafe('schtasks /Run /TN ClawLinkHost') !== null;
314
+ }
301
315
  }
302
316
  } catch { /* fall through */ }
303
317
  return false;
@@ -308,8 +322,10 @@ async function serviceControlCmd(action) {
308
322
  // `stop` stays prompt: its SIGTERM already lets the worker finish in-flight work (2nd signal forces).
309
323
  if (action === 'restart') {
310
324
  const { force, timeoutMs } = parseDrainArgs();
311
- const { restarted } = await gracefulServiceRestartCLI({ force, timeoutMs });
312
- console.log(restarted ? ' ✓ Service restarted.' : ' ⚠ Could not restart the service (is it installed? run `clhost install`).');
325
+ const r = await gracefulServiceRestartCLI({ force, timeoutMs });
326
+ if (!r.restarted) console.log(' ⚠ Could not restart the service (is it installed? run `clhost install`).');
327
+ else if (r.confirmed) console.log(` ✓ Service restarted — running v${r.version} (pid ${r.pid}).`);
328
+ else console.log(' ⚠ Restart triggered but the worker hasn’t reported yet — check `clhost status`.');
313
329
  return;
314
330
  }
315
331
  const ok = serviceControl(action);
@@ -547,47 +563,90 @@ async function drainToIdle({ timeoutMs = 15 * 60 * 1000, force = false, onWait }
547
563
  if (force) return { hadWorker: true, drained: false, forced: true };
548
564
 
549
565
  control.requestDrain();
550
- // If the operator Ctrl-C's (or the CLI is killed) mid-wait, clear the flag so the still-running
551
- // worker resumes claiming instead of sitting drained forever. Without this, an aborted update/
552
- // restart would silently wedge the worker.
566
+ // NOTE: the Ctrl-C cleanup that clears the drain flag on abort lives in withDrainGuard(), which
567
+ // wraps this call AND the restart so there's no window (between drain finishing and the restart)
568
+ // where an interrupt could leave the worker drained. Don't install a handler here.
569
+ const startedAt = Date.now();
570
+ let waited = false;
571
+ while (Date.now() - startedAt < timeoutMs) {
572
+ const s = control.liveStatus();
573
+ if (!s) return { hadWorker: true, drained: true, workerGone: true }; // worker exited → safe
574
+ // Safe to restart only once the worker has ACKED the drain (won't claim more) AND is idle.
575
+ if (s.drain && !s.busy) return { hadWorker: true, drained: true, waited };
576
+ if (s.busy && typeof onWait === 'function') { onWait(s); waited = true; }
577
+ await sleep(1500);
578
+ }
579
+ return { hadWorker: true, drained: false, timedOut: true };
580
+ }
581
+
582
+ // Run an async drain+restart sequence under a Ctrl-C guard: if the operator interrupts at ANY point
583
+ // (during the drain wait OR the restart), clear the drain flag FIRST so the still-running worker
584
+ // resumes claiming — never left wedged — then exit. Spanning the whole sequence closes the gap that
585
+ // a per-drainToIdle handler would leave open between draining and restarting.
586
+ async function withDrainGuard(fn) {
553
587
  const onInt = () => { try { control.clearDrain(); } catch { /* ignore */ } process.exit(130); };
554
588
  process.on('SIGINT', onInt);
555
589
  process.on('SIGTERM', onInt);
556
- try {
557
- const startedAt = Date.now();
558
- let waited = false;
559
- while (Date.now() - startedAt < timeoutMs) {
560
- const s = control.liveStatus();
561
- if (!s) return { hadWorker: true, drained: true, workerGone: true }; // worker exited safe
562
- // Safe to restart only once the worker has ACKED the drain (won't claim more) AND is idle.
563
- if (s.drain && !s.busy) return { hadWorker: true, drained: true, waited };
564
- if (s.busy && typeof onWait === 'function') { onWait(s); waited = true; }
565
- await sleep(1500);
590
+ try { return await fn(); }
591
+ finally { process.off('SIGINT', onInt); process.off('SIGTERM', onInt); }
592
+ }
593
+
594
+ // After triggering a restart, wait until the NEW worker actually publishes status (a different pid
595
+ // than before and — if given the expected version), so a caller never reports success while the old
596
+ // process is still winding down or a launchd unload/reload is mid-flight. Best-effort: confirmed=false
597
+ // on timeout. This is what makes `clhost update`'s "runs vX" honest instead of premature.
598
+ async function waitForWorkerUp(prevPid, wantVersion, timeoutMs = 25000) {
599
+ const startedAt = Date.now();
600
+ while (Date.now() - startedAt < timeoutMs) {
601
+ await sleep(1000);
602
+ const s = control.liveStatus();
603
+ if (s && s.pid !== prevPid && (!wantVersion || String(s.version) === String(wantVersion))) {
604
+ return { confirmed: true, version: s.version, pid: s.pid };
566
605
  }
567
- return { hadWorker: true, drained: false, timedOut: true };
568
- } finally {
569
- process.off('SIGINT', onInt);
570
- process.off('SIGTERM', onInt);
571
606
  }
607
+ const s = control.liveStatus();
608
+ return { confirmed: false, version: s && s.version, pid: s && s.pid };
572
609
  }
573
610
 
574
- // Drain the running worker to idle (unless forced), then stage the new code + restart the service.
575
- // The fresh worker clears the drain flag on boot; if the restart fails we clear it here so the old
576
- // worker resumes claiming instead of sitting permanently drained.
611
+ // Restart the service and CONFIRM the new worker came up. Prefer an IN-PLACE restart
612
+ // (kickstart -k / systemctl restart / schtasks) it never unloads the job (no "Service: stopped"
613
+ // window like bootout→bootstrap) and relaunches from the stable app dir. Only if that fails to bring
614
+ // a worker up (service not loaded yet, or a stale Node path in the plist after an nvm/Node upgrade)
615
+ // fall back to a full (re)install, which rewrites the plist. Clears the drain flag unless a fresh
616
+ // worker is confirmed (which clears it on boot) so nothing is ever left drained.
617
+ async function restartAndConfirm({ stageCode = false, wantVersion, onRestart } = {}) {
618
+ if (stageCode) syncAppDir(); // copy the new code into the stable app dir the service runs from
619
+ const prevPid = (control.liveStatus() || {}).pid;
620
+ let notified = false;
621
+ const notify = () => { if (!notified && typeof onRestart === 'function') { notified = true; onRestart(); } };
622
+ let restarted = serviceControl('restart');
623
+ if (restarted) notify();
624
+ let up = restarted ? await waitForWorkerUp(prevPid, wantVersion) : { confirmed: false };
625
+ if (!up.confirmed) {
626
+ const reinstalled = installService(); // rewrites plist/unit (handles a moved Node) + reload
627
+ if (reinstalled) { restarted = true; notify(); up = await waitForWorkerUp(prevPid, wantVersion); }
628
+ }
629
+ if (!up.confirmed) control.clearDrain();
630
+ return { restarted, ...up };
631
+ }
632
+
633
+ // `update` path — drain, then stage new code + restart, confirming the new version is actually live.
577
634
  async function gracefulRestart(opts = {}) {
578
- const drain = await drainToIdle(opts);
579
- const restarted = installService(); // syncAppDir (stage new code) + bootstrap/restart the service
580
- if (!restarted) control.clearDrain();
581
- return { ...drain, restarted };
635
+ return withDrainGuard(async () => {
636
+ const drain = await drainToIdle(opts);
637
+ const r = await restartAndConfirm({ stageCode: true, wantVersion: VERSION, onRestart: opts.onRestart });
638
+ return { ...drain, ...r };
639
+ });
582
640
  }
583
641
 
584
- // Config-change restart (add/remove/retoken agent, `clhost restart`): drain in-flight jobs first,
585
- // then a plain restart. Unlike gracefulRestart it does NOT re-stage code the version is unchanged.
642
+ // Config-change restart (add/remove/retoken agent, `clhost restart`): drain first, then restart in
643
+ // place (no code re-stage same version), confirming the worker actually bounced (new pid).
586
644
  async function gracefulServiceRestart(opts = {}) {
587
- const drain = await drainToIdle(opts);
588
- const restarted = serviceControl('restart');
589
- if (!restarted) control.clearDrain();
590
- return { ...drain, restarted };
645
+ return withDrainGuard(async () => {
646
+ const drain = await drainToIdle(opts);
647
+ const r = await restartAndConfirm({ stageCode: false, onRestart: opts.onRestart });
648
+ return { ...drain, ...r };
649
+ });
591
650
  }
592
651
 
593
652
  // Console wrapper around gracefulServiceRestart: prints a one-time "waiting" notice + a live progress
@@ -637,10 +696,15 @@ async function update() {
637
696
  if (live && live.busy && !force) process.stdout.write('\n');
638
697
 
639
698
  if (!r.restarted) { console.log(' ⚠ Could not refresh automatically — run: clhost restart'); return; }
640
- if (r.forced) console.log(` ✓ Restarted now (--force). Service runs v${VERSION} from ${appDir()}.`);
641
- else if (r.timedOut) console.log(` ⚠ A job was still running after the wait restarted anyway (it may have been interrupted). Service runs v${VERSION}.`);
642
- else if (r.hadWorker) console.log(` ✓ Finished in-flight work, then refreshed. Service runs v${VERSION} from ${appDir()}.`);
643
- else console.log(` ✓ Service now runs v${VERSION} from ${appDir()}.`);
699
+ if (r.confirmed) {
700
+ // The success line now reflects what the new worker ACTUALLY reports (pid + version), not a guess.
701
+ const drained = r.hadWorker && !r.forced && !r.timedOut;
702
+ console.log(` ✓ ${drained ? 'Finished in-flight work, then restarted' : 'Restarted'} — now running v${r.version} (pid ${r.pid}).`);
703
+ if (r.timedOut) console.log(' ⚠ (A job was still running after the wait — it may have been interrupted.)');
704
+ } else {
705
+ console.log(` ⚠ Restart triggered but the new worker hasn't come up on v${VERSION} yet` +
706
+ (r.version ? ` (still seeing v${r.version})` : '') + ` — check \`clhost status\` in a few seconds (\`clhost logs\` if it doesn't appear).`);
707
+ }
644
708
  console.log(' (To upgrade the `clhost` CLI you type: npm i -g @claw-link/gateway-host@latest)\n');
645
709
  }
646
710
 
package/src/worker.js CHANGED
@@ -221,18 +221,14 @@ async function startWorker() {
221
221
 
222
222
  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(', ')}`);
223
223
 
224
- // H2H data plane: bring up the native QUIC transport once per host (shared by all agents). If no
225
- // prebuilt native core exists for this platform, every transfer routes through the central relay.
226
224
  const p2p = new P2P(logger);
227
- const p2pAddr = await p2p.start();
228
- logger.info(p2pAddr
229
- ? `H2H transport: native P2P active (${p2pAddr})`
230
- : 'H2H transport: central relay (no native core for this platform — install acceleration later)');
231
225
 
232
226
  // Advertise liveness + in-flight count so `clhost update`/`restart` can wait for this host to go
233
227
  // idle before restarting, and pick up the CLI's drain flag (stop claiming, finish current work).
234
- // Clear any stale flag first; and only ever honor a flag written AFTER this boot, so a leftover
235
- // flag from a crashed update (or a clearDrain that failed) can never wedge a fresh worker.
228
+ // Do this BEFORE the (possibly slow) P2P init, so a restart is CONFIRMED fast waitForWorkerUp sees
229
+ // the new pid/version within ~1s even while iroh is still binding. Clear any stale flag first; and
230
+ // only ever honor a flag written AFTER this boot, so a leftover flag from a crashed update (or a
231
+ // clearDrain that failed) can never wedge a fresh worker. (pushStatus never touches p2p.)
236
232
  control.clearDrain(); // drop any stale flag left by a previous life FIRST…
237
233
  const bootAt = Date.now(); // …then anchor "now": only a drain requested after this point is honored.
238
234
  const pushStatus = () => {
@@ -248,6 +244,13 @@ async function startWorker() {
248
244
  const statusTimer = setInterval(pushStatus, 2000);
249
245
  statusTimer.unref();
250
246
 
247
+ // H2H data plane: bring up the native QUIC transport once per host (shared by all agents). If no
248
+ // prebuilt native core exists for this platform, every transfer routes through the central relay.
249
+ const p2pAddr = await p2p.start();
250
+ logger.info(p2pAddr
251
+ ? `H2H transport: native P2P active (${p2pAddr})`
252
+ : 'H2H transport: central relay (no native core for this platform — install acceleration later)');
253
+
251
254
  // Graceful shutdown: stop claiming, let in-flight jobs finish (keeping P2P alive for them), then
252
255
  // exit. A second signal forces an immediate exit; SHUTDOWN_DRAIN_MS bounds the wait.
253
256
  let stopSignals = 0;