groove-dev 0.27.48 → 0.27.50
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/CLAUDE.md +0 -7
- package/default/security-review-prompt.md +98 -0
- 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/api.js +309 -24
- package/node_modules/@groove-dev/daemon/src/firstrun.js +1 -1
- package/node_modules/@groove-dev/daemon/src/index.js +7 -0
- package/node_modules/@groove-dev/daemon/src/providers/groove-network.js +47 -7
- package/node_modules/@groove-dev/gui/dist/assets/{index-B9oPxmNj.js → index-Dd4u8X70.js} +1723 -1723
- package/node_modules/@groove-dev/gui/dist/index.html +1 -1
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/network/network-status.jsx +12 -0
- package/node_modules/@groove-dev/gui/src/components/network/node-toggle.jsx +18 -15
- package/node_modules/@groove-dev/gui/src/stores/groove.js +128 -1
- package/node_modules/@groove-dev/gui/src/views/network.jsx +82 -2
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +309 -24
- package/packages/daemon/src/firstrun.js +1 -1
- package/packages/daemon/src/index.js +7 -0
- package/packages/daemon/src/providers/groove-network.js +47 -7
- package/packages/gui/dist/assets/{index-B9oPxmNj.js → index-Dd4u8X70.js} +1723 -1723
- package/packages/gui/dist/index.html +1 -1
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/network/network-status.jsx +12 -0
- package/packages/gui/src/components/network/node-toggle.jsx +18 -15
- package/packages/gui/src/stores/groove.js +128 -1
- package/packages/gui/src/views/network.jsx +82 -2
package/CLAUDE.md
CHANGED
|
@@ -263,10 +263,3 @@ Audit-driven release. Multi-agent orchestration system with 7 coordination layer
|
|
|
263
263
|
- Dashboard: routing donut, cache panel, context health gauges
|
|
264
264
|
- Monitor/QC agent mode (stay active, loop)
|
|
265
265
|
- Distribution: demo video, HN launch, Twitter content
|
|
266
|
-
|
|
267
|
-
<!-- GROOVE:START -->
|
|
268
|
-
## GROOVE Orchestration (auto-injected)
|
|
269
|
-
Active agents: 0
|
|
270
|
-
See AGENTS_REGISTRY.md for full agent state.
|
|
271
|
-
**Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.
|
|
272
|
-
<!-- GROOVE:END -->
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Security Review: Groove Network Integration
|
|
2
|
+
|
|
3
|
+
You are auditing the Groove Network feature — a decentralized LLM inference network integrated into the Groove desktop app. This feature is beta-gated behind invite codes. Review all network-related code for security vulnerabilities and attack vectors.
|
|
4
|
+
|
|
5
|
+
## Scope
|
|
6
|
+
|
|
7
|
+
All code added in these commits (newest first):
|
|
8
|
+
- daemon: switch network from relay to signal service
|
|
9
|
+
- gui: handle signal_connected and matched WS events in network feed
|
|
10
|
+
- daemon: spawn Python from venv so msgpack/torch are available
|
|
11
|
+
- daemon: use python3.12 for brew Python
|
|
12
|
+
- network: wire install/uninstall endpoints with progress broadcast
|
|
13
|
+
- network: gate view on network package install
|
|
14
|
+
- beta: validate invite codes against groovedev.ai with offline fallback
|
|
15
|
+
- settings: hide groove-network provider from Settings UI
|
|
16
|
+
- provider: default claude-code to Opus 4.6
|
|
17
|
+
- network: add beta-gated Groove Network integration
|
|
18
|
+
|
|
19
|
+
## Files to Review
|
|
20
|
+
|
|
21
|
+
Daemon (packages/daemon/src/):
|
|
22
|
+
- api.js — search for "Network" section (~lines 3720-4200): beta gate, invite code validation, node start/stop, install/uninstall, network status, version check/update endpoints
|
|
23
|
+
- providers/groove-network.js — provider that spawns Python consumer subprocess
|
|
24
|
+
- providers/index.js — provider registration
|
|
25
|
+
- index.js — networkNode state, startup re-validation
|
|
26
|
+
- firstrun.js — networkBeta config defaults
|
|
27
|
+
|
|
28
|
+
GUI (packages/gui/src/):
|
|
29
|
+
- views/network.jsx — install gate, main network view
|
|
30
|
+
- views/settings.jsx — Early Access invite code section
|
|
31
|
+
- stores/groove.js — networkUnlocked state, install/update progress, WebSocket handlers
|
|
32
|
+
- components/network/* — NodeToggle, NetworkStatus, NodeDetails
|
|
33
|
+
- components/layout/activity-bar.jsx — conditional Globe icon
|
|
34
|
+
|
|
35
|
+
## Threat Model
|
|
36
|
+
|
|
37
|
+
The feature involves:
|
|
38
|
+
1. An invite code system validating against a remote server (groovedev.ai)
|
|
39
|
+
2. Cloning a GitHub repo to the user's machine and running a setup script
|
|
40
|
+
3. Spawning Python subprocesses that connect outbound to a signal service (signal.groovedev.ai)
|
|
41
|
+
4. WebSocket connections to an external service carrying msgpack-encoded inference data
|
|
42
|
+
5. Ethereum-style keypair stored at ~/.groove/node_key.json
|
|
43
|
+
6. Config persistence with unlocked state, codes, and paths
|
|
44
|
+
|
|
45
|
+
## Attack Vectors to Investigate
|
|
46
|
+
|
|
47
|
+
### Code Execution & Injection
|
|
48
|
+
- Can the install endpoint be tricked into cloning a malicious repo? (repo URL hardcoded or configurable?)
|
|
49
|
+
- Does setup.sh run with proper sandboxing? What if the repo contents are compromised?
|
|
50
|
+
- Are Python spawn commands safe from injection? (check all spawn() calls use array args, not shell strings)
|
|
51
|
+
- Can the deployPath config value be manipulated to point outside ~/.groove/?
|
|
52
|
+
- Is the git clone --depth 1 safe from git-specific attacks?
|
|
53
|
+
|
|
54
|
+
### Authentication & Authorization
|
|
55
|
+
- Can the beta gate be bypassed? (check all /api/network/* endpoints go through networkGate)
|
|
56
|
+
- Is the hardcoded fallback code list a risk? (codes visible in source)
|
|
57
|
+
- Can invite codes be brute-forced? (rate limiting — daemon side and server side)
|
|
58
|
+
- Is the machineId derivation stable and non-spoofable?
|
|
59
|
+
- Can a deactivated user re-activate by manipulating local config?
|
|
60
|
+
|
|
61
|
+
### Network Security
|
|
62
|
+
- Is the WebSocket connection to signal.groovedev.ai using TLS? (wss:// vs ws://)
|
|
63
|
+
- Can a MITM intercept inference data between node and signal?
|
|
64
|
+
- Is the signal URL validated? Can it be pointed to a malicious server via config manipulation?
|
|
65
|
+
- Are there SSRF risks from the network status fetch?
|
|
66
|
+
- Does the daemon properly validate responses from groovedev.ai and signal.groovedev.ai?
|
|
67
|
+
|
|
68
|
+
### Data Security
|
|
69
|
+
- Is the node keypair (~/.groove/node_key.json) properly protected? (file permissions)
|
|
70
|
+
- Is the private key ever exposed via API endpoints or logs?
|
|
71
|
+
- Are invite codes logged in full or truncated?
|
|
72
|
+
- Can the API credentials endpoint leak the beta code?
|
|
73
|
+
- Is config.networkBeta.code exposed in GET /api/config?
|
|
74
|
+
|
|
75
|
+
### Denial of Service
|
|
76
|
+
- Can the install/update endpoints be called repeatedly to exhaust disk?
|
|
77
|
+
- Is there a limit on networkEvents array growth?
|
|
78
|
+
- Can the node process be spawned multiple times simultaneously?
|
|
79
|
+
- Does the version check cache actually prevent GitHub API abuse?
|
|
80
|
+
|
|
81
|
+
### Filesystem Safety
|
|
82
|
+
- Does uninstall properly scope deletion to ~/.groove/network/?
|
|
83
|
+
- Can path traversal in deployPath escape the intended directory?
|
|
84
|
+
- Are there symlink attacks possible in the install path?
|
|
85
|
+
- Does rmSync with recursive: true risk deleting unintended files?
|
|
86
|
+
|
|
87
|
+
### Supply Chain
|
|
88
|
+
- The install clones from GitHub — is the repo URL hardcoded or injectable?
|
|
89
|
+
- Is the tag pinned or can an attacker push a malicious tag?
|
|
90
|
+
- setup.sh runs arbitrary shell — what if the repo is compromised?
|
|
91
|
+
|
|
92
|
+
## Deliverables
|
|
93
|
+
|
|
94
|
+
1. List every vulnerability found with severity (critical/high/medium/low)
|
|
95
|
+
2. For each: describe the attack, the affected file and line number, and the fix
|
|
96
|
+
3. Flag any patterns that are risky even if not immediately exploitable
|
|
97
|
+
4. Confirm which security measures are already in place and working correctly
|
|
98
|
+
5. Commit all fixes
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
3
|
|
|
4
4
|
import express from 'express';
|
|
5
|
-
import { resolve, dirname } from 'path';
|
|
5
|
+
import { resolve, dirname, join } from 'path';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
7
|
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, unlinkSync, renameSync, rmSync, createReadStream, copyFileSync, realpathSync } from 'fs';
|
|
8
8
|
import { spawn, execFile } from 'child_process';
|
|
@@ -12,6 +12,7 @@ import { lookup as mimeLookup } from './mimetypes.js';
|
|
|
12
12
|
import { listProviders, getProvider } from './providers/index.js';
|
|
13
13
|
import { OllamaProvider } from './providers/ollama.js';
|
|
14
14
|
import { ClaudeCodeProvider } from './providers/claude-code.js';
|
|
15
|
+
import { supportsSignalFlag, compareSemver, parseSemver } from './providers/groove-network.js';
|
|
15
16
|
import { validateAgentConfig } from './validate.js';
|
|
16
17
|
import { ROLE_INTEGRATIONS } from './process.js';
|
|
17
18
|
|
|
@@ -3674,7 +3675,13 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
3674
3675
|
// --- Config ---
|
|
3675
3676
|
|
|
3676
3677
|
app.get('/api/config', (req, res) => {
|
|
3677
|
-
|
|
3678
|
+
const cfg = daemon.config || {};
|
|
3679
|
+
const sanitized = { ...cfg };
|
|
3680
|
+
if (sanitized.networkBeta) {
|
|
3681
|
+
sanitized.networkBeta = { ...sanitized.networkBeta };
|
|
3682
|
+
delete sanitized.networkBeta.code;
|
|
3683
|
+
}
|
|
3684
|
+
res.json(sanitized);
|
|
3678
3685
|
});
|
|
3679
3686
|
|
|
3680
3687
|
app.patch('/api/config', async (req, res) => {
|
|
@@ -3724,18 +3731,26 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
3724
3731
|
|
|
3725
3732
|
// --- Groove Network (Beta) ---
|
|
3726
3733
|
|
|
3727
|
-
// Offline fallback allowlist —
|
|
3728
|
-
//
|
|
3729
|
-
const
|
|
3730
|
-
'
|
|
3731
|
-
'
|
|
3732
|
-
'
|
|
3733
|
-
'
|
|
3734
|
-
'
|
|
3734
|
+
// Offline fallback allowlist — SHA-256 hashes of valid codes so plaintext
|
|
3735
|
+
// codes aren't exposed in source. Used only when groovedev.ai is unreachable.
|
|
3736
|
+
const BETA_CODES_FALLBACK_HASHES = new Set([
|
|
3737
|
+
'2dd41c615fd155f322e8381fed28f346ed6592e2bbab1c068f156fa225c02110',
|
|
3738
|
+
'034d771385b608bb85d8f0225c561fe3c084b8ce7851221b01f9c2226dfe3e7b',
|
|
3739
|
+
'fad2c7b09f9161db518d8c9a8d338831eb3894ef0f36e2c7cb1884cffbb05768',
|
|
3740
|
+
'0ff4c9c1d224e59ac370d6f4bf315ae2ec750af014758c8206f38980cb7603ba',
|
|
3741
|
+
'08b2ffe7f40afe2894db335860d67af877fa31201b3e2c25736480eb3f7c58ef',
|
|
3735
3742
|
]);
|
|
3736
3743
|
|
|
3744
|
+
function hashCode(code) {
|
|
3745
|
+
return createHash('sha256').update(code).digest('hex');
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3737
3748
|
const BETA_VALIDATE_URL = 'https://groovedev.ai/api/beta/validate';
|
|
3738
3749
|
|
|
3750
|
+
const betaAttempts = [];
|
|
3751
|
+
const BETA_RATE_LIMIT = 5;
|
|
3752
|
+
const BETA_RATE_WINDOW_MS = 60_000;
|
|
3753
|
+
|
|
3739
3754
|
function getMachineId() {
|
|
3740
3755
|
const nets = networkInterfaces();
|
|
3741
3756
|
const macs = [];
|
|
@@ -3790,6 +3805,13 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
3790
3805
|
});
|
|
3791
3806
|
|
|
3792
3807
|
app.post('/api/beta/activate', async (req, res) => {
|
|
3808
|
+
const now = Date.now();
|
|
3809
|
+
while (betaAttempts.length && betaAttempts[0] < now - BETA_RATE_WINDOW_MS) betaAttempts.shift();
|
|
3810
|
+
if (betaAttempts.length >= BETA_RATE_LIMIT) {
|
|
3811
|
+
return res.status(429).json({ error: 'Too many attempts. Try again in a minute.' });
|
|
3812
|
+
}
|
|
3813
|
+
betaAttempts.push(now);
|
|
3814
|
+
|
|
3793
3815
|
const { code } = req.body || {};
|
|
3794
3816
|
if (typeof code !== 'string' || code.length > 64 || !/^[A-Z0-9-]+$/.test(code)) {
|
|
3795
3817
|
return res.status(400).json({ error: 'Invalid code format' });
|
|
@@ -3811,9 +3833,9 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
3811
3833
|
}
|
|
3812
3834
|
if (Array.isArray(remote.result.features)) features = remote.result.features;
|
|
3813
3835
|
} else {
|
|
3814
|
-
// Offline fallback — only trust the
|
|
3836
|
+
// Offline fallback — only trust the hashed list when we can't reach the server
|
|
3815
3837
|
source = 'fallback';
|
|
3816
|
-
if (
|
|
3838
|
+
if (BETA_CODES_FALLBACK_HASHES.has(hashCode(code))) {
|
|
3817
3839
|
valid = true;
|
|
3818
3840
|
message = 'Activated (offline)';
|
|
3819
3841
|
features = ['network-node', 'network-consumer'];
|
|
@@ -3977,7 +3999,10 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
3977
3999
|
}
|
|
3978
4000
|
|
|
3979
4001
|
const cfg = daemon.config.networkBeta || {};
|
|
3980
|
-
const
|
|
4002
|
+
const signal = cfg.signalUrl || 'signal.groovedev.ai';
|
|
4003
|
+
if (!isAllowedSignalHost(signal)) {
|
|
4004
|
+
return res.status(400).json({ error: 'Invalid signal host' });
|
|
4005
|
+
}
|
|
3981
4006
|
const device = cfg.devicePreference || 'auto';
|
|
3982
4007
|
const maxContext = Number.isFinite(cfg.maxContext) ? cfg.maxContext : 4096;
|
|
3983
4008
|
|
|
@@ -3993,16 +4018,17 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
3993
4018
|
return res.status(400).json({ error: `Deploy path not found: ${deployPath}` });
|
|
3994
4019
|
}
|
|
3995
4020
|
|
|
4021
|
+
const signalFlag = supportsSignalFlag(cfg.version) ? '--signal' : '--relay';
|
|
3996
4022
|
const args = [
|
|
3997
4023
|
'-m', 'src.node.server',
|
|
3998
|
-
|
|
4024
|
+
signalFlag, signal,
|
|
3999
4025
|
'--device', device,
|
|
4000
4026
|
'--max-context', String(maxContext),
|
|
4001
4027
|
];
|
|
4002
4028
|
|
|
4003
4029
|
let proc;
|
|
4004
4030
|
try {
|
|
4005
|
-
proc = spawn('python3.12', args, {
|
|
4031
|
+
proc = spawn(join(deployPath, 'venv', 'bin', 'python3.12'), args, {
|
|
4006
4032
|
cwd: deployPath,
|
|
4007
4033
|
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
4008
4034
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -4025,7 +4051,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4025
4051
|
events: [],
|
|
4026
4052
|
};
|
|
4027
4053
|
|
|
4028
|
-
pushNodeEvent('starting', { pid: proc.pid,
|
|
4054
|
+
pushNodeEvent('starting', { pid: proc.pid, signal, device });
|
|
4029
4055
|
broadcastNodeStatus();
|
|
4030
4056
|
|
|
4031
4057
|
let stderrBuf = '';
|
|
@@ -4090,7 +4116,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4090
4116
|
broadcastNodeStatus();
|
|
4091
4117
|
});
|
|
4092
4118
|
|
|
4093
|
-
daemon.audit.log('network.node.start', { pid: proc.pid,
|
|
4119
|
+
daemon.audit.log('network.node.start', { pid: proc.pid, signal, device });
|
|
4094
4120
|
res.status(202).json({ started: true, ...snapshotNode() });
|
|
4095
4121
|
});
|
|
4096
4122
|
|
|
@@ -4111,9 +4137,35 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4111
4137
|
res.json({ stopping: true });
|
|
4112
4138
|
});
|
|
4113
4139
|
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4140
|
+
function isAllowedSignalHost(host) {
|
|
4141
|
+
const h = (host || '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').toLowerCase();
|
|
4142
|
+
return h === 'signal.groovedev.ai' || h.endsWith('.groovedev.ai');
|
|
4143
|
+
}
|
|
4144
|
+
|
|
4145
|
+
app.get('/api/network/status', networkGate, async (req, res) => {
|
|
4146
|
+
const cfg = daemon.config.networkBeta || {};
|
|
4147
|
+
const signalHost = cfg.signalUrl || 'signal.groovedev.ai';
|
|
4148
|
+
|
|
4149
|
+
if (!isAllowedSignalHost(signalHost)) {
|
|
4150
|
+
return res.status(400).json({ error: 'Invalid signal host' });
|
|
4151
|
+
}
|
|
4152
|
+
|
|
4153
|
+
const statusUrl = /^https?:\/\//i.test(signalHost)
|
|
4154
|
+
? `${signalHost.replace(/\/$/, '')}/status`
|
|
4155
|
+
: `https://${signalHost}/status`;
|
|
4156
|
+
|
|
4157
|
+
try {
|
|
4158
|
+
const controller = new AbortController();
|
|
4159
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
4160
|
+
const r = await fetch(statusUrl, { signal: controller.signal });
|
|
4161
|
+
clearTimeout(timer);
|
|
4162
|
+
if (r.ok) {
|
|
4163
|
+
const data = await r.json();
|
|
4164
|
+
return res.json(data);
|
|
4165
|
+
}
|
|
4166
|
+
} catch { /* fall through to local snapshot */ }
|
|
4167
|
+
|
|
4168
|
+
// Fallback: local node snapshot when signal is unreachable.
|
|
4117
4169
|
const node = daemon.networkNode || {};
|
|
4118
4170
|
const selfNode = node.active && node.nodeId ? [{
|
|
4119
4171
|
node_id: node.nodeId,
|
|
@@ -4141,10 +4193,15 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4141
4193
|
}
|
|
4142
4194
|
|
|
4143
4195
|
// Defensive: only permit fs ops on paths that resolve inside ~/.groove/.
|
|
4196
|
+
// Uses realpathSync when the path exists to defeat symlink escapes.
|
|
4144
4197
|
function isInsideGrooveHome(target) {
|
|
4145
4198
|
const home = resolve(homedir(), '.groove') + '/';
|
|
4146
|
-
const
|
|
4147
|
-
|
|
4199
|
+
const resolved = resolve(target);
|
|
4200
|
+
let full;
|
|
4201
|
+
try { full = existsSync(resolved) ? realpathSync(resolved) + '/' : resolved + '/'; }
|
|
4202
|
+
catch { full = resolved + '/'; }
|
|
4203
|
+
const realHome = existsSync(home.slice(0, -1)) ? realpathSync(home.slice(0, -1)) + '/' : home;
|
|
4204
|
+
return full.startsWith(realHome);
|
|
4148
4205
|
}
|
|
4149
4206
|
|
|
4150
4207
|
function broadcastInstallProgress(step, message, percent) {
|
|
@@ -4217,13 +4274,15 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4217
4274
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
4218
4275
|
});
|
|
4219
4276
|
|
|
4277
|
+
const stripCredentials = (s) => s.replace(/https:\/\/[^@]+@/g, 'https://***@');
|
|
4278
|
+
|
|
4220
4279
|
let cloneErr = '';
|
|
4221
4280
|
clone.stderr.on('data', (chunk) => {
|
|
4222
4281
|
const s = chunk.toString();
|
|
4223
4282
|
cloneErr += s;
|
|
4224
4283
|
// git writes progress to stderr — relay last line as status.
|
|
4225
4284
|
const line = s.split('\n').map((l) => l.trim()).filter(Boolean).pop();
|
|
4226
|
-
if (line) broadcastInstallProgress('cloning', line, 5);
|
|
4285
|
+
if (line) broadcastInstallProgress('cloning', stripCredentials(line), 5);
|
|
4227
4286
|
});
|
|
4228
4287
|
|
|
4229
4288
|
const cloneCode = await new Promise((resolveClone) => {
|
|
@@ -4232,7 +4291,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4232
4291
|
});
|
|
4233
4292
|
|
|
4234
4293
|
if (cloneCode.code !== 0) {
|
|
4235
|
-
const hint = cloneErr.trim().split('\n').slice(-1)[0] || 'git clone failed';
|
|
4294
|
+
const hint = stripCredentials(cloneErr.trim().split('\n').slice(-1)[0] || 'git clone failed');
|
|
4236
4295
|
return fail(`Clone failed: ${hint}`);
|
|
4237
4296
|
}
|
|
4238
4297
|
|
|
@@ -4339,6 +4398,232 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4339
4398
|
res.json({ status: 'uninstalled' });
|
|
4340
4399
|
});
|
|
4341
4400
|
|
|
4401
|
+
// --- Network package update check / update ---
|
|
4402
|
+
|
|
4403
|
+
// 5-minute cache of the latest-tag lookup so startup + GUI polls don't
|
|
4404
|
+
// hammer GitHub. Shape: { latest, fetchedAt }. null until first check.
|
|
4405
|
+
let networkUpdateCache = null;
|
|
4406
|
+
const NETWORK_UPDATE_CACHE_MS = 5 * 60 * 1000;
|
|
4407
|
+
|
|
4408
|
+
// Run `git ls-remote --tags <repo>` and return the highest semver tag.
|
|
4409
|
+
// Resolves to null on git errors / network failure; caller decides how to
|
|
4410
|
+
// surface that. Uses spawn with array args — no shell interpolation.
|
|
4411
|
+
function fetchLatestNetworkTag() {
|
|
4412
|
+
return new Promise((resolvePromise) => {
|
|
4413
|
+
const proc = spawn('git', ['ls-remote', '--tags', NETWORK_REPO_URL], {
|
|
4414
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4415
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
4416
|
+
});
|
|
4417
|
+
let stdout = '';
|
|
4418
|
+
let stderr = '';
|
|
4419
|
+
proc.stdout.on('data', (c) => { stdout += c.toString(); });
|
|
4420
|
+
proc.stderr.on('data', (c) => { stderr += c.toString(); });
|
|
4421
|
+
const timeout = setTimeout(() => {
|
|
4422
|
+
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
|
|
4423
|
+
}, 10_000);
|
|
4424
|
+
proc.on('error', () => { clearTimeout(timeout); resolvePromise(null); });
|
|
4425
|
+
proc.on('close', (code) => {
|
|
4426
|
+
clearTimeout(timeout);
|
|
4427
|
+
if (code !== 0) return resolvePromise(null);
|
|
4428
|
+
const tags = [];
|
|
4429
|
+
for (const line of stdout.split('\n')) {
|
|
4430
|
+
// Format: <sha>\trefs/tags/v0.1.0 (or .../v0.1.0^{} for annotated)
|
|
4431
|
+
const m = line.match(/refs\/tags\/(v?\d+\.\d+\.\d+[^\s^]*)(?:\^\{\})?$/);
|
|
4432
|
+
if (m && parseSemver(m[1])) tags.push(m[1]);
|
|
4433
|
+
}
|
|
4434
|
+
if (tags.length === 0) return resolvePromise(null);
|
|
4435
|
+
tags.sort(compareSemver);
|
|
4436
|
+
resolvePromise(tags[tags.length - 1]);
|
|
4437
|
+
});
|
|
4438
|
+
});
|
|
4439
|
+
}
|
|
4440
|
+
|
|
4441
|
+
async function getLatestNetworkTag(force = false) {
|
|
4442
|
+
if (!force && networkUpdateCache && (Date.now() - networkUpdateCache.fetchedAt) < NETWORK_UPDATE_CACHE_MS) {
|
|
4443
|
+
return networkUpdateCache.latest;
|
|
4444
|
+
}
|
|
4445
|
+
const latest = await fetchLatestNetworkTag();
|
|
4446
|
+
if (latest) networkUpdateCache = { latest, fetchedAt: Date.now() };
|
|
4447
|
+
return latest;
|
|
4448
|
+
}
|
|
4449
|
+
|
|
4450
|
+
app.get('/api/network/update/check', networkGate, async (req, res) => {
|
|
4451
|
+
const installed = daemon.config?.networkBeta?.version || null;
|
|
4452
|
+
const force = req.query.force === '1' || req.query.force === 'true';
|
|
4453
|
+
const latest = await getLatestNetworkTag(force);
|
|
4454
|
+
if (!latest) {
|
|
4455
|
+
return res.status(502).json({
|
|
4456
|
+
installed,
|
|
4457
|
+
latest: null,
|
|
4458
|
+
updateAvailable: false,
|
|
4459
|
+
error: 'Could not reach github.com to check for updates',
|
|
4460
|
+
});
|
|
4461
|
+
}
|
|
4462
|
+
const updateAvailable = !!installed && compareSemver(latest, installed) > 0;
|
|
4463
|
+
res.json({ installed, latest, updateAvailable });
|
|
4464
|
+
});
|
|
4465
|
+
|
|
4466
|
+
function broadcastUpdateProgress(step, message, percent) {
|
|
4467
|
+
daemon.broadcast({
|
|
4468
|
+
type: 'network:update:progress',
|
|
4469
|
+
data: { step, message, percent },
|
|
4470
|
+
});
|
|
4471
|
+
}
|
|
4472
|
+
|
|
4473
|
+
app.post('/api/network/update', networkGate, async (req, res) => {
|
|
4474
|
+
if (daemon.networkInstall?.running) {
|
|
4475
|
+
return res.status(409).json({ error: 'Install/update already in progress' });
|
|
4476
|
+
}
|
|
4477
|
+
if (!daemon.config?.networkBeta?.installed) {
|
|
4478
|
+
return res.status(400).json({ error: 'Network package not installed' });
|
|
4479
|
+
}
|
|
4480
|
+
|
|
4481
|
+
const installPath = networkRoot();
|
|
4482
|
+
if (!existsSync(installPath) || !isInsideGrooveHome(installPath)) {
|
|
4483
|
+
return res.status(400).json({ error: 'Install path missing or invalid' });
|
|
4484
|
+
}
|
|
4485
|
+
|
|
4486
|
+
const latest = await getLatestNetworkTag(true);
|
|
4487
|
+
if (!latest) {
|
|
4488
|
+
return res.status(502).json({ error: 'Could not reach github.com to check for updates' });
|
|
4489
|
+
}
|
|
4490
|
+
const current = daemon.config.networkBeta.version || null;
|
|
4491
|
+
if (current && compareSemver(latest, current) <= 0) {
|
|
4492
|
+
return res.status(400).json({ error: 'Already at latest version', installed: current, latest });
|
|
4493
|
+
}
|
|
4494
|
+
|
|
4495
|
+
daemon.networkInstall = { running: true, startedAt: Date.now(), kind: 'update' };
|
|
4496
|
+
res.status(200).json({ status: 'updating', from: current, to: latest });
|
|
4497
|
+
|
|
4498
|
+
(async () => {
|
|
4499
|
+
const fail = (message) => {
|
|
4500
|
+
broadcastUpdateProgress('error', message, -1);
|
|
4501
|
+
daemon.audit.log('network.update.failed', { message, from: current, to: latest });
|
|
4502
|
+
daemon.networkInstall = { running: false };
|
|
4503
|
+
};
|
|
4504
|
+
|
|
4505
|
+
try {
|
|
4506
|
+
// Stop the running node first so we don't update files under its feet.
|
|
4507
|
+
try {
|
|
4508
|
+
const node = daemon.networkNode;
|
|
4509
|
+
if (node?.active && node.proc && !node.proc.killed) {
|
|
4510
|
+
try { node.proc.kill('SIGINT'); } catch { /* ignore */ }
|
|
4511
|
+
daemon.networkNode.status = 'stopping';
|
|
4512
|
+
pushNodeEvent('stopping', { pid: node.pid, reason: 'update' });
|
|
4513
|
+
broadcastNodeStatus();
|
|
4514
|
+
// Small grace window for the process to exit cleanly.
|
|
4515
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
4516
|
+
}
|
|
4517
|
+
} catch { /* ignore */ }
|
|
4518
|
+
|
|
4519
|
+
broadcastUpdateProgress('fetching', `Fetching ${latest}...`, 5);
|
|
4520
|
+
|
|
4521
|
+
const fetchProc = spawn('git', ['-C', installPath, 'fetch', '--tags', '--force'], {
|
|
4522
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4523
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
4524
|
+
});
|
|
4525
|
+
let fetchErr = '';
|
|
4526
|
+
fetchProc.stderr.on('data', (c) => { fetchErr += c.toString(); });
|
|
4527
|
+
const fetchCode = await new Promise((r) => {
|
|
4528
|
+
fetchProc.on('error', (e) => r({ code: -1, err: e.message }));
|
|
4529
|
+
fetchProc.on('close', (code) => r({ code }));
|
|
4530
|
+
});
|
|
4531
|
+
if (fetchCode.code !== 0) {
|
|
4532
|
+
const hint = fetchErr.trim().split('\n').slice(-1)[0] || 'git fetch failed';
|
|
4533
|
+
return fail(`Fetch failed: ${hint}`);
|
|
4534
|
+
}
|
|
4535
|
+
|
|
4536
|
+
broadcastUpdateProgress('checkout', `Checking out ${latest}...`, 20);
|
|
4537
|
+
|
|
4538
|
+
const checkoutProc = spawn('git', ['-C', installPath, 'checkout', latest], {
|
|
4539
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4540
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
4541
|
+
});
|
|
4542
|
+
let checkoutErr = '';
|
|
4543
|
+
checkoutProc.stderr.on('data', (c) => { checkoutErr += c.toString(); });
|
|
4544
|
+
const checkoutCode = await new Promise((r) => {
|
|
4545
|
+
checkoutProc.on('error', (e) => r({ code: -1, err: e.message }));
|
|
4546
|
+
checkoutProc.on('close', (code) => r({ code }));
|
|
4547
|
+
});
|
|
4548
|
+
if (checkoutCode.code !== 0) {
|
|
4549
|
+
const hint = checkoutErr.trim().split('\n').slice(-1)[0] || 'git checkout failed';
|
|
4550
|
+
return fail(`Checkout failed: ${hint}`);
|
|
4551
|
+
}
|
|
4552
|
+
|
|
4553
|
+
broadcastUpdateProgress('deps', 'Updating dependencies...', 30);
|
|
4554
|
+
|
|
4555
|
+
const setup = spawn('bash', ['setup.sh', '--json'], {
|
|
4556
|
+
cwd: installPath,
|
|
4557
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4558
|
+
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
4559
|
+
});
|
|
4560
|
+
|
|
4561
|
+
daemon.networkInstall.proc = setup;
|
|
4562
|
+
|
|
4563
|
+
let stdoutBuf = '';
|
|
4564
|
+
setup.stdout.on('data', (chunk) => {
|
|
4565
|
+
stdoutBuf += chunk.toString();
|
|
4566
|
+
let idx;
|
|
4567
|
+
while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
|
|
4568
|
+
const line = stdoutBuf.slice(0, idx).trim();
|
|
4569
|
+
stdoutBuf = stdoutBuf.slice(idx + 1);
|
|
4570
|
+
if (!line || line[0] !== '{') continue;
|
|
4571
|
+
try {
|
|
4572
|
+
const event = JSON.parse(line);
|
|
4573
|
+
const step = typeof event.step === 'string' ? event.step : 'progress';
|
|
4574
|
+
const message = typeof event.message === 'string' ? event.message : '';
|
|
4575
|
+
const percent = Number.isFinite(event.percent) ? event.percent : null;
|
|
4576
|
+
broadcastUpdateProgress(step, message, percent);
|
|
4577
|
+
} catch { /* non-JSON line, ignore */ }
|
|
4578
|
+
}
|
|
4579
|
+
});
|
|
4580
|
+
|
|
4581
|
+
let stderrBuf = '';
|
|
4582
|
+
setup.stderr.on('data', (chunk) => { stderrBuf += chunk.toString(); });
|
|
4583
|
+
|
|
4584
|
+
const setupResult = await new Promise((r) => {
|
|
4585
|
+
setup.on('error', (e) => r({ code: -1, err: e.message }));
|
|
4586
|
+
setup.on('close', (code) => r({ code }));
|
|
4587
|
+
});
|
|
4588
|
+
|
|
4589
|
+
if (setupResult.code !== 0) {
|
|
4590
|
+
const hint = stderrBuf.trim().split('\n').slice(-1)[0] || `setup.sh exited ${setupResult.code}`;
|
|
4591
|
+
return fail(`Setup failed: ${hint}`);
|
|
4592
|
+
}
|
|
4593
|
+
|
|
4594
|
+
daemon.config.networkBeta = {
|
|
4595
|
+
...(daemon.config.networkBeta || {}),
|
|
4596
|
+
version: latest,
|
|
4597
|
+
};
|
|
4598
|
+
await persistConfig();
|
|
4599
|
+
// Invalidate the update cache now that we've moved forward.
|
|
4600
|
+
networkUpdateCache = { latest, fetchedAt: Date.now() };
|
|
4601
|
+
daemon.networkUpdateAvailable = { latest, updateAvailable: false, installed: latest };
|
|
4602
|
+
daemon.broadcast({ type: 'config:updated' });
|
|
4603
|
+
daemon.broadcast({ type: 'network:update:available', data: daemon.networkUpdateAvailable });
|
|
4604
|
+
broadcastUpdateProgress('done', `Updated to ${latest}`, 100);
|
|
4605
|
+
daemon.audit.log('network.update', { from: current, to: latest, path: installPath });
|
|
4606
|
+
daemon.networkInstall = { running: false };
|
|
4607
|
+
} catch (err) {
|
|
4608
|
+
fail(err?.message || 'Update failed');
|
|
4609
|
+
}
|
|
4610
|
+
})();
|
|
4611
|
+
});
|
|
4612
|
+
|
|
4613
|
+
// Startup hook — called from index.js once the server is up. Non-blocking;
|
|
4614
|
+
// updates daemon.networkUpdateAvailable and broadcasts so the GUI can badge.
|
|
4615
|
+
daemon.checkNetworkUpdate = async function checkNetworkUpdate() {
|
|
4616
|
+
if (!daemon.config?.networkBeta?.installed) return;
|
|
4617
|
+
try {
|
|
4618
|
+
const latest = await getLatestNetworkTag(true);
|
|
4619
|
+
if (!latest) return;
|
|
4620
|
+
const installed = daemon.config.networkBeta.version || null;
|
|
4621
|
+
const updateAvailable = !!installed && compareSemver(latest, installed) > 0;
|
|
4622
|
+
daemon.networkUpdateAvailable = { installed, latest, updateAvailable };
|
|
4623
|
+
daemon.broadcast({ type: 'network:update:available', data: daemon.networkUpdateAvailable });
|
|
4624
|
+
} catch { /* non-fatal */ }
|
|
4625
|
+
};
|
|
4626
|
+
|
|
4342
4627
|
// Serve GUI static files (built GUI) — no-cache headers to prevent stale bundles
|
|
4343
4628
|
const guiPath = process.env.GROOVE_GUI_PATH || resolve(__dirname, '../../gui/dist');
|
|
4344
4629
|
app.use(express.static(guiPath, { etag: false, maxAge: 0, lastModified: false }));
|
|
@@ -523,6 +523,13 @@ export class Daemon {
|
|
|
523
523
|
this.revalidateBetaCode().catch(() => {});
|
|
524
524
|
}
|
|
525
525
|
|
|
526
|
+
// Non-blocking check for a newer Network package release. Result is
|
|
527
|
+
// stored on daemon.networkUpdateAvailable and broadcast so the GUI
|
|
528
|
+
// can show an update badge without having to poll on open.
|
|
529
|
+
if (typeof this.checkNetworkUpdate === 'function') {
|
|
530
|
+
this.checkNetworkUpdate().catch(() => {});
|
|
531
|
+
}
|
|
532
|
+
|
|
526
533
|
// Classifier broadcasting — batched into a single message per interval
|
|
527
534
|
// Also bridges classifier tier changes to the router for mid-session suggestions
|
|
528
535
|
this._classifierInterval = setInterval(() => {
|