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.
Files changed (28) hide show
  1. package/default/security-review-prompt.md +98 -0
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +376 -29
  5. package/node_modules/@groove-dev/daemon/src/firstrun.js +1 -1
  6. package/node_modules/@groove-dev/daemon/src/index.js +7 -0
  7. package/node_modules/@groove-dev/daemon/src/providers/groove-network.js +57 -7
  8. package/node_modules/@groove-dev/gui/dist/assets/{index-B9oPxmNj.js → index-Dd4u8X70.js} +1723 -1723
  9. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  10. package/node_modules/@groove-dev/gui/package.json +1 -1
  11. package/node_modules/@groove-dev/gui/src/components/network/network-status.jsx +12 -0
  12. package/node_modules/@groove-dev/gui/src/components/network/node-toggle.jsx +18 -15
  13. package/node_modules/@groove-dev/gui/src/stores/groove.js +128 -1
  14. package/node_modules/@groove-dev/gui/src/views/network.jsx +82 -2
  15. package/package.json +1 -1
  16. package/packages/cli/package.json +1 -1
  17. package/packages/daemon/package.json +1 -1
  18. package/packages/daemon/src/api.js +376 -29
  19. package/packages/daemon/src/firstrun.js +1 -1
  20. package/packages/daemon/src/index.js +7 -0
  21. package/packages/daemon/src/providers/groove-network.js +57 -7
  22. package/packages/gui/dist/assets/{index-B9oPxmNj.js → index-Dd4u8X70.js} +1723 -1723
  23. package/packages/gui/dist/index.html +1 -1
  24. package/packages/gui/package.json +1 -1
  25. package/packages/gui/src/components/network/network-status.jsx +12 -0
  26. package/packages/gui/src/components/network/node-toggle.jsx +18 -15
  27. package/packages/gui/src/stores/groove.js +128 -1
  28. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.49",
3
+ "version": "0.27.51",
4
4
  "description": "GROOVE CLI — manage AI coding agents from your terminal",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/daemon",
3
- "version": "0.27.49",
3
+ "version": "0.27.51",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -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
- res.json(daemon.config || {});
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 — used only when groovedev.ai is unreachable
3728
- // so beta testers aren't locked out by network failures.
3729
- const BETA_CODES_FALLBACK = new Set([
3730
- 'GROOVE-NET-ALPHA-001',
3731
- 'GROOVE-NET-ALPHA-002',
3732
- 'GROOVE-NET-ALPHA-003',
3733
- 'GROOVE-NET-ALPHA-004',
3734
- 'GROOVE-NET-ALPHA-005',
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 hardcoded list when we can't reach the server
3836
+ // Offline fallback — only trust the hashed list when we can't reach the server
3815
3837
  source = 'fallback';
3816
- if (BETA_CODES_FALLBACK.has(code)) {
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 relay = cfg.relayUrl || 'localhost:8770';
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
- '--relay', relay,
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.12'), args, {
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, relay, device });
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, relay, device });
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
- app.get('/api/network/status', networkGate, (req, res) => {
4115
- // Mocked relay status until the relay /status HTTP endpoint lands.
4116
- // Shape matches the spec in groove-comms/GETTING-STARTED.md.
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.1.0';
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 full = resolve(target) + '/';
4147
- return full.startsWith(home);
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
- broadcastInstallProgress('cloning', 'Cloning network package...', 0);
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
- const cloneArgs = ['clone', '--branch', NETWORK_VERSION, '--depth', '1', cloneUrl, installPath];
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: NETWORK_VERSION,
4409
+ version: installVersion,
4289
4410
  };
4290
4411
  await persistConfig();
4291
4412
  daemon.broadcast({ type: 'config:updated' });
4292
- broadcastInstallProgress('done', 'Network package installed', 100);
4293
- daemon.audit.log('network.install', { path: installPath, version: NETWORK_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 }));