groove-dev 0.27.213 → 0.27.214
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/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +146 -20
- package/node_modules/@groove-dev/daemon/test/tunnel-manager.test.js +99 -1
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/tunnel-manager.js +146 -20
- package/packages/gui/package.json +1 -1
|
@@ -26,6 +26,10 @@ const CONFIRM_TIMEOUT = 15000;
|
|
|
26
26
|
// At most one automatic rebuild per tunnel per window; beyond that it stays
|
|
27
27
|
// disconnected rather than thrashing against a host that keeps dying.
|
|
28
28
|
const REBUILD_COOLDOWN_MS = 10 * 60 * 1000;
|
|
29
|
+
// Restarting a crashed REMOTE daemon is cheaper and safer than a rebuild, so
|
|
30
|
+
// its cooldown is shorter — but still bounded: a daemon that dies right after
|
|
31
|
+
// every start has a real problem more starts won't fix.
|
|
32
|
+
const REMOTE_START_COOLDOWN_MS = 2 * 60 * 1000;
|
|
29
33
|
|
|
30
34
|
const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
|
|
31
35
|
|
|
@@ -60,6 +64,10 @@ export class TunnelManager {
|
|
|
60
64
|
constructor(daemon) {
|
|
61
65
|
this.daemon = daemon;
|
|
62
66
|
this.remotesPath = resolve(daemon.grooveDir, 'remotes.json');
|
|
67
|
+
// Live tunnel state, persisted separately from the configs so a daemon
|
|
68
|
+
// restart can re-adopt still-running ssh processes instead of forgetting
|
|
69
|
+
// them (the configs file is user data; this is runtime state).
|
|
70
|
+
this.activePath = resolve(daemon.grooveDir, 'tunnels-active.json');
|
|
63
71
|
this.saved = new Map();
|
|
64
72
|
this.active = new Map();
|
|
65
73
|
this._healthInterval = null;
|
|
@@ -88,8 +96,9 @@ export class TunnelManager {
|
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
async init() {
|
|
99
|
+
await this._readopt();
|
|
91
100
|
for (const [id, config] of this.saved) {
|
|
92
|
-
if (config.autoConnect) {
|
|
101
|
+
if (config.autoConnect && !this.active.has(id)) {
|
|
93
102
|
try {
|
|
94
103
|
await this.connect(id);
|
|
95
104
|
} catch (err) {
|
|
@@ -99,6 +108,71 @@ export class TunnelManager {
|
|
|
99
108
|
}
|
|
100
109
|
}
|
|
101
110
|
|
|
111
|
+
// Re-adopt tunnels whose detached ssh processes survived a daemon restart.
|
|
112
|
+
// Without this, every daemon/app restart orphaned the ssh (or shutdown killed
|
|
113
|
+
// it) and the new daemon started amnesiac — remote windows died mid-session
|
|
114
|
+
// and the user had to reconnect everything by hand.
|
|
115
|
+
async _readopt() {
|
|
116
|
+
let entries = [];
|
|
117
|
+
try {
|
|
118
|
+
if (existsSync(this.activePath)) entries = JSON.parse(readFileSync(this.activePath, 'utf8'));
|
|
119
|
+
} catch { /* corrupt — treat as none */ }
|
|
120
|
+
if (!Array.isArray(entries) || entries.length === 0) return;
|
|
121
|
+
|
|
122
|
+
let adopted = 0;
|
|
123
|
+
for (const e of entries) {
|
|
124
|
+
if (!e?.id || !e.pid || !e.localPort || !this.saved.has(e.id)) continue;
|
|
125
|
+
// Only re-adopt what is provably OUR ssh still doing THIS job: the pid
|
|
126
|
+
// must be alive, be an ssh process forwarding this port, and the port
|
|
127
|
+
// must serve HTTP.
|
|
128
|
+
let alive = false;
|
|
129
|
+
try { process.kill(e.pid, 0); alive = true; } catch { /* gone */ }
|
|
130
|
+
if (alive) alive = this._looksLikeOurSsh(e.pid, e.localPort);
|
|
131
|
+
if (alive && await this._tunnelResponds(e.localPort)) {
|
|
132
|
+
this.active.set(e.id, {
|
|
133
|
+
pid: e.pid,
|
|
134
|
+
localPort: e.localPort,
|
|
135
|
+
startedAt: e.startedAt || new Date().toISOString(),
|
|
136
|
+
lastPing: Date.now(),
|
|
137
|
+
latencyMs: null,
|
|
138
|
+
healthy: true,
|
|
139
|
+
failCount: 0,
|
|
140
|
+
});
|
|
141
|
+
adopted++;
|
|
142
|
+
const name = this.saved.get(e.id)?.name || e.id;
|
|
143
|
+
console.log(`[Groove:Tunnel] Re-adopted live tunnel to ${name} on port ${e.localPort}`);
|
|
144
|
+
this.daemon.broadcast({ type: 'tunnel.connected', data: { id: e.id, name, localPort: e.localPort, host: this.saved.get(e.id)?.host, url: `http://localhost:${e.localPort}?instance=${encodeURIComponent(name)}` } });
|
|
145
|
+
} else if (alive) {
|
|
146
|
+
// ssh survives but doesn't serve — a corpse from before the restart.
|
|
147
|
+
try { process.kill(e.pid, 'SIGTERM'); } catch { /* gone */ }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (adopted > 0 && !this._healthInterval) {
|
|
151
|
+
this._healthInterval = setInterval(() => this._healthCheckAll(), HEALTH_INTERVAL);
|
|
152
|
+
}
|
|
153
|
+
this._saveActive();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Identity check for re-adoption: is this pid an ssh forwarding this port?
|
|
157
|
+
// Guards against pid recycling handing us an unrelated process.
|
|
158
|
+
_looksLikeOurSsh(pid, localPort) {
|
|
159
|
+
try {
|
|
160
|
+
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
161
|
+
encoding: 'utf8', timeout: 3000,
|
|
162
|
+
}).trim();
|
|
163
|
+
return cmd.includes('ssh') && cmd.includes(String(localPort));
|
|
164
|
+
} catch { return false; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_saveActive() {
|
|
168
|
+
try {
|
|
169
|
+
const entries = [...this.active.entries()].map(([id, c]) => ({
|
|
170
|
+
id, pid: c.pid, localPort: c.localPort, startedAt: c.startedAt,
|
|
171
|
+
}));
|
|
172
|
+
writeFileSync(this.activePath, JSON.stringify(entries, null, 2), { mode: 0o600 });
|
|
173
|
+
} catch { /* best effort */ }
|
|
174
|
+
}
|
|
175
|
+
|
|
102
176
|
getSaved() {
|
|
103
177
|
return Array.from(this.saved.values()).map(s => ({
|
|
104
178
|
...this._sanitize(s),
|
|
@@ -406,6 +480,7 @@ export class TunnelManager {
|
|
|
406
480
|
healthy: true,
|
|
407
481
|
failCount: 0,
|
|
408
482
|
});
|
|
483
|
+
this._saveActive();
|
|
409
484
|
|
|
410
485
|
// Verify daemon is reachable through tunnel, start if needed
|
|
411
486
|
let remoteAlive = false;
|
|
@@ -496,6 +571,7 @@ export class TunnelManager {
|
|
|
496
571
|
if (localPort) await this._waitForPortFree(localPort, 3000);
|
|
497
572
|
|
|
498
573
|
this.active.delete(id);
|
|
574
|
+
this._saveActive();
|
|
499
575
|
|
|
500
576
|
const config = this.saved.get(id);
|
|
501
577
|
this.daemon.audit.log('tunnel.disconnect', { id, name: config?.name });
|
|
@@ -972,6 +1048,12 @@ export class TunnelManager {
|
|
|
972
1048
|
conn.failCount = 0;
|
|
973
1049
|
conn.healthy = true;
|
|
974
1050
|
conn._wedgedStreak = 0;
|
|
1051
|
+
} else if (verdict === 'remote-down') {
|
|
1052
|
+
// The tunnel is carrying traffic correctly — the far daemon is what
|
|
1053
|
+
// died (typically mid-upgrade). Rebuild would be useless; start it.
|
|
1054
|
+
conn.healthy = false;
|
|
1055
|
+
this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
|
|
1056
|
+
await this._startRemoteDaemon(id, conn);
|
|
975
1057
|
} else {
|
|
976
1058
|
conn.healthy = false;
|
|
977
1059
|
this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
|
|
@@ -995,19 +1077,71 @@ export class TunnelManager {
|
|
|
995
1077
|
}
|
|
996
1078
|
|
|
997
1079
|
// Escalating evidence that a tunnel is actually dead, not merely slow:
|
|
998
|
-
// 'alive'
|
|
999
|
-
// 'proc-dead'
|
|
1000
|
-
// 'port-dead'
|
|
1001
|
-
// '
|
|
1080
|
+
// 'alive' — answered a long-timeout HTTP probe; leave it alone
|
|
1081
|
+
// 'proc-dead' — the ssh client process is gone
|
|
1082
|
+
// 'port-dead' — nothing is listening on the local port
|
|
1083
|
+
// 'remote-down' — the tunnel forwards fine but the REMOTE end refuses:
|
|
1084
|
+
// the probe fails fast with a connection error, not a
|
|
1085
|
+
// timeout. Killing the tunnel won't fix that — the remote
|
|
1086
|
+
// daemon needs starting (e.g. it died during an upgrade).
|
|
1087
|
+
// 'wedged' — port accepts TCP but HTTP hangs to timeout (dead forward)
|
|
1002
1088
|
async _confirmDead(conn) {
|
|
1003
|
-
|
|
1089
|
+
const started = Date.now();
|
|
1090
|
+
let probeErr = null;
|
|
1091
|
+
try {
|
|
1092
|
+
const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
|
|
1093
|
+
signal: AbortSignal.timeout(this.confirmTimeout ?? CONFIRM_TIMEOUT),
|
|
1094
|
+
});
|
|
1095
|
+
if (res.ok) return 'alive';
|
|
1096
|
+
} catch (err) { probeErr = err; }
|
|
1097
|
+
|
|
1004
1098
|
if (conn.pid) {
|
|
1005
1099
|
try { process.kill(conn.pid, 0); } catch { return 'proc-dead'; }
|
|
1006
1100
|
}
|
|
1007
1101
|
if (!(await this._isPortInUse(conn.localPort))) return 'port-dead';
|
|
1102
|
+
|
|
1103
|
+
// ssh is alive and its port listens. A hang (timeout) means the forward is
|
|
1104
|
+
// dead; a FAST connection-level error means ssh relayed the remote side's
|
|
1105
|
+
// refusal — the tunnel works, the far daemon doesn't.
|
|
1106
|
+
const failedFast = Date.now() - started < 2000;
|
|
1107
|
+
const timedOut = probeErr && (probeErr.name === 'TimeoutError' || probeErr.name === 'AbortError');
|
|
1108
|
+
if (failedFast && !timedOut) return 'remote-down';
|
|
1008
1109
|
return 'wedged';
|
|
1009
1110
|
}
|
|
1010
1111
|
|
|
1112
|
+
// The tunnel is healthy; the daemon on the far side is what's down. Start it
|
|
1113
|
+
// over ssh rather than pointlessly rebuilding the tunnel. Rate-limited: if
|
|
1114
|
+
// the remote daemon won't stay up, repeated starts won't save it.
|
|
1115
|
+
async _startRemoteDaemon(id, conn) {
|
|
1116
|
+
this._remoteStartAt = this._remoteStartAt || new Map();
|
|
1117
|
+
const last = this._remoteStartAt.get(id) || 0;
|
|
1118
|
+
if (Date.now() - last < REMOTE_START_COOLDOWN_MS) return;
|
|
1119
|
+
this._remoteStartAt.set(id, Date.now());
|
|
1120
|
+
|
|
1121
|
+
console.log(`[Groove:Tunnel] ${id}: tunnel is fine but the remote daemon is down — starting it`);
|
|
1122
|
+
this.daemon.audit.log('tunnel.remote-daemon-start', { id });
|
|
1123
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
|
|
1124
|
+
try {
|
|
1125
|
+
await this.autoStart(id);
|
|
1126
|
+
// Confirm it came up; a success resets the failure counters immediately
|
|
1127
|
+
// instead of waiting out another health cycle.
|
|
1128
|
+
for (let i = 0; i < 10; i++) {
|
|
1129
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1130
|
+
if (await this._tunnelResponds(conn.localPort)) {
|
|
1131
|
+
conn.failCount = 0;
|
|
1132
|
+
conn.healthy = true;
|
|
1133
|
+
conn._wedgedStreak = 0;
|
|
1134
|
+
console.log(`[Groove:Tunnel] ${id}: remote daemon is back`);
|
|
1135
|
+
this.daemon.broadcast({ type: 'tunnel.health', data: { id, latencyMs: conn.latencyMs, healthy: true } });
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
console.warn(`[Groove:Tunnel] ${id}: remote daemon did not come back after start`);
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
console.warn(`[Groove:Tunnel] ${id}: could not start remote daemon: ${err.message}`);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1011
1145
|
// Tear down a confirmed-dead tunnel and immediately rebuild it on the SAME
|
|
1012
1146
|
// local port. The remote GUI window points at that port and its WebSocket
|
|
1013
1147
|
// retries every 2s, so a same-port rebuild heals an open window without the
|
|
@@ -1075,25 +1209,17 @@ export class TunnelManager {
|
|
|
1075
1209
|
throw new Error(`No available local port found (tried ${DEFAULT_LOCAL_PORT}-${DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS - 1})`);
|
|
1076
1210
|
}
|
|
1077
1211
|
|
|
1212
|
+
// Deliberately does NOT kill the ssh processes. They are spawned detached and
|
|
1213
|
+
// are the user's live sessions: killing them on every daemon restart (app
|
|
1214
|
+
// upgrade, promote, crash) is what nuked remote windows mid-session. State is
|
|
1215
|
+
// persisted; the next daemon re-adopts whatever is still alive and serving.
|
|
1216
|
+
// Explicit disconnect()/delete() remain the paths that actually kill a tunnel.
|
|
1078
1217
|
shutdown() {
|
|
1079
1218
|
if (this._healthInterval) {
|
|
1080
1219
|
clearInterval(this._healthInterval);
|
|
1081
1220
|
this._healthInterval = null;
|
|
1082
1221
|
}
|
|
1083
|
-
|
|
1084
|
-
try {
|
|
1085
|
-
const conn = this.active.get(id);
|
|
1086
|
-
if (conn?.pid) {
|
|
1087
|
-
const cmd = execFileSync('ps', ['-p', String(conn.pid), '-o', 'command='], {
|
|
1088
|
-
encoding: 'utf8',
|
|
1089
|
-
timeout: 3000,
|
|
1090
|
-
}).trim();
|
|
1091
|
-
if (cmd.includes('ssh')) {
|
|
1092
|
-
process.kill(conn.pid, 'SIGTERM');
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
} catch { /* ignore */ }
|
|
1096
|
-
}
|
|
1222
|
+
this._saveActive();
|
|
1097
1223
|
this.active.clear();
|
|
1098
1224
|
}
|
|
1099
1225
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
12
12
|
import assert from 'node:assert/strict';
|
|
13
|
-
import { mkdtempSync, rmSync } from 'fs';
|
|
13
|
+
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs';
|
|
14
14
|
import { tmpdir } from 'os';
|
|
15
15
|
import { resolve } from 'path';
|
|
16
16
|
import { createServer } from 'net';
|
|
@@ -240,6 +240,104 @@ describe('TunnelManager — wake-from-sleep recovery', () => {
|
|
|
240
240
|
} finally { live.close(); }
|
|
241
241
|
});
|
|
242
242
|
|
|
243
|
+
it('remote-down (fast refusal through a live tunnel) starts the daemon, not a rebuild', async () => {
|
|
244
|
+
// ssh alive + port listens + connections REFUSED at the far end: ssh relays
|
|
245
|
+
// the refusal as an immediate close, so the probe fails fast rather than
|
|
246
|
+
// hanging. The tunnel is fine; only the remote daemon needs starting.
|
|
247
|
+
const refusing = createServer((sock) => sock.destroy());
|
|
248
|
+
await new Promise((r) => refusing.listen(0, '127.0.0.1', r));
|
|
249
|
+
const port = refusing.address().port;
|
|
250
|
+
try {
|
|
251
|
+
mgr.saved.set('dgx', { id: 'dgx', name: 'Axom Spark', host: 'edgexpert.local', user: 'axom', port: 22 });
|
|
252
|
+
mgr.active.set('dgx', {
|
|
253
|
+
pid: process.pid, localPort: port, healthy: true, failCount: 99,
|
|
254
|
+
startedAt: new Date().toISOString(),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const started = [];
|
|
258
|
+
mgr.autoStart = async (id) => { started.push(id); };
|
|
259
|
+
mgr.connect = async () => { throw new Error('rebuild must not be attempted'); };
|
|
260
|
+
|
|
261
|
+
await mgr._healthCheckAll();
|
|
262
|
+
|
|
263
|
+
assert.deepEqual(started, ['dgx'], 'the remote daemon was started over ssh');
|
|
264
|
+
assert.equal(mgr.active.has('dgx'), true, 'the healthy tunnel was NOT torn down');
|
|
265
|
+
} finally { refusing.close(); }
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('re-adopts a surviving tunnel after a daemon restart instead of forgetting it', async () => {
|
|
269
|
+
const live = await startHealthyListener();
|
|
270
|
+
try {
|
|
271
|
+
mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: '3.22.211.238', user: 'ubuntu', port: 22 });
|
|
272
|
+
mgr._save(); // the new daemon loads configs from disk
|
|
273
|
+
mgr.active.set('s19', {
|
|
274
|
+
pid: process.pid, localPort: live.port,
|
|
275
|
+
startedAt: new Date().toISOString(), healthy: true, failCount: 0,
|
|
276
|
+
});
|
|
277
|
+
mgr._saveActive();
|
|
278
|
+
mgr.shutdown(); // daemon going down — must NOT kill the tunnel
|
|
279
|
+
|
|
280
|
+
// "New daemon" after restart. The stand-in pid is node, not ssh, so the
|
|
281
|
+
// identity gate is stubbed — everything else runs for real.
|
|
282
|
+
const daemon2 = makeDaemon(grooveDir);
|
|
283
|
+
const mgr2 = new TunnelManager(daemon2);
|
|
284
|
+
mgr2.healthTimeout = 400;
|
|
285
|
+
mgr2._looksLikeOurSsh = (pid, port) => pid === process.pid && port === live.port;
|
|
286
|
+
|
|
287
|
+
await mgr2._readopt();
|
|
288
|
+
|
|
289
|
+
assert.equal(mgr2.active.has('s19'), true, 'the surviving tunnel was re-adopted');
|
|
290
|
+
assert.equal(mgr2.active.get('s19').localPort, live.port, 'on its original port');
|
|
291
|
+
assert.ok(
|
|
292
|
+
daemon2.broadcasts.some((b) => b.type === 'tunnel.connected'),
|
|
293
|
+
'the GUI is told the tunnel is (still) connected',
|
|
294
|
+
);
|
|
295
|
+
mgr2.shutdown();
|
|
296
|
+
} finally { live.close(); }
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('does not re-adopt a dead or hijacked pid', async () => {
|
|
300
|
+
const live = await startHealthyListener();
|
|
301
|
+
try {
|
|
302
|
+
mgr.saved.set('s19', { id: 's19', name: 'S19 Agency', host: '3.22.211.238', user: 'ubuntu', port: 22 });
|
|
303
|
+
mgr._save();
|
|
304
|
+
mgr.active.set('s19', {
|
|
305
|
+
pid: 999999, localPort: live.port, // no such process
|
|
306
|
+
startedAt: new Date().toISOString(), healthy: true, failCount: 0,
|
|
307
|
+
});
|
|
308
|
+
mgr._saveActive();
|
|
309
|
+
mgr.shutdown();
|
|
310
|
+
|
|
311
|
+
const daemon2 = makeDaemon(grooveDir);
|
|
312
|
+
const mgr2 = new TunnelManager(daemon2);
|
|
313
|
+
mgr2.healthTimeout = 400;
|
|
314
|
+
await mgr2._readopt();
|
|
315
|
+
|
|
316
|
+
assert.equal(mgr2.active.has('s19'), false, 'a dead pid is not adopted');
|
|
317
|
+
mgr2.shutdown();
|
|
318
|
+
} finally { live.close(); }
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('shutdown persists state and does not kill the tunnel process', async () => {
|
|
322
|
+
const live = await startHealthyListener();
|
|
323
|
+
try {
|
|
324
|
+
mgr.saved.set('s19', { id: 's19', name: 'S19', host: 'x', user: 'u', port: 22 });
|
|
325
|
+
mgr.active.set('s19', {
|
|
326
|
+
pid: process.pid, localPort: live.port,
|
|
327
|
+
startedAt: new Date().toISOString(), healthy: true, failCount: 0,
|
|
328
|
+
});
|
|
329
|
+
mgr.shutdown();
|
|
330
|
+
|
|
331
|
+
// Our stand-in "tunnel process" (this test runner) must still be alive —
|
|
332
|
+
// shutdown killing it would have killed the test.
|
|
333
|
+
assert.doesNotThrow(() => process.kill(process.pid, 0));
|
|
334
|
+
const persisted = JSON.parse(readFileSync(resolve(grooveDir, 'tunnels-active.json'), 'utf8'));
|
|
335
|
+
assert.equal(persisted.length, 1);
|
|
336
|
+
assert.equal(persisted[0].id, 's19');
|
|
337
|
+
assert.equal(persisted[0].localPort, live.port);
|
|
338
|
+
} finally { live.close(); }
|
|
339
|
+
});
|
|
340
|
+
|
|
243
341
|
it('_waitForPortFree reports a released port', async () => {
|
|
244
342
|
const live = await startHealthyListener();
|
|
245
343
|
assert.equal(await mgr._waitForPortFree(live.port, 600), false, 'still held while listening');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.214",
|
|
4
4
|
"description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
|
|
@@ -26,6 +26,10 @@ const CONFIRM_TIMEOUT = 15000;
|
|
|
26
26
|
// At most one automatic rebuild per tunnel per window; beyond that it stays
|
|
27
27
|
// disconnected rather than thrashing against a host that keeps dying.
|
|
28
28
|
const REBUILD_COOLDOWN_MS = 10 * 60 * 1000;
|
|
29
|
+
// Restarting a crashed REMOTE daemon is cheaper and safer than a rebuild, so
|
|
30
|
+
// its cooldown is shorter — but still bounded: a daemon that dies right after
|
|
31
|
+
// every start has a real problem more starts won't fix.
|
|
32
|
+
const REMOTE_START_COOLDOWN_MS = 2 * 60 * 1000;
|
|
29
33
|
|
|
30
34
|
const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
|
|
31
35
|
|
|
@@ -60,6 +64,10 @@ export class TunnelManager {
|
|
|
60
64
|
constructor(daemon) {
|
|
61
65
|
this.daemon = daemon;
|
|
62
66
|
this.remotesPath = resolve(daemon.grooveDir, 'remotes.json');
|
|
67
|
+
// Live tunnel state, persisted separately from the configs so a daemon
|
|
68
|
+
// restart can re-adopt still-running ssh processes instead of forgetting
|
|
69
|
+
// them (the configs file is user data; this is runtime state).
|
|
70
|
+
this.activePath = resolve(daemon.grooveDir, 'tunnels-active.json');
|
|
63
71
|
this.saved = new Map();
|
|
64
72
|
this.active = new Map();
|
|
65
73
|
this._healthInterval = null;
|
|
@@ -88,8 +96,9 @@ export class TunnelManager {
|
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
async init() {
|
|
99
|
+
await this._readopt();
|
|
91
100
|
for (const [id, config] of this.saved) {
|
|
92
|
-
if (config.autoConnect) {
|
|
101
|
+
if (config.autoConnect && !this.active.has(id)) {
|
|
93
102
|
try {
|
|
94
103
|
await this.connect(id);
|
|
95
104
|
} catch (err) {
|
|
@@ -99,6 +108,71 @@ export class TunnelManager {
|
|
|
99
108
|
}
|
|
100
109
|
}
|
|
101
110
|
|
|
111
|
+
// Re-adopt tunnels whose detached ssh processes survived a daemon restart.
|
|
112
|
+
// Without this, every daemon/app restart orphaned the ssh (or shutdown killed
|
|
113
|
+
// it) and the new daemon started amnesiac — remote windows died mid-session
|
|
114
|
+
// and the user had to reconnect everything by hand.
|
|
115
|
+
async _readopt() {
|
|
116
|
+
let entries = [];
|
|
117
|
+
try {
|
|
118
|
+
if (existsSync(this.activePath)) entries = JSON.parse(readFileSync(this.activePath, 'utf8'));
|
|
119
|
+
} catch { /* corrupt — treat as none */ }
|
|
120
|
+
if (!Array.isArray(entries) || entries.length === 0) return;
|
|
121
|
+
|
|
122
|
+
let adopted = 0;
|
|
123
|
+
for (const e of entries) {
|
|
124
|
+
if (!e?.id || !e.pid || !e.localPort || !this.saved.has(e.id)) continue;
|
|
125
|
+
// Only re-adopt what is provably OUR ssh still doing THIS job: the pid
|
|
126
|
+
// must be alive, be an ssh process forwarding this port, and the port
|
|
127
|
+
// must serve HTTP.
|
|
128
|
+
let alive = false;
|
|
129
|
+
try { process.kill(e.pid, 0); alive = true; } catch { /* gone */ }
|
|
130
|
+
if (alive) alive = this._looksLikeOurSsh(e.pid, e.localPort);
|
|
131
|
+
if (alive && await this._tunnelResponds(e.localPort)) {
|
|
132
|
+
this.active.set(e.id, {
|
|
133
|
+
pid: e.pid,
|
|
134
|
+
localPort: e.localPort,
|
|
135
|
+
startedAt: e.startedAt || new Date().toISOString(),
|
|
136
|
+
lastPing: Date.now(),
|
|
137
|
+
latencyMs: null,
|
|
138
|
+
healthy: true,
|
|
139
|
+
failCount: 0,
|
|
140
|
+
});
|
|
141
|
+
adopted++;
|
|
142
|
+
const name = this.saved.get(e.id)?.name || e.id;
|
|
143
|
+
console.log(`[Groove:Tunnel] Re-adopted live tunnel to ${name} on port ${e.localPort}`);
|
|
144
|
+
this.daemon.broadcast({ type: 'tunnel.connected', data: { id: e.id, name, localPort: e.localPort, host: this.saved.get(e.id)?.host, url: `http://localhost:${e.localPort}?instance=${encodeURIComponent(name)}` } });
|
|
145
|
+
} else if (alive) {
|
|
146
|
+
// ssh survives but doesn't serve — a corpse from before the restart.
|
|
147
|
+
try { process.kill(e.pid, 'SIGTERM'); } catch { /* gone */ }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (adopted > 0 && !this._healthInterval) {
|
|
151
|
+
this._healthInterval = setInterval(() => this._healthCheckAll(), HEALTH_INTERVAL);
|
|
152
|
+
}
|
|
153
|
+
this._saveActive();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Identity check for re-adoption: is this pid an ssh forwarding this port?
|
|
157
|
+
// Guards against pid recycling handing us an unrelated process.
|
|
158
|
+
_looksLikeOurSsh(pid, localPort) {
|
|
159
|
+
try {
|
|
160
|
+
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
161
|
+
encoding: 'utf8', timeout: 3000,
|
|
162
|
+
}).trim();
|
|
163
|
+
return cmd.includes('ssh') && cmd.includes(String(localPort));
|
|
164
|
+
} catch { return false; }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_saveActive() {
|
|
168
|
+
try {
|
|
169
|
+
const entries = [...this.active.entries()].map(([id, c]) => ({
|
|
170
|
+
id, pid: c.pid, localPort: c.localPort, startedAt: c.startedAt,
|
|
171
|
+
}));
|
|
172
|
+
writeFileSync(this.activePath, JSON.stringify(entries, null, 2), { mode: 0o600 });
|
|
173
|
+
} catch { /* best effort */ }
|
|
174
|
+
}
|
|
175
|
+
|
|
102
176
|
getSaved() {
|
|
103
177
|
return Array.from(this.saved.values()).map(s => ({
|
|
104
178
|
...this._sanitize(s),
|
|
@@ -406,6 +480,7 @@ export class TunnelManager {
|
|
|
406
480
|
healthy: true,
|
|
407
481
|
failCount: 0,
|
|
408
482
|
});
|
|
483
|
+
this._saveActive();
|
|
409
484
|
|
|
410
485
|
// Verify daemon is reachable through tunnel, start if needed
|
|
411
486
|
let remoteAlive = false;
|
|
@@ -496,6 +571,7 @@ export class TunnelManager {
|
|
|
496
571
|
if (localPort) await this._waitForPortFree(localPort, 3000);
|
|
497
572
|
|
|
498
573
|
this.active.delete(id);
|
|
574
|
+
this._saveActive();
|
|
499
575
|
|
|
500
576
|
const config = this.saved.get(id);
|
|
501
577
|
this.daemon.audit.log('tunnel.disconnect', { id, name: config?.name });
|
|
@@ -972,6 +1048,12 @@ export class TunnelManager {
|
|
|
972
1048
|
conn.failCount = 0;
|
|
973
1049
|
conn.healthy = true;
|
|
974
1050
|
conn._wedgedStreak = 0;
|
|
1051
|
+
} else if (verdict === 'remote-down') {
|
|
1052
|
+
// The tunnel is carrying traffic correctly — the far daemon is what
|
|
1053
|
+
// died (typically mid-upgrade). Rebuild would be useless; start it.
|
|
1054
|
+
conn.healthy = false;
|
|
1055
|
+
this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
|
|
1056
|
+
await this._startRemoteDaemon(id, conn);
|
|
975
1057
|
} else {
|
|
976
1058
|
conn.healthy = false;
|
|
977
1059
|
this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
|
|
@@ -995,19 +1077,71 @@ export class TunnelManager {
|
|
|
995
1077
|
}
|
|
996
1078
|
|
|
997
1079
|
// Escalating evidence that a tunnel is actually dead, not merely slow:
|
|
998
|
-
// 'alive'
|
|
999
|
-
// 'proc-dead'
|
|
1000
|
-
// 'port-dead'
|
|
1001
|
-
// '
|
|
1080
|
+
// 'alive' — answered a long-timeout HTTP probe; leave it alone
|
|
1081
|
+
// 'proc-dead' — the ssh client process is gone
|
|
1082
|
+
// 'port-dead' — nothing is listening on the local port
|
|
1083
|
+
// 'remote-down' — the tunnel forwards fine but the REMOTE end refuses:
|
|
1084
|
+
// the probe fails fast with a connection error, not a
|
|
1085
|
+
// timeout. Killing the tunnel won't fix that — the remote
|
|
1086
|
+
// daemon needs starting (e.g. it died during an upgrade).
|
|
1087
|
+
// 'wedged' — port accepts TCP but HTTP hangs to timeout (dead forward)
|
|
1002
1088
|
async _confirmDead(conn) {
|
|
1003
|
-
|
|
1089
|
+
const started = Date.now();
|
|
1090
|
+
let probeErr = null;
|
|
1091
|
+
try {
|
|
1092
|
+
const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
|
|
1093
|
+
signal: AbortSignal.timeout(this.confirmTimeout ?? CONFIRM_TIMEOUT),
|
|
1094
|
+
});
|
|
1095
|
+
if (res.ok) return 'alive';
|
|
1096
|
+
} catch (err) { probeErr = err; }
|
|
1097
|
+
|
|
1004
1098
|
if (conn.pid) {
|
|
1005
1099
|
try { process.kill(conn.pid, 0); } catch { return 'proc-dead'; }
|
|
1006
1100
|
}
|
|
1007
1101
|
if (!(await this._isPortInUse(conn.localPort))) return 'port-dead';
|
|
1102
|
+
|
|
1103
|
+
// ssh is alive and its port listens. A hang (timeout) means the forward is
|
|
1104
|
+
// dead; a FAST connection-level error means ssh relayed the remote side's
|
|
1105
|
+
// refusal — the tunnel works, the far daemon doesn't.
|
|
1106
|
+
const failedFast = Date.now() - started < 2000;
|
|
1107
|
+
const timedOut = probeErr && (probeErr.name === 'TimeoutError' || probeErr.name === 'AbortError');
|
|
1108
|
+
if (failedFast && !timedOut) return 'remote-down';
|
|
1008
1109
|
return 'wedged';
|
|
1009
1110
|
}
|
|
1010
1111
|
|
|
1112
|
+
// The tunnel is healthy; the daemon on the far side is what's down. Start it
|
|
1113
|
+
// over ssh rather than pointlessly rebuilding the tunnel. Rate-limited: if
|
|
1114
|
+
// the remote daemon won't stay up, repeated starts won't save it.
|
|
1115
|
+
async _startRemoteDaemon(id, conn) {
|
|
1116
|
+
this._remoteStartAt = this._remoteStartAt || new Map();
|
|
1117
|
+
const last = this._remoteStartAt.get(id) || 0;
|
|
1118
|
+
if (Date.now() - last < REMOTE_START_COOLDOWN_MS) return;
|
|
1119
|
+
this._remoteStartAt.set(id, Date.now());
|
|
1120
|
+
|
|
1121
|
+
console.log(`[Groove:Tunnel] ${id}: tunnel is fine but the remote daemon is down — starting it`);
|
|
1122
|
+
this.daemon.audit.log('tunnel.remote-daemon-start', { id });
|
|
1123
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
|
|
1124
|
+
try {
|
|
1125
|
+
await this.autoStart(id);
|
|
1126
|
+
// Confirm it came up; a success resets the failure counters immediately
|
|
1127
|
+
// instead of waiting out another health cycle.
|
|
1128
|
+
for (let i = 0; i < 10; i++) {
|
|
1129
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1130
|
+
if (await this._tunnelResponds(conn.localPort)) {
|
|
1131
|
+
conn.failCount = 0;
|
|
1132
|
+
conn.healthy = true;
|
|
1133
|
+
conn._wedgedStreak = 0;
|
|
1134
|
+
console.log(`[Groove:Tunnel] ${id}: remote daemon is back`);
|
|
1135
|
+
this.daemon.broadcast({ type: 'tunnel.health', data: { id, latencyMs: conn.latencyMs, healthy: true } });
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
console.warn(`[Groove:Tunnel] ${id}: remote daemon did not come back after start`);
|
|
1140
|
+
} catch (err) {
|
|
1141
|
+
console.warn(`[Groove:Tunnel] ${id}: could not start remote daemon: ${err.message}`);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1011
1145
|
// Tear down a confirmed-dead tunnel and immediately rebuild it on the SAME
|
|
1012
1146
|
// local port. The remote GUI window points at that port and its WebSocket
|
|
1013
1147
|
// retries every 2s, so a same-port rebuild heals an open window without the
|
|
@@ -1075,25 +1209,17 @@ export class TunnelManager {
|
|
|
1075
1209
|
throw new Error(`No available local port found (tried ${DEFAULT_LOCAL_PORT}-${DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS - 1})`);
|
|
1076
1210
|
}
|
|
1077
1211
|
|
|
1212
|
+
// Deliberately does NOT kill the ssh processes. They are spawned detached and
|
|
1213
|
+
// are the user's live sessions: killing them on every daemon restart (app
|
|
1214
|
+
// upgrade, promote, crash) is what nuked remote windows mid-session. State is
|
|
1215
|
+
// persisted; the next daemon re-adopts whatever is still alive and serving.
|
|
1216
|
+
// Explicit disconnect()/delete() remain the paths that actually kill a tunnel.
|
|
1078
1217
|
shutdown() {
|
|
1079
1218
|
if (this._healthInterval) {
|
|
1080
1219
|
clearInterval(this._healthInterval);
|
|
1081
1220
|
this._healthInterval = null;
|
|
1082
1221
|
}
|
|
1083
|
-
|
|
1084
|
-
try {
|
|
1085
|
-
const conn = this.active.get(id);
|
|
1086
|
-
if (conn?.pid) {
|
|
1087
|
-
const cmd = execFileSync('ps', ['-p', String(conn.pid), '-o', 'command='], {
|
|
1088
|
-
encoding: 'utf8',
|
|
1089
|
-
timeout: 3000,
|
|
1090
|
-
}).trim();
|
|
1091
|
-
if (cmd.includes('ssh')) {
|
|
1092
|
-
process.kill(conn.pid, 'SIGTERM');
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
} catch { /* ignore */ }
|
|
1096
|
-
}
|
|
1222
|
+
this._saveActive();
|
|
1097
1223
|
this.active.clear();
|
|
1098
1224
|
}
|
|
1099
1225
|
}
|