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.
Files changed (29) hide show
  1. package/CLAUDE.md +0 -7
  2. package/default/security-review-prompt.md +98 -0
  3. package/node_modules/@groove-dev/cli/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/package.json +1 -1
  5. package/node_modules/@groove-dev/daemon/src/api.js +309 -24
  6. package/node_modules/@groove-dev/daemon/src/firstrun.js +1 -1
  7. package/node_modules/@groove-dev/daemon/src/index.js +7 -0
  8. package/node_modules/@groove-dev/daemon/src/providers/groove-network.js +47 -7
  9. package/node_modules/@groove-dev/gui/dist/assets/{index-B9oPxmNj.js → index-Dd4u8X70.js} +1723 -1723
  10. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  11. package/node_modules/@groove-dev/gui/package.json +1 -1
  12. package/node_modules/@groove-dev/gui/src/components/network/network-status.jsx +12 -0
  13. package/node_modules/@groove-dev/gui/src/components/network/node-toggle.jsx +18 -15
  14. package/node_modules/@groove-dev/gui/src/stores/groove.js +128 -1
  15. package/node_modules/@groove-dev/gui/src/views/network.jsx +82 -2
  16. package/package.json +1 -1
  17. package/packages/cli/package.json +1 -1
  18. package/packages/daemon/package.json +1 -1
  19. package/packages/daemon/src/api.js +309 -24
  20. package/packages/daemon/src/firstrun.js +1 -1
  21. package/packages/daemon/src/index.js +7 -0
  22. package/packages/daemon/src/providers/groove-network.js +47 -7
  23. package/packages/gui/dist/assets/{index-B9oPxmNj.js → index-Dd4u8X70.js} +1723 -1723
  24. package/packages/gui/dist/index.html +1 -1
  25. package/packages/gui/package.json +1 -1
  26. package/packages/gui/src/components/network/network-status.jsx +12 -0
  27. package/packages/gui/src/components/network/node-toggle.jsx +18 -15
  28. package/packages/gui/src/stores/groove.js +128 -1
  29. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groove-dev/cli",
3
- "version": "0.27.48",
3
+ "version": "0.27.50",
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.48",
3
+ "version": "0.27.50",
4
4
  "description": "GROOVE daemon — agent orchestration engine",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -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
- 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 = 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
- '--relay', relay,
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, relay, device });
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, relay, device });
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
- 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.
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 full = resolve(target) + '/';
4147
- return full.startsWith(home);
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 }));
@@ -28,7 +28,7 @@ const DEFAULT_CONFIG = {
28
28
  networkBeta: {
29
29
  unlocked: false,
30
30
  code: null,
31
- relayUrl: 'localhost:8770',
31
+ signalUrl: 'signal.groovedev.ai',
32
32
  devicePreference: 'auto',
33
33
  maxContext: 4096,
34
34
  deployPath: null,
@@ -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(() => {