groove-dev 0.27.8 → 0.27.11
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/daemon/src/api.js +460 -25
- package/node_modules/@groove-dev/daemon/src/index.js +7 -0
- package/node_modules/@groove-dev/daemon/src/introducer.js +72 -4
- package/node_modules/@groove-dev/daemon/src/journalist.js +66 -11
- package/node_modules/@groove-dev/daemon/src/process.js +67 -7
- package/node_modules/@groove-dev/daemon/src/registry.js +1 -1
- package/node_modules/@groove-dev/daemon/src/repo-import.js +541 -0
- package/node_modules/@groove-dev/daemon/src/rotator.js +28 -1
- package/node_modules/@groove-dev/daemon/src/supervisor.js +2 -1
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +504 -0
- package/node_modules/@groove-dev/daemon/src/validate.js +13 -0
- package/node_modules/@groove-dev/daemon/test/journalist.test.js +5 -4
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +4 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-BE6lYcd7.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-zdzOLAZM.js +677 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/src/app.css +14 -0
- package/node_modules/@groove-dev/gui/src/app.jsx +13 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +130 -1
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/agents/agent-mdfiles.jsx +43 -1
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +141 -1
- package/node_modules/@groove-dev/gui/src/components/dashboard/fleet-panel.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/dashboard/intel-panel.jsx +4 -4
- package/node_modules/@groove-dev/gui/src/components/dashboard/routing-chart.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +4 -4
- package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +7 -1
- package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +26 -8
- package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +14 -4
- package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +46 -11
- package/node_modules/@groove-dev/gui/src/components/marketplace/repo-card.jsx +64 -0
- package/node_modules/@groove-dev/gui/src/components/marketplace/repo-import.jsx +363 -0
- package/node_modules/@groove-dev/gui/src/components/marketplace/repo-nuke-dialog.jsx +68 -0
- package/node_modules/@groove-dev/gui/src/components/pro/pro-gate.jsx +22 -0
- package/node_modules/@groove-dev/gui/src/components/pro/upgrade-card.jsx +48 -0
- package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +129 -0
- package/node_modules/@groove-dev/gui/src/components/settings/remote-server-card.jsx +243 -0
- package/node_modules/@groove-dev/gui/src/components/settings/server-dialog.jsx +192 -0
- package/node_modules/@groove-dev/gui/src/components/ui/approval-modal.jsx +63 -0
- package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/lib/edition.js +4 -0
- package/node_modules/@groove-dev/gui/src/lib/electron.js +17 -0
- package/node_modules/@groove-dev/gui/src/lib/status.js +1 -0
- package/node_modules/@groove-dev/gui/src/stores/groove.js +139 -6
- package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +38 -39
- package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +82 -0
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +66 -0
- package/node_modules/@groove-dev/gui/vite.config.js +3 -0
- package/package.json +7 -2
- package/packages/daemon/src/api.js +460 -25
- package/packages/daemon/src/index.js +7 -0
- package/packages/daemon/src/introducer.js +72 -4
- package/packages/daemon/src/journalist.js +66 -11
- package/packages/daemon/src/process.js +67 -7
- package/packages/daemon/src/registry.js +1 -1
- package/packages/daemon/src/repo-import.js +541 -0
- package/packages/daemon/src/rotator.js +28 -1
- package/packages/daemon/src/supervisor.js +2 -1
- package/packages/daemon/src/tunnel-manager.js +504 -0
- package/packages/daemon/src/validate.js +13 -0
- package/packages/gui/dist/assets/index-BE6lYcd7.css +1 -0
- package/packages/gui/dist/assets/index-zdzOLAZM.js +677 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/node_modules/.vite/deps/@codemirror_lang-html.js +3 -3
- package/packages/gui/node_modules/.vite/deps/@codemirror_lang-javascript.js +2 -2
- package/packages/gui/node_modules/.vite/deps/@codemirror_lang-markdown.js +3 -3
- package/packages/gui/node_modules/.vite/deps/@codemirror_lang-python.js +5 -5
- package/packages/gui/node_modules/.vite/deps/@radix-ui_react-dialog.js +3 -3
- package/packages/gui/node_modules/.vite/deps/@radix-ui_react-scroll-area.js +1 -1
- package/packages/gui/node_modules/.vite/deps/@radix-ui_react-tabs.js +5 -5
- package/packages/gui/node_modules/.vite/deps/@radix-ui_react-tooltip.js +3 -3
- package/packages/gui/node_modules/.vite/deps/_metadata.json +53 -53
- package/packages/gui/node_modules/.vite/deps/{chunk-WYSQD5ZG.js → chunk-DH7AESXW.js} +2 -2
- package/packages/gui/node_modules/.vite/deps/{chunk-KXLIKZFX.js → chunk-GFE3G4IN.js} +133 -133
- package/packages/gui/node_modules/.vite/deps/chunk-GFE3G4IN.js.map +7 -0
- package/packages/gui/node_modules/.vite/deps/{chunk-3LBP22MX.js → chunk-LKZVMLRH.js} +6 -6
- package/packages/gui/node_modules/.vite/deps/{chunk-J6DMOQWP.js → chunk-MCVDVNE5.js} +2 -2
- package/packages/gui/node_modules/.vite/deps/{chunk-3Q7HT7ZF.js → chunk-SPKVQGZX.js} +6 -6
- package/packages/gui/src/app.css +14 -0
- package/packages/gui/src/app.jsx +13 -0
- package/packages/gui/src/components/agents/agent-config.jsx +130 -1
- package/packages/gui/src/components/agents/agent-feed.jsx +2 -2
- package/packages/gui/src/components/agents/agent-mdfiles.jsx +43 -1
- package/packages/gui/src/components/agents/spawn-wizard.jsx +141 -1
- package/packages/gui/src/components/dashboard/fleet-panel.jsx +3 -3
- package/packages/gui/src/components/dashboard/intel-panel.jsx +4 -4
- package/packages/gui/src/components/dashboard/routing-chart.jsx +3 -3
- package/packages/gui/src/components/dashboard/team-burn-panel.jsx +1 -1
- package/packages/gui/src/components/layout/activity-bar.jsx +4 -4
- package/packages/gui/src/components/layout/app-shell.jsx +7 -1
- package/packages/gui/src/components/layout/breadcrumb-bar.jsx +26 -8
- package/packages/gui/src/components/layout/command-palette.jsx +14 -4
- package/packages/gui/src/components/layout/status-bar.jsx +46 -11
- package/packages/gui/src/components/marketplace/repo-card.jsx +64 -0
- package/packages/gui/src/components/marketplace/repo-import.jsx +363 -0
- package/packages/gui/src/components/marketplace/repo-nuke-dialog.jsx +68 -0
- package/packages/gui/src/components/pro/pro-gate.jsx +22 -0
- package/packages/gui/src/components/pro/upgrade-card.jsx +48 -0
- package/packages/gui/src/components/settings/quick-connect.jsx +129 -0
- package/packages/gui/src/components/settings/remote-server-card.jsx +243 -0
- package/packages/gui/src/components/settings/server-dialog.jsx +192 -0
- package/packages/gui/src/components/ui/approval-modal.jsx +63 -0
- package/packages/gui/src/components/ui/toast.jsx +1 -1
- package/packages/gui/src/lib/edition.js +4 -0
- package/packages/gui/src/lib/electron.js +17 -0
- package/packages/gui/src/lib/status.js +1 -0
- package/packages/gui/src/stores/groove.js +139 -6
- package/packages/gui/src/views/dashboard.jsx +38 -39
- package/packages/gui/src/views/marketplace.jsx +82 -0
- package/packages/gui/src/views/settings.jsx +66 -0
- package/packages/gui/vite.config.js +3 -0
- package/integrations/FEDERATION_PLAN.md +0 -583
- package/integrations/VOICE_PLAN.md +0 -232
- package/node_modules/@groove-dev/gui/dist/assets/index-CwmR3-HY.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-DiCjVtQL.js +0 -652
- package/packages/gui/dist/assets/index-CwmR3-HY.css +0 -1
- package/packages/gui/dist/assets/index-DiCjVtQL.js +0 -652
- package/packages/gui/node_modules/.vite/deps/chunk-KXLIKZFX.js.map +0 -7
- package/test-slack.mjs +0 -28
- /package/packages/gui/node_modules/.vite/deps/{chunk-WYSQD5ZG.js.map → chunk-DH7AESXW.js.map} +0 -0
- /package/packages/gui/node_modules/.vite/deps/{chunk-3LBP22MX.js.map → chunk-LKZVMLRH.js.map} +0 -0
- /package/packages/gui/node_modules/.vite/deps/{chunk-J6DMOQWP.js.map → chunk-MCVDVNE5.js.map} +0 -0
- /package/packages/gui/node_modules/.vite/deps/{chunk-3Q7HT7ZF.js.map → chunk-SPKVQGZX.js.map} +0 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
// GROOVE — Tunnel Manager (SSH remote access)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { execFileSync, spawn } from 'child_process';
|
|
5
|
+
import { existsSync, writeFileSync, readFileSync, statSync } from 'fs';
|
|
6
|
+
import { resolve } from 'path';
|
|
7
|
+
import { createConnection } from 'net';
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
|
|
10
|
+
const REMOTE_PORT = 31415;
|
|
11
|
+
const DEFAULT_LOCAL_PORT = 31416;
|
|
12
|
+
const MAX_PORT_ATTEMPTS = 10;
|
|
13
|
+
const HEALTH_INTERVAL = 30000;
|
|
14
|
+
const HEALTH_TIMEOUT = 5000;
|
|
15
|
+
const MAX_FAIL_COUNT = 3;
|
|
16
|
+
|
|
17
|
+
const INJECTION_CHARS = /[;|&`$(){}[\]<>!#\n\r\\]/;
|
|
18
|
+
|
|
19
|
+
function validateField(value, name) {
|
|
20
|
+
if (!value || typeof value !== 'string' || !value.trim()) {
|
|
21
|
+
throw new Error(`${name} is required`);
|
|
22
|
+
}
|
|
23
|
+
if (INJECTION_CHARS.test(value)) {
|
|
24
|
+
throw new Error(`Invalid characters in ${name}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class TunnelManager {
|
|
29
|
+
constructor(daemon) {
|
|
30
|
+
this.daemon = daemon;
|
|
31
|
+
this.remotesPath = resolve(daemon.grooveDir, 'remotes.json');
|
|
32
|
+
this.saved = new Map();
|
|
33
|
+
this.active = new Map();
|
|
34
|
+
this._healthInterval = null;
|
|
35
|
+
this._load();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_load() {
|
|
39
|
+
try {
|
|
40
|
+
if (existsSync(this.remotesPath)) {
|
|
41
|
+
const data = JSON.parse(readFileSync(this.remotesPath, 'utf8'));
|
|
42
|
+
if (Array.isArray(data)) {
|
|
43
|
+
for (const entry of data) {
|
|
44
|
+
if (entry && entry.id) this.saved.set(entry.id, entry);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch { /* ignore corrupt file */ }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_save() {
|
|
52
|
+
writeFileSync(
|
|
53
|
+
this.remotesPath,
|
|
54
|
+
JSON.stringify(Array.from(this.saved.values()), null, 2),
|
|
55
|
+
{ mode: 0o600 }
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getSaved() {
|
|
60
|
+
return Array.from(this.saved.values()).map(s => ({
|
|
61
|
+
...s,
|
|
62
|
+
active: this.active.has(s.id),
|
|
63
|
+
...(this.active.get(s.id) || {}),
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
save({ name, host, user, port, sshKeyPath, autoStart, autoConnect }) {
|
|
68
|
+
validateField(name, 'name');
|
|
69
|
+
validateField(host, 'host');
|
|
70
|
+
validateField(user, 'user');
|
|
71
|
+
|
|
72
|
+
const p = port != null ? Number(port) : 22;
|
|
73
|
+
if (!Number.isInteger(p) || p < 1 || p > 65535) {
|
|
74
|
+
throw new Error('port must be a number between 1 and 65535');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (sshKeyPath) {
|
|
78
|
+
if (!existsSync(sshKeyPath)) {
|
|
79
|
+
throw new Error(`SSH key not found: ${sshKeyPath}`);
|
|
80
|
+
}
|
|
81
|
+
if (!statSync(sshKeyPath).isFile()) {
|
|
82
|
+
throw new Error('sshKeyPath must be a file, not a directory');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const id = crypto.randomUUID().slice(0, 8);
|
|
87
|
+
const entry = {
|
|
88
|
+
id,
|
|
89
|
+
name: name.trim(),
|
|
90
|
+
host: host.trim(),
|
|
91
|
+
user: user.trim(),
|
|
92
|
+
port: p,
|
|
93
|
+
sshKeyPath: sshKeyPath || null,
|
|
94
|
+
autoStart: !!autoStart,
|
|
95
|
+
autoConnect: !!autoConnect,
|
|
96
|
+
createdAt: new Date().toISOString(),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
this.saved.set(id, entry);
|
|
100
|
+
this._save();
|
|
101
|
+
this.daemon.audit.log('tunnel.save', { id, name: entry.name, host: entry.host });
|
|
102
|
+
return entry;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
update(id, config) {
|
|
106
|
+
const existing = this.saved.get(id);
|
|
107
|
+
if (!existing) throw new Error(`Remote ${id} not found`);
|
|
108
|
+
|
|
109
|
+
const merged = { ...existing };
|
|
110
|
+
|
|
111
|
+
if (config.name !== undefined) {
|
|
112
|
+
validateField(config.name, 'name');
|
|
113
|
+
merged.name = config.name.trim();
|
|
114
|
+
}
|
|
115
|
+
if (config.host !== undefined) {
|
|
116
|
+
validateField(config.host, 'host');
|
|
117
|
+
merged.host = config.host.trim();
|
|
118
|
+
}
|
|
119
|
+
if (config.user !== undefined) {
|
|
120
|
+
validateField(config.user, 'user');
|
|
121
|
+
merged.user = config.user.trim();
|
|
122
|
+
}
|
|
123
|
+
if (config.port !== undefined) {
|
|
124
|
+
const p = Number(config.port);
|
|
125
|
+
if (!Number.isInteger(p) || p < 1 || p > 65535) {
|
|
126
|
+
throw new Error('port must be a number between 1 and 65535');
|
|
127
|
+
}
|
|
128
|
+
merged.port = p;
|
|
129
|
+
}
|
|
130
|
+
if (config.sshKeyPath !== undefined) {
|
|
131
|
+
if (config.sshKeyPath) {
|
|
132
|
+
if (!existsSync(config.sshKeyPath)) {
|
|
133
|
+
throw new Error(`SSH key not found: ${config.sshKeyPath}`);
|
|
134
|
+
}
|
|
135
|
+
if (!statSync(config.sshKeyPath).isFile()) {
|
|
136
|
+
throw new Error('sshKeyPath must be a file, not a directory');
|
|
137
|
+
}
|
|
138
|
+
merged.sshKeyPath = config.sshKeyPath;
|
|
139
|
+
} else {
|
|
140
|
+
merged.sshKeyPath = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (config.autoStart !== undefined) merged.autoStart = !!config.autoStart;
|
|
144
|
+
if (config.autoConnect !== undefined) merged.autoConnect = !!config.autoConnect;
|
|
145
|
+
|
|
146
|
+
this.saved.set(id, merged);
|
|
147
|
+
this._save();
|
|
148
|
+
this.daemon.audit.log('tunnel.update', { id, keys: Object.keys(config) });
|
|
149
|
+
return merged;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
delete(id) {
|
|
153
|
+
if (!this.saved.has(id)) throw new Error(`Remote ${id} not found`);
|
|
154
|
+
if (this.active.has(id)) this.disconnect(id);
|
|
155
|
+
const name = this.saved.get(id).name;
|
|
156
|
+
this.saved.delete(id);
|
|
157
|
+
this._save();
|
|
158
|
+
this.daemon.audit.log('tunnel.delete', { id, name });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async test(id) {
|
|
162
|
+
const config = this.saved.get(id);
|
|
163
|
+
if (!config) throw new Error(`Remote ${id} not found`);
|
|
164
|
+
|
|
165
|
+
const target = `${config.user}@${config.host}`;
|
|
166
|
+
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const result = execFileSync('ssh', [
|
|
170
|
+
...keyArgs,
|
|
171
|
+
'-p', String(config.port || 22),
|
|
172
|
+
'-o', 'ConnectTimeout=10',
|
|
173
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
174
|
+
'-o', 'BatchMode=yes',
|
|
175
|
+
target,
|
|
176
|
+
`curl -sf http://localhost:${REMOTE_PORT}/api/health 2>/dev/null || (which groove >/dev/null 2>&1 && echo __GROOVE_STOPPED__ || echo __GROOVE_NOT_INSTALLED__)`,
|
|
177
|
+
], {
|
|
178
|
+
encoding: 'utf8',
|
|
179
|
+
timeout: 20000,
|
|
180
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (result.includes('__GROOVE_NOT_INSTALLED__')) {
|
|
184
|
+
return { reachable: true, daemonRunning: false, grooveInstalled: false };
|
|
185
|
+
}
|
|
186
|
+
if (result.includes('__GROOVE_STOPPED__')) {
|
|
187
|
+
return { reachable: true, daemonRunning: false, grooveInstalled: true };
|
|
188
|
+
}
|
|
189
|
+
return { reachable: true, daemonRunning: true, grooveInstalled: true };
|
|
190
|
+
} catch (err) {
|
|
191
|
+
const stderr = err.stderr?.toString() || '';
|
|
192
|
+
if (stderr.includes('Permission denied')) {
|
|
193
|
+
return { reachable: false, error: 'SSH authentication failed' };
|
|
194
|
+
}
|
|
195
|
+
if (stderr.includes('Connection refused') || stderr.includes('Connection timed out') || stderr.includes('No route to host')) {
|
|
196
|
+
return { reachable: false, error: 'Host unreachable' };
|
|
197
|
+
}
|
|
198
|
+
return { reachable: false, error: err.message };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async connect(id) {
|
|
203
|
+
const config = this.saved.get(id);
|
|
204
|
+
if (!config) throw new Error(`Remote ${id} not found`);
|
|
205
|
+
|
|
206
|
+
if (this.active.has(id)) {
|
|
207
|
+
const existing = this.active.get(id);
|
|
208
|
+
return { localPort: existing.localPort, pid: existing.pid };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const testResult = await this.test(id);
|
|
212
|
+
if (!testResult.reachable) {
|
|
213
|
+
throw new Error(testResult.error || 'Host unreachable');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!testResult.daemonRunning && !testResult.grooveInstalled) {
|
|
217
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'installing' } });
|
|
218
|
+
await this.remoteInstall(id);
|
|
219
|
+
} else if (!testResult.daemonRunning && testResult.grooveInstalled) {
|
|
220
|
+
this.daemon.broadcast({ type: 'tunnel.status', data: { id, step: 'starting' } });
|
|
221
|
+
await this.autoStart(id);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const localPort = await this._findAvailablePort();
|
|
225
|
+
const target = `${config.user}@${config.host}`;
|
|
226
|
+
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
227
|
+
|
|
228
|
+
const sshArgs = [
|
|
229
|
+
'-N',
|
|
230
|
+
'-L', `127.0.0.1:${localPort}:localhost:${REMOTE_PORT}`,
|
|
231
|
+
'-p', String(config.port || 22),
|
|
232
|
+
'-o', 'ServerAliveInterval=30',
|
|
233
|
+
'-o', 'ServerAliveCountMax=3',
|
|
234
|
+
'-o', 'ExitOnForwardFailure=yes',
|
|
235
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
236
|
+
...keyArgs,
|
|
237
|
+
target,
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
const tunnel = spawn('ssh', sshArgs, {
|
|
241
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
242
|
+
detached: true,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
let stderrBuf = '';
|
|
246
|
+
tunnel.stderr.on('data', (chunk) => { stderrBuf += chunk.toString(); });
|
|
247
|
+
|
|
248
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
249
|
+
|
|
250
|
+
if (tunnel.exitCode !== null) {
|
|
251
|
+
throw new Error(`Tunnel failed to start: ${stderrBuf.trim() || 'unknown error'}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const tunnelUp = await this._isPortInUse(localPort);
|
|
255
|
+
if (!tunnelUp) {
|
|
256
|
+
try { process.kill(tunnel.pid); } catch { /* ignore */ }
|
|
257
|
+
throw new Error('Tunnel started but port forward not active');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
tunnel.unref();
|
|
261
|
+
|
|
262
|
+
this.active.set(id, {
|
|
263
|
+
pid: tunnel.pid,
|
|
264
|
+
localPort,
|
|
265
|
+
startedAt: new Date().toISOString(),
|
|
266
|
+
lastPing: Date.now(),
|
|
267
|
+
latencyMs: null,
|
|
268
|
+
healthy: true,
|
|
269
|
+
failCount: 0,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const url = `http://localhost:${localPort}?instance=${encodeURIComponent(config.name)}`;
|
|
273
|
+
|
|
274
|
+
this.daemon.audit.log('tunnel.connect', { id, name: config.name, host: config.host, localPort });
|
|
275
|
+
this.daemon.broadcast({ type: 'tunnel.connected', data: { id, name: config.name, localPort, host: config.host, url } });
|
|
276
|
+
|
|
277
|
+
if (!this._healthInterval) {
|
|
278
|
+
this._healthInterval = setInterval(() => this._healthCheckAll(), HEALTH_INTERVAL);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return { localPort, pid: tunnel.pid, name: config.name, url };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async disconnect(id) {
|
|
285
|
+
const conn = this.active.get(id);
|
|
286
|
+
if (!conn) return;
|
|
287
|
+
|
|
288
|
+
const { pid } = conn;
|
|
289
|
+
try {
|
|
290
|
+
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
291
|
+
encoding: 'utf8',
|
|
292
|
+
timeout: 3000,
|
|
293
|
+
}).trim();
|
|
294
|
+
if (cmd.includes('ssh')) {
|
|
295
|
+
process.kill(pid, 'SIGTERM');
|
|
296
|
+
}
|
|
297
|
+
} catch { /* process already dead */ }
|
|
298
|
+
|
|
299
|
+
this.active.delete(id);
|
|
300
|
+
|
|
301
|
+
const config = this.saved.get(id);
|
|
302
|
+
this.daemon.audit.log('tunnel.disconnect', { id, name: config?.name });
|
|
303
|
+
this.daemon.broadcast({ type: 'tunnel.disconnected', data: { id, name: config?.name } });
|
|
304
|
+
|
|
305
|
+
if (this.active.size === 0 && this._healthInterval) {
|
|
306
|
+
clearInterval(this._healthInterval);
|
|
307
|
+
this._healthInterval = null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async autoStart(id) {
|
|
312
|
+
const config = this.saved.get(id);
|
|
313
|
+
if (!config) throw new Error(`Remote ${id} not found`);
|
|
314
|
+
|
|
315
|
+
const target = `${config.user}@${config.host}`;
|
|
316
|
+
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
const result = execFileSync('ssh', [
|
|
320
|
+
...keyArgs,
|
|
321
|
+
'-p', String(config.port || 22),
|
|
322
|
+
'-o', 'ConnectTimeout=10',
|
|
323
|
+
'-o', 'BatchMode=yes',
|
|
324
|
+
target,
|
|
325
|
+
`bash -lc 'nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/health > /dev/null && echo __DAEMON_OK__ || echo __DAEMON_FAIL__'`,
|
|
326
|
+
], {
|
|
327
|
+
encoding: 'utf8',
|
|
328
|
+
timeout: 45000,
|
|
329
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (result.includes('__DAEMON_FAIL__')) {
|
|
333
|
+
throw new Error('Daemon process started but health check failed — check /tmp/groove-daemon.log on remote');
|
|
334
|
+
}
|
|
335
|
+
} catch (err) {
|
|
336
|
+
if (err.message.includes('Daemon process started')) throw err;
|
|
337
|
+
const output = err.stdout?.toString() || err.stderr?.toString() || err.message;
|
|
338
|
+
throw new Error(`Failed to start remote daemon: ${output.slice(-300)}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async remoteInstall(id) {
|
|
343
|
+
const config = this.saved.get(id);
|
|
344
|
+
if (!config) throw new Error(`Remote ${id} not found`);
|
|
345
|
+
|
|
346
|
+
const target = `${config.user}@${config.host}`;
|
|
347
|
+
const keyArgs = config.sshKeyPath ? ['-i', config.sshKeyPath] : [];
|
|
348
|
+
const sshBase = [
|
|
349
|
+
...keyArgs,
|
|
350
|
+
'-p', String(config.port || 22),
|
|
351
|
+
'-o', 'ConnectTimeout=10',
|
|
352
|
+
'-o', 'BatchMode=yes',
|
|
353
|
+
target,
|
|
354
|
+
];
|
|
355
|
+
|
|
356
|
+
// Non-interactive SSH doesn't source shell profiles, so npm/node may not be on PATH.
|
|
357
|
+
// Use a login shell (-l) to get the user's full environment.
|
|
358
|
+
const remoteCmd = (cmd) => `bash -lc '${cmd}'`;
|
|
359
|
+
|
|
360
|
+
// Step 1: Check if node/npm are available
|
|
361
|
+
try {
|
|
362
|
+
const check = execFileSync('ssh', [
|
|
363
|
+
...sshBase,
|
|
364
|
+
remoteCmd('which node && which npm || echo __NO_NODE__'),
|
|
365
|
+
], {
|
|
366
|
+
encoding: 'utf8',
|
|
367
|
+
timeout: 20000,
|
|
368
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
if (check.includes('__NO_NODE__')) {
|
|
372
|
+
throw new Error('Node.js is not installed on the remote server. Install Node.js 20+ first, then retry.');
|
|
373
|
+
}
|
|
374
|
+
} catch (err) {
|
|
375
|
+
if (err.message.includes('Node.js is not installed')) throw err;
|
|
376
|
+
throw new Error(`Failed to check remote environment: ${err.message}`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Step 2: Install groove-dev globally (use sudo if not root)
|
|
380
|
+
const installCmd = config.user === 'root'
|
|
381
|
+
? 'npm i -g groove-dev'
|
|
382
|
+
: 'sudo npm i -g groove-dev';
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
execFileSync('ssh', [
|
|
386
|
+
...sshBase,
|
|
387
|
+
remoteCmd(installCmd),
|
|
388
|
+
], {
|
|
389
|
+
encoding: 'utf8',
|
|
390
|
+
timeout: 120000,
|
|
391
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
392
|
+
});
|
|
393
|
+
} catch (err) {
|
|
394
|
+
const output = err.stdout?.toString() || err.stderr?.toString() || err.message;
|
|
395
|
+
throw new Error(`npm install failed: ${output.slice(-400)}`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Step 3: Start the daemon in background
|
|
399
|
+
try {
|
|
400
|
+
const result = execFileSync('ssh', [
|
|
401
|
+
...sshBase,
|
|
402
|
+
remoteCmd(`nohup groove start > /tmp/groove-daemon.log 2>&1 < /dev/null & disown; sleep 4; curl -sf http://localhost:${REMOTE_PORT}/api/health > /dev/null && echo __DAEMON_OK__ || echo __DAEMON_FAIL__`),
|
|
403
|
+
], {
|
|
404
|
+
encoding: 'utf8',
|
|
405
|
+
timeout: 45000,
|
|
406
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
if (result.includes('__DAEMON_FAIL__')) {
|
|
410
|
+
throw new Error('Groove installed but daemon failed to start — check /tmp/groove-daemon.log on remote');
|
|
411
|
+
}
|
|
412
|
+
} catch (err) {
|
|
413
|
+
if (err.message.includes('Groove installed')) throw err;
|
|
414
|
+
const output = err.stdout?.toString() || err.stderr?.toString() || err.message;
|
|
415
|
+
throw new Error(`Groove installed but failed to start: ${output.slice(-300)}`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const verify = await this.test(id);
|
|
419
|
+
return { installed: verify.grooveInstalled, daemonRunning: verify.daemonRunning };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
getStatus(id) {
|
|
423
|
+
const saved = this.saved.get(id);
|
|
424
|
+
if (!saved) return null;
|
|
425
|
+
const active = this.active.get(id);
|
|
426
|
+
return { ...saved, active: !!active, ...(active || {}) };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
getActive() {
|
|
430
|
+
return Array.from(this.active.entries()).map(([id, conn]) => ({
|
|
431
|
+
...conn,
|
|
432
|
+
...(this.saved.get(id) || {}),
|
|
433
|
+
id,
|
|
434
|
+
}));
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async _healthCheckAll() {
|
|
438
|
+
for (const [id, conn] of this.active) {
|
|
439
|
+
try {
|
|
440
|
+
const start = Date.now();
|
|
441
|
+
const res = await fetch(`http://localhost:${conn.localPort}/api/health`, {
|
|
442
|
+
signal: AbortSignal.timeout(HEALTH_TIMEOUT),
|
|
443
|
+
});
|
|
444
|
+
if (res.ok) {
|
|
445
|
+
conn.latencyMs = Date.now() - start;
|
|
446
|
+
conn.lastPing = Date.now();
|
|
447
|
+
conn.healthy = true;
|
|
448
|
+
conn.failCount = 0;
|
|
449
|
+
} else {
|
|
450
|
+
throw new Error('unhealthy response');
|
|
451
|
+
}
|
|
452
|
+
} catch {
|
|
453
|
+
conn.failCount = (conn.failCount || 0) + 1;
|
|
454
|
+
if (conn.failCount >= MAX_FAIL_COUNT) {
|
|
455
|
+
conn.healthy = false;
|
|
456
|
+
this.daemon.broadcast({ type: 'tunnel.unhealthy', data: { id } });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
this.daemon.broadcast({
|
|
460
|
+
type: 'tunnel.health',
|
|
461
|
+
data: { id, latencyMs: conn.latencyMs, healthy: conn.healthy },
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
_isPortInUse(port) {
|
|
467
|
+
return new Promise((resolve) => {
|
|
468
|
+
const conn = createConnection({ host: '127.0.0.1', port });
|
|
469
|
+
conn.setTimeout(3000);
|
|
470
|
+
conn.on('connect', () => { conn.destroy(); resolve(true); });
|
|
471
|
+
conn.on('error', () => resolve(false));
|
|
472
|
+
conn.on('timeout', () => { conn.destroy(); resolve(false); });
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async _findAvailablePort() {
|
|
477
|
+
for (let port = DEFAULT_LOCAL_PORT; port < DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS; port++) {
|
|
478
|
+
if (!(await this._isPortInUse(port))) return port;
|
|
479
|
+
}
|
|
480
|
+
throw new Error(`No available local port found (tried ${DEFAULT_LOCAL_PORT}-${DEFAULT_LOCAL_PORT + MAX_PORT_ATTEMPTS - 1})`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
shutdown() {
|
|
484
|
+
if (this._healthInterval) {
|
|
485
|
+
clearInterval(this._healthInterval);
|
|
486
|
+
this._healthInterval = null;
|
|
487
|
+
}
|
|
488
|
+
for (const [id] of this.active) {
|
|
489
|
+
try {
|
|
490
|
+
const conn = this.active.get(id);
|
|
491
|
+
if (conn?.pid) {
|
|
492
|
+
const cmd = execFileSync('ps', ['-p', String(conn.pid), '-o', 'command='], {
|
|
493
|
+
encoding: 'utf8',
|
|
494
|
+
timeout: 3000,
|
|
495
|
+
}).trim();
|
|
496
|
+
if (cmd.includes('ssh')) {
|
|
497
|
+
process.kill(conn.pid, 'SIGTERM');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
} catch { /* ignore */ }
|
|
501
|
+
}
|
|
502
|
+
this.active.clear();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
@@ -75,10 +75,21 @@ export function validateAgentConfig(config) {
|
|
|
75
75
|
integrations = config.integrations.filter((s) => typeof s === 'string' && s.length > 0 && s.length <= 100);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// Validate repos (array of import IDs)
|
|
79
|
+
let repos = [];
|
|
80
|
+
if (config.repos !== undefined && config.repos !== null) {
|
|
81
|
+
if (!Array.isArray(config.repos)) {
|
|
82
|
+
throw new Error('Repos must be an array');
|
|
83
|
+
}
|
|
84
|
+
repos = config.repos.filter((s) => typeof s === 'string' && s.length > 0 && s.length <= 100);
|
|
85
|
+
}
|
|
86
|
+
|
|
78
87
|
// Validate integration approval mode
|
|
79
88
|
const validApprovalModes = ['auto', 'manual'];
|
|
80
89
|
const integrationApproval = validApprovalModes.includes(config.integrationApproval) ? config.integrationApproval : 'manual';
|
|
81
90
|
|
|
91
|
+
const personality = (typeof config.personality === 'string' && config.personality.length > 0 && config.personality.length <= 64 && NAME_PATTERN.test(config.personality)) ? config.personality : undefined;
|
|
92
|
+
|
|
82
93
|
// Return sanitized config (only known fields)
|
|
83
94
|
return {
|
|
84
95
|
role: config.role,
|
|
@@ -93,6 +104,8 @@ export function validateAgentConfig(config) {
|
|
|
93
104
|
skills,
|
|
94
105
|
integrations,
|
|
95
106
|
integrationApproval,
|
|
107
|
+
repos,
|
|
108
|
+
personality,
|
|
96
109
|
};
|
|
97
110
|
}
|
|
98
111
|
|
|
@@ -45,11 +45,12 @@ describe('Journalist', () => {
|
|
|
45
45
|
},
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
const entries = journalist.filterLog(logLine, { name: 'test' });
|
|
48
|
+
const { entries, explorationEntries } = journalist.filterLog(logLine, { name: 'test' });
|
|
49
49
|
assert.equal(entries.length, 1);
|
|
50
50
|
assert.equal(entries[0].type, 'tool');
|
|
51
51
|
assert.equal(entries[0].tool, 'Write');
|
|
52
52
|
assert.equal(entries[0].input, 'src/api/auth.js');
|
|
53
|
+
assert.equal(explorationEntries.length, 0);
|
|
53
54
|
});
|
|
54
55
|
|
|
55
56
|
it('should skip non-JSON lines (transient errors are noise)', () => {
|
|
@@ -57,7 +58,7 @@ describe('Journalist', () => {
|
|
|
57
58
|
const journalist = new Journalist(daemon);
|
|
58
59
|
|
|
59
60
|
// Non-JSON error lines are dropped to prevent context degradation
|
|
60
|
-
const entries = journalist.filterLog('Error: something broke\nTypeError: undefined is not a function', {});
|
|
61
|
+
const { entries } = journalist.filterLog('Error: something broke\nTypeError: undefined is not a function', {});
|
|
61
62
|
assert.equal(entries.length, 0);
|
|
62
63
|
});
|
|
63
64
|
|
|
@@ -73,7 +74,7 @@ describe('Journalist', () => {
|
|
|
73
74
|
total_cost_usd: 0.15,
|
|
74
75
|
});
|
|
75
76
|
|
|
76
|
-
const entries = journalist.filterLog(logLine, {});
|
|
77
|
+
const { entries } = journalist.filterLog(logLine, {});
|
|
77
78
|
assert.equal(entries.length, 1);
|
|
78
79
|
assert.equal(entries[0].type, 'result');
|
|
79
80
|
assert.ok(entries[0].text.includes('Task completed'));
|
|
@@ -84,7 +85,7 @@ describe('Journalist', () => {
|
|
|
84
85
|
const { daemon } = createMockDaemon();
|
|
85
86
|
const journalist = new Journalist(daemon);
|
|
86
87
|
|
|
87
|
-
const entries = journalist.filterLog(
|
|
88
|
+
const { entries } = journalist.filterLog(
|
|
88
89
|
'[2026-04-04] GROOVE spawning: claude ...\n\nnot json\n',
|
|
89
90
|
{}
|
|
90
91
|
);
|
|
@@ -26,10 +26,13 @@ describe('Rotator', () => {
|
|
|
26
26
|
async spawn(config) { return { id: 'new-' + config.role, name: config.name, ...config }; },
|
|
27
27
|
},
|
|
28
28
|
journalist: {
|
|
29
|
-
async generateHandoffBrief(agent) {
|
|
29
|
+
async generateHandoffBrief(agent, options = {}) {
|
|
30
30
|
return `Handoff brief for ${agent.name}`;
|
|
31
31
|
},
|
|
32
32
|
},
|
|
33
|
+
memory: {
|
|
34
|
+
appendHandoffBrief() { return true; },
|
|
35
|
+
},
|
|
33
36
|
adaptive: {
|
|
34
37
|
extractSignals() { return { errorCount: 0, repetitions: 0, scopeViolations: 0, toolCalls: 0, toolFailures: 0, filesWritten: 0 }; },
|
|
35
38
|
recordSession() { return { score: 70, threshold: 0.75, converged: false }; },
|