groove-dev 0.27.49 → 0.27.51
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/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 +376 -29
- 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 +57 -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 +376 -29
- package/packages/daemon/src/firstrun.js +1 -1
- package/packages/daemon/src/index.js +7 -0
- package/packages/daemon/src/providers/groove-network.js +57 -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
|
@@ -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
|
|
@@ -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 = stripScheme(cfg.signalUrl);
|
|
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,18 @@ 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,
|
|
4025
|
+
'--tls',
|
|
3999
4026
|
'--device', device,
|
|
4000
4027
|
'--max-context', String(maxContext),
|
|
4001
4028
|
];
|
|
4002
4029
|
|
|
4003
4030
|
let proc;
|
|
4004
4031
|
try {
|
|
4005
|
-
proc = spawn(join(deployPath, 'venv', 'bin', 'python3
|
|
4032
|
+
proc = spawn(join(deployPath, 'venv', 'bin', 'python3'), args, {
|
|
4006
4033
|
cwd: deployPath,
|
|
4007
4034
|
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
4008
4035
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -4025,7 +4052,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4025
4052
|
events: [],
|
|
4026
4053
|
};
|
|
4027
4054
|
|
|
4028
|
-
pushNodeEvent('starting', { pid: proc.pid,
|
|
4055
|
+
pushNodeEvent('starting', { pid: proc.pid, signal, device });
|
|
4029
4056
|
broadcastNodeStatus();
|
|
4030
4057
|
|
|
4031
4058
|
let stderrBuf = '';
|
|
@@ -4037,7 +4064,33 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4037
4064
|
stderrBuf = stderrBuf.slice(idx + 1);
|
|
4038
4065
|
if (!line) continue;
|
|
4039
4066
|
if (line[0] !== '{') {
|
|
4067
|
+
// Python node emits plain-text logs like "Node identity: abc123",
|
|
4068
|
+
// "shard loaded: layers 0-12", "registered with signal". Parse those
|
|
4069
|
+
// here so the GUI reflects reality even without structured logging.
|
|
4070
|
+
let changed = false;
|
|
4071
|
+
const idMatch = line.match(/Node identity:\s*([A-Za-z0-9_\-:.]+)/i);
|
|
4072
|
+
if (idMatch && idMatch[1] !== daemon.networkNode.nodeId) {
|
|
4073
|
+
daemon.networkNode.nodeId = idMatch[1]; changed = true;
|
|
4074
|
+
}
|
|
4075
|
+
const layerMatch = line.match(/layers?\s*(\d+)\s*[-–to]+\s*(\d+)/i);
|
|
4076
|
+
if (layerMatch) {
|
|
4077
|
+
const start = parseInt(layerMatch[1], 10);
|
|
4078
|
+
const end = parseInt(layerMatch[2], 10);
|
|
4079
|
+
if (Number.isFinite(start) && Number.isFinite(end)) {
|
|
4080
|
+
daemon.networkNode.layers = [start, end]; changed = true;
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
const modelMatch = line.match(/model[:\s]+([A-Za-z0-9_\-./]+\/[A-Za-z0-9_\-.]+)/i);
|
|
4084
|
+
if (modelMatch && modelMatch[1] !== daemon.networkNode.model) {
|
|
4085
|
+
daemon.networkNode.model = modelMatch[1]; changed = true;
|
|
4086
|
+
}
|
|
4087
|
+
if (/\bregistered\b/i.test(line) || /\bconnected\b/i.test(line)) {
|
|
4088
|
+
if (daemon.networkNode.status !== 'connected') {
|
|
4089
|
+
daemon.networkNode.status = 'connected'; changed = true;
|
|
4090
|
+
}
|
|
4091
|
+
}
|
|
4040
4092
|
pushNodeEvent('log', { line });
|
|
4093
|
+
if (changed) broadcastNodeStatus();
|
|
4041
4094
|
continue;
|
|
4042
4095
|
}
|
|
4043
4096
|
let entry;
|
|
@@ -4090,7 +4143,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4090
4143
|
broadcastNodeStatus();
|
|
4091
4144
|
});
|
|
4092
4145
|
|
|
4093
|
-
daemon.audit.log('network.node.start', { pid: proc.pid,
|
|
4146
|
+
daemon.audit.log('network.node.start', { pid: proc.pid, signal, device });
|
|
4094
4147
|
res.status(202).json({ started: true, ...snapshotNode() });
|
|
4095
4148
|
});
|
|
4096
4149
|
|
|
@@ -4111,9 +4164,59 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4111
4164
|
res.json({ stopping: true });
|
|
4112
4165
|
});
|
|
4113
4166
|
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4167
|
+
function isAllowedSignalHost(host) {
|
|
4168
|
+
const h = (host || '').replace(/^(wss?|https?):\/\//i, '').replace(/\/.*$/, '').toLowerCase();
|
|
4169
|
+
return h === 'signal.groovedev.ai' || h.endsWith('.groovedev.ai');
|
|
4170
|
+
}
|
|
4171
|
+
|
|
4172
|
+
// The Python node/client code prepends the scheme itself from `--tls`.
|
|
4173
|
+
// Daemon must pass a BARE host to --relay/--signal; otherwise the Python
|
|
4174
|
+
// side ends up with a double-scheme URI like wss://wss://host.
|
|
4175
|
+
function stripScheme(url) {
|
|
4176
|
+
if (!url) return 'signal.groovedev.ai';
|
|
4177
|
+
return url.replace(/^wss?:\/\//i, '').replace(/\/.*$/, '');
|
|
4178
|
+
}
|
|
4179
|
+
|
|
4180
|
+
app.get('/api/network/status', networkGate, async (req, res) => {
|
|
4181
|
+
const cfg = daemon.config.networkBeta || {};
|
|
4182
|
+
const signalHost = cfg.signalUrl || 'signal.groovedev.ai';
|
|
4183
|
+
|
|
4184
|
+
if (!isAllowedSignalHost(signalHost)) {
|
|
4185
|
+
return res.status(400).json({ error: 'Invalid signal host' });
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
const bareHost = signalHost.replace(/^(wss?|https?):\/\//i, '').replace(/\/.*$/, '');
|
|
4189
|
+
const statusUrl = `https://${bareHost}/status`;
|
|
4190
|
+
|
|
4191
|
+
try {
|
|
4192
|
+
const controller = new AbortController();
|
|
4193
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
4194
|
+
const r = await fetch(statusUrl, { signal: controller.signal });
|
|
4195
|
+
clearTimeout(timer);
|
|
4196
|
+
if (r.ok) {
|
|
4197
|
+
const data = await r.json();
|
|
4198
|
+
// Signal service returns snake_case; GUI expects camelCase.
|
|
4199
|
+
const models = Array.isArray(data.models) ? data.models.map((m) => {
|
|
4200
|
+
if (!m || typeof m !== 'object') return m;
|
|
4201
|
+
const { covered_layers, total_layers, ...rest } = m;
|
|
4202
|
+
return {
|
|
4203
|
+
...rest,
|
|
4204
|
+
...(covered_layers !== undefined ? { coveredLayers: covered_layers } : {}),
|
|
4205
|
+
...(total_layers !== undefined ? { totalLayers: total_layers } : {}),
|
|
4206
|
+
};
|
|
4207
|
+
}) : [];
|
|
4208
|
+
return res.json({
|
|
4209
|
+
nodes: Array.isArray(data.nodes) ? data.nodes : [],
|
|
4210
|
+
models,
|
|
4211
|
+
coverage: data.covered_layers ?? data.coverage ?? 0,
|
|
4212
|
+
totalLayers: data.total_layers ?? data.totalLayers ?? 24,
|
|
4213
|
+
activeSessions: data.active_sessions ?? data.activeSessions ?? 0,
|
|
4214
|
+
totalNodes: data.total_nodes ?? data.totalNodes ?? (Array.isArray(data.nodes) ? data.nodes.length : 0),
|
|
4215
|
+
});
|
|
4216
|
+
}
|
|
4217
|
+
} catch { /* fall through to local snapshot */ }
|
|
4218
|
+
|
|
4219
|
+
// Fallback: local node snapshot when signal is unreachable.
|
|
4117
4220
|
const node = daemon.networkNode || {};
|
|
4118
4221
|
const selfNode = node.active && node.nodeId ? [{
|
|
4119
4222
|
node_id: node.nodeId,
|
|
@@ -4128,23 +4231,29 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4128
4231
|
coverage,
|
|
4129
4232
|
totalLayers: 24,
|
|
4130
4233
|
activeSessions: node.sessions || 0,
|
|
4234
|
+
totalNodes: selfNode.length,
|
|
4131
4235
|
});
|
|
4132
4236
|
});
|
|
4133
4237
|
|
|
4134
4238
|
// --- Network package install/uninstall ---
|
|
4135
4239
|
|
|
4136
4240
|
const NETWORK_REPO_URL = 'https://github.com/grooveai-dev/groove-network.git';
|
|
4137
|
-
const NETWORK_VERSION = 'v0.
|
|
4241
|
+
const NETWORK_VERSION = 'v0.2.0';
|
|
4138
4242
|
|
|
4139
4243
|
function networkRoot() {
|
|
4140
4244
|
return resolve(homedir(), '.groove', 'network');
|
|
4141
4245
|
}
|
|
4142
4246
|
|
|
4143
4247
|
// Defensive: only permit fs ops on paths that resolve inside ~/.groove/.
|
|
4248
|
+
// Uses realpathSync when the path exists to defeat symlink escapes.
|
|
4144
4249
|
function isInsideGrooveHome(target) {
|
|
4145
4250
|
const home = resolve(homedir(), '.groove') + '/';
|
|
4146
|
-
const
|
|
4147
|
-
|
|
4251
|
+
const resolved = resolve(target);
|
|
4252
|
+
let full;
|
|
4253
|
+
try { full = existsSync(resolved) ? realpathSync(resolved) + '/' : resolved + '/'; }
|
|
4254
|
+
catch { full = resolved + '/'; }
|
|
4255
|
+
const realHome = existsSync(home.slice(0, -1)) ? realpathSync(home.slice(0, -1)) + '/' : home;
|
|
4256
|
+
return full.startsWith(realHome);
|
|
4148
4257
|
}
|
|
4149
4258
|
|
|
4150
4259
|
function broadcastInstallProgress(step, message, percent) {
|
|
@@ -4209,21 +4318,33 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4209
4318
|
? NETWORK_REPO_URL.replace('https://', `https://${pat}@`)
|
|
4210
4319
|
: NETWORK_REPO_URL;
|
|
4211
4320
|
|
|
4212
|
-
|
|
4321
|
+
// Resolve the latest released tag so fresh installs track new releases
|
|
4322
|
+
// without requiring a code change. Falls back to NETWORK_VERSION if the
|
|
4323
|
+
// remote lookup fails (offline, rate-limited, no tags yet).
|
|
4324
|
+
let installVersion;
|
|
4325
|
+
try {
|
|
4326
|
+
installVersion = (await getLatestNetworkTag()) || NETWORK_VERSION;
|
|
4327
|
+
} catch {
|
|
4328
|
+
installVersion = NETWORK_VERSION;
|
|
4329
|
+
}
|
|
4213
4330
|
|
|
4214
|
-
|
|
4331
|
+
broadcastInstallProgress('cloning', `Cloning network package ${installVersion}...`, 0);
|
|
4332
|
+
|
|
4333
|
+
const cloneArgs = ['clone', '--branch', installVersion, '--depth', '1', cloneUrl, installPath];
|
|
4215
4334
|
const clone = spawn('git', cloneArgs, {
|
|
4216
4335
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4217
4336
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
4218
4337
|
});
|
|
4219
4338
|
|
|
4339
|
+
const stripCredentials = (s) => s.replace(/https:\/\/[^@]+@/g, 'https://***@');
|
|
4340
|
+
|
|
4220
4341
|
let cloneErr = '';
|
|
4221
4342
|
clone.stderr.on('data', (chunk) => {
|
|
4222
4343
|
const s = chunk.toString();
|
|
4223
4344
|
cloneErr += s;
|
|
4224
4345
|
// git writes progress to stderr — relay last line as status.
|
|
4225
4346
|
const line = s.split('\n').map((l) => l.trim()).filter(Boolean).pop();
|
|
4226
|
-
if (line) broadcastInstallProgress('cloning', line, 5);
|
|
4347
|
+
if (line) broadcastInstallProgress('cloning', stripCredentials(line), 5);
|
|
4227
4348
|
});
|
|
4228
4349
|
|
|
4229
4350
|
const cloneCode = await new Promise((resolveClone) => {
|
|
@@ -4232,7 +4353,7 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4232
4353
|
});
|
|
4233
4354
|
|
|
4234
4355
|
if (cloneCode.code !== 0) {
|
|
4235
|
-
const hint = cloneErr.trim().split('\n').slice(-1)[0] || 'git clone failed';
|
|
4356
|
+
const hint = stripCredentials(cloneErr.trim().split('\n').slice(-1)[0] || 'git clone failed');
|
|
4236
4357
|
return fail(`Clone failed: ${hint}`);
|
|
4237
4358
|
}
|
|
4238
4359
|
|
|
@@ -4285,12 +4406,12 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4285
4406
|
...(daemon.config.networkBeta || {}),
|
|
4286
4407
|
installed: true,
|
|
4287
4408
|
deployPath: installPath,
|
|
4288
|
-
version:
|
|
4409
|
+
version: installVersion,
|
|
4289
4410
|
};
|
|
4290
4411
|
await persistConfig();
|
|
4291
4412
|
daemon.broadcast({ type: 'config:updated' });
|
|
4292
|
-
broadcastInstallProgress('done',
|
|
4293
|
-
daemon.audit.log('network.install', { path: installPath, version:
|
|
4413
|
+
broadcastInstallProgress('done', `Network package ${installVersion} installed`, 100);
|
|
4414
|
+
daemon.audit.log('network.install', { path: installPath, version: installVersion });
|
|
4294
4415
|
daemon.networkInstall = { running: false };
|
|
4295
4416
|
} catch (err) {
|
|
4296
4417
|
fail(err?.message || 'Install failed');
|
|
@@ -4339,6 +4460,232 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4339
4460
|
res.json({ status: 'uninstalled' });
|
|
4340
4461
|
});
|
|
4341
4462
|
|
|
4463
|
+
// --- Network package update check / update ---
|
|
4464
|
+
|
|
4465
|
+
// 5-minute cache of the latest-tag lookup so startup + GUI polls don't
|
|
4466
|
+
// hammer GitHub. Shape: { latest, fetchedAt }. null until first check.
|
|
4467
|
+
let networkUpdateCache = null;
|
|
4468
|
+
const NETWORK_UPDATE_CACHE_MS = 5 * 60 * 1000;
|
|
4469
|
+
|
|
4470
|
+
// Run `git ls-remote --tags <repo>` and return the highest semver tag.
|
|
4471
|
+
// Resolves to null on git errors / network failure; caller decides how to
|
|
4472
|
+
// surface that. Uses spawn with array args — no shell interpolation.
|
|
4473
|
+
function fetchLatestNetworkTag() {
|
|
4474
|
+
return new Promise((resolvePromise) => {
|
|
4475
|
+
const proc = spawn('git', ['ls-remote', '--tags', NETWORK_REPO_URL], {
|
|
4476
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4477
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
4478
|
+
});
|
|
4479
|
+
let stdout = '';
|
|
4480
|
+
let stderr = '';
|
|
4481
|
+
proc.stdout.on('data', (c) => { stdout += c.toString(); });
|
|
4482
|
+
proc.stderr.on('data', (c) => { stderr += c.toString(); });
|
|
4483
|
+
const timeout = setTimeout(() => {
|
|
4484
|
+
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
|
|
4485
|
+
}, 10_000);
|
|
4486
|
+
proc.on('error', () => { clearTimeout(timeout); resolvePromise(null); });
|
|
4487
|
+
proc.on('close', (code) => {
|
|
4488
|
+
clearTimeout(timeout);
|
|
4489
|
+
if (code !== 0) return resolvePromise(null);
|
|
4490
|
+
const tags = [];
|
|
4491
|
+
for (const line of stdout.split('\n')) {
|
|
4492
|
+
// Format: <sha>\trefs/tags/v0.1.0 (or .../v0.1.0^{} for annotated)
|
|
4493
|
+
const m = line.match(/refs\/tags\/(v?\d+\.\d+\.\d+[^\s^]*)(?:\^\{\})?$/);
|
|
4494
|
+
if (m && parseSemver(m[1])) tags.push(m[1]);
|
|
4495
|
+
}
|
|
4496
|
+
if (tags.length === 0) return resolvePromise(null);
|
|
4497
|
+
tags.sort(compareSemver);
|
|
4498
|
+
resolvePromise(tags[tags.length - 1]);
|
|
4499
|
+
});
|
|
4500
|
+
});
|
|
4501
|
+
}
|
|
4502
|
+
|
|
4503
|
+
async function getLatestNetworkTag(force = false) {
|
|
4504
|
+
if (!force && networkUpdateCache && (Date.now() - networkUpdateCache.fetchedAt) < NETWORK_UPDATE_CACHE_MS) {
|
|
4505
|
+
return networkUpdateCache.latest;
|
|
4506
|
+
}
|
|
4507
|
+
const latest = await fetchLatestNetworkTag();
|
|
4508
|
+
if (latest) networkUpdateCache = { latest, fetchedAt: Date.now() };
|
|
4509
|
+
return latest;
|
|
4510
|
+
}
|
|
4511
|
+
|
|
4512
|
+
app.get('/api/network/update/check', networkGate, async (req, res) => {
|
|
4513
|
+
const installed = daemon.config?.networkBeta?.version || null;
|
|
4514
|
+
const force = req.query.force === '1' || req.query.force === 'true';
|
|
4515
|
+
const latest = await getLatestNetworkTag(force);
|
|
4516
|
+
if (!latest) {
|
|
4517
|
+
return res.status(502).json({
|
|
4518
|
+
installed,
|
|
4519
|
+
latest: null,
|
|
4520
|
+
updateAvailable: false,
|
|
4521
|
+
error: 'Could not reach github.com to check for updates',
|
|
4522
|
+
});
|
|
4523
|
+
}
|
|
4524
|
+
const updateAvailable = !!installed && compareSemver(latest, installed) > 0;
|
|
4525
|
+
res.json({ installed, latest, updateAvailable });
|
|
4526
|
+
});
|
|
4527
|
+
|
|
4528
|
+
function broadcastUpdateProgress(step, message, percent) {
|
|
4529
|
+
daemon.broadcast({
|
|
4530
|
+
type: 'network:update:progress',
|
|
4531
|
+
data: { step, message, percent },
|
|
4532
|
+
});
|
|
4533
|
+
}
|
|
4534
|
+
|
|
4535
|
+
app.post('/api/network/update', networkGate, async (req, res) => {
|
|
4536
|
+
if (daemon.networkInstall?.running) {
|
|
4537
|
+
return res.status(409).json({ error: 'Install/update already in progress' });
|
|
4538
|
+
}
|
|
4539
|
+
if (!daemon.config?.networkBeta?.installed) {
|
|
4540
|
+
return res.status(400).json({ error: 'Network package not installed' });
|
|
4541
|
+
}
|
|
4542
|
+
|
|
4543
|
+
const installPath = networkRoot();
|
|
4544
|
+
if (!existsSync(installPath) || !isInsideGrooveHome(installPath)) {
|
|
4545
|
+
return res.status(400).json({ error: 'Install path missing or invalid' });
|
|
4546
|
+
}
|
|
4547
|
+
|
|
4548
|
+
const latest = await getLatestNetworkTag(true);
|
|
4549
|
+
if (!latest) {
|
|
4550
|
+
return res.status(502).json({ error: 'Could not reach github.com to check for updates' });
|
|
4551
|
+
}
|
|
4552
|
+
const current = daemon.config.networkBeta.version || null;
|
|
4553
|
+
if (current && compareSemver(latest, current) <= 0) {
|
|
4554
|
+
return res.status(400).json({ error: 'Already at latest version', installed: current, latest });
|
|
4555
|
+
}
|
|
4556
|
+
|
|
4557
|
+
daemon.networkInstall = { running: true, startedAt: Date.now(), kind: 'update' };
|
|
4558
|
+
res.status(200).json({ status: 'updating', from: current, to: latest });
|
|
4559
|
+
|
|
4560
|
+
(async () => {
|
|
4561
|
+
const fail = (message) => {
|
|
4562
|
+
broadcastUpdateProgress('error', message, -1);
|
|
4563
|
+
daemon.audit.log('network.update.failed', { message, from: current, to: latest });
|
|
4564
|
+
daemon.networkInstall = { running: false };
|
|
4565
|
+
};
|
|
4566
|
+
|
|
4567
|
+
try {
|
|
4568
|
+
// Stop the running node first so we don't update files under its feet.
|
|
4569
|
+
try {
|
|
4570
|
+
const node = daemon.networkNode;
|
|
4571
|
+
if (node?.active && node.proc && !node.proc.killed) {
|
|
4572
|
+
try { node.proc.kill('SIGINT'); } catch { /* ignore */ }
|
|
4573
|
+
daemon.networkNode.status = 'stopping';
|
|
4574
|
+
pushNodeEvent('stopping', { pid: node.pid, reason: 'update' });
|
|
4575
|
+
broadcastNodeStatus();
|
|
4576
|
+
// Small grace window for the process to exit cleanly.
|
|
4577
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
4578
|
+
}
|
|
4579
|
+
} catch { /* ignore */ }
|
|
4580
|
+
|
|
4581
|
+
broadcastUpdateProgress('fetching', `Fetching ${latest}...`, 5);
|
|
4582
|
+
|
|
4583
|
+
const fetchProc = spawn('git', ['-C', installPath, 'fetch', '--tags', '--force'], {
|
|
4584
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4585
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
4586
|
+
});
|
|
4587
|
+
let fetchErr = '';
|
|
4588
|
+
fetchProc.stderr.on('data', (c) => { fetchErr += c.toString(); });
|
|
4589
|
+
const fetchCode = await new Promise((r) => {
|
|
4590
|
+
fetchProc.on('error', (e) => r({ code: -1, err: e.message }));
|
|
4591
|
+
fetchProc.on('close', (code) => r({ code }));
|
|
4592
|
+
});
|
|
4593
|
+
if (fetchCode.code !== 0) {
|
|
4594
|
+
const hint = fetchErr.trim().split('\n').slice(-1)[0] || 'git fetch failed';
|
|
4595
|
+
return fail(`Fetch failed: ${hint}`);
|
|
4596
|
+
}
|
|
4597
|
+
|
|
4598
|
+
broadcastUpdateProgress('checkout', `Checking out ${latest}...`, 20);
|
|
4599
|
+
|
|
4600
|
+
const checkoutProc = spawn('git', ['-C', installPath, 'checkout', latest], {
|
|
4601
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4602
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
4603
|
+
});
|
|
4604
|
+
let checkoutErr = '';
|
|
4605
|
+
checkoutProc.stderr.on('data', (c) => { checkoutErr += c.toString(); });
|
|
4606
|
+
const checkoutCode = await new Promise((r) => {
|
|
4607
|
+
checkoutProc.on('error', (e) => r({ code: -1, err: e.message }));
|
|
4608
|
+
checkoutProc.on('close', (code) => r({ code }));
|
|
4609
|
+
});
|
|
4610
|
+
if (checkoutCode.code !== 0) {
|
|
4611
|
+
const hint = checkoutErr.trim().split('\n').slice(-1)[0] || 'git checkout failed';
|
|
4612
|
+
return fail(`Checkout failed: ${hint}`);
|
|
4613
|
+
}
|
|
4614
|
+
|
|
4615
|
+
broadcastUpdateProgress('deps', 'Updating dependencies...', 30);
|
|
4616
|
+
|
|
4617
|
+
const setup = spawn('bash', ['setup.sh', '--json'], {
|
|
4618
|
+
cwd: installPath,
|
|
4619
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4620
|
+
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
|
4621
|
+
});
|
|
4622
|
+
|
|
4623
|
+
daemon.networkInstall.proc = setup;
|
|
4624
|
+
|
|
4625
|
+
let stdoutBuf = '';
|
|
4626
|
+
setup.stdout.on('data', (chunk) => {
|
|
4627
|
+
stdoutBuf += chunk.toString();
|
|
4628
|
+
let idx;
|
|
4629
|
+
while ((idx = stdoutBuf.indexOf('\n')) !== -1) {
|
|
4630
|
+
const line = stdoutBuf.slice(0, idx).trim();
|
|
4631
|
+
stdoutBuf = stdoutBuf.slice(idx + 1);
|
|
4632
|
+
if (!line || line[0] !== '{') continue;
|
|
4633
|
+
try {
|
|
4634
|
+
const event = JSON.parse(line);
|
|
4635
|
+
const step = typeof event.step === 'string' ? event.step : 'progress';
|
|
4636
|
+
const message = typeof event.message === 'string' ? event.message : '';
|
|
4637
|
+
const percent = Number.isFinite(event.percent) ? event.percent : null;
|
|
4638
|
+
broadcastUpdateProgress(step, message, percent);
|
|
4639
|
+
} catch { /* non-JSON line, ignore */ }
|
|
4640
|
+
}
|
|
4641
|
+
});
|
|
4642
|
+
|
|
4643
|
+
let stderrBuf = '';
|
|
4644
|
+
setup.stderr.on('data', (chunk) => { stderrBuf += chunk.toString(); });
|
|
4645
|
+
|
|
4646
|
+
const setupResult = await new Promise((r) => {
|
|
4647
|
+
setup.on('error', (e) => r({ code: -1, err: e.message }));
|
|
4648
|
+
setup.on('close', (code) => r({ code }));
|
|
4649
|
+
});
|
|
4650
|
+
|
|
4651
|
+
if (setupResult.code !== 0) {
|
|
4652
|
+
const hint = stderrBuf.trim().split('\n').slice(-1)[0] || `setup.sh exited ${setupResult.code}`;
|
|
4653
|
+
return fail(`Setup failed: ${hint}`);
|
|
4654
|
+
}
|
|
4655
|
+
|
|
4656
|
+
daemon.config.networkBeta = {
|
|
4657
|
+
...(daemon.config.networkBeta || {}),
|
|
4658
|
+
version: latest,
|
|
4659
|
+
};
|
|
4660
|
+
await persistConfig();
|
|
4661
|
+
// Invalidate the update cache now that we've moved forward.
|
|
4662
|
+
networkUpdateCache = { latest, fetchedAt: Date.now() };
|
|
4663
|
+
daemon.networkUpdateAvailable = { latest, updateAvailable: false, installed: latest };
|
|
4664
|
+
daemon.broadcast({ type: 'config:updated' });
|
|
4665
|
+
daemon.broadcast({ type: 'network:update:available', data: daemon.networkUpdateAvailable });
|
|
4666
|
+
broadcastUpdateProgress('done', `Updated to ${latest}`, 100);
|
|
4667
|
+
daemon.audit.log('network.update', { from: current, to: latest, path: installPath });
|
|
4668
|
+
daemon.networkInstall = { running: false };
|
|
4669
|
+
} catch (err) {
|
|
4670
|
+
fail(err?.message || 'Update failed');
|
|
4671
|
+
}
|
|
4672
|
+
})();
|
|
4673
|
+
});
|
|
4674
|
+
|
|
4675
|
+
// Startup hook — called from index.js once the server is up. Non-blocking;
|
|
4676
|
+
// updates daemon.networkUpdateAvailable and broadcasts so the GUI can badge.
|
|
4677
|
+
daemon.checkNetworkUpdate = async function checkNetworkUpdate() {
|
|
4678
|
+
if (!daemon.config?.networkBeta?.installed) return;
|
|
4679
|
+
try {
|
|
4680
|
+
const latest = await getLatestNetworkTag(true);
|
|
4681
|
+
if (!latest) return;
|
|
4682
|
+
const installed = daemon.config.networkBeta.version || null;
|
|
4683
|
+
const updateAvailable = !!installed && compareSemver(latest, installed) > 0;
|
|
4684
|
+
daemon.networkUpdateAvailable = { installed, latest, updateAvailable };
|
|
4685
|
+
daemon.broadcast({ type: 'network:update:available', data: daemon.networkUpdateAvailable });
|
|
4686
|
+
} catch { /* non-fatal */ }
|
|
4687
|
+
};
|
|
4688
|
+
|
|
4342
4689
|
// Serve GUI static files (built GUI) — no-cache headers to prevent stale bundles
|
|
4343
4690
|
const guiPath = process.env.GROOVE_GUI_PATH || resolve(__dirname, '../../gui/dist');
|
|
4344
4691
|
app.use(express.static(guiPath, { etag: false, maxAge: 0, lastModified: false }));
|