@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 +1 -1
- package/scripts/install.js +112 -48
- package/src/worker.js +11 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claw-link/gateway-host",
|
|
3
|
-
"version": "0.3.
|
|
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": {
|
package/scripts/install.js
CHANGED
|
@@ -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(
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
216
|
-
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
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') {
|
|
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
|
|
312
|
-
console.log(
|
|
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
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
|
585
|
-
//
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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.
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
//
|
|
235
|
-
//
|
|
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;
|