groove-dev 0.27.111 → 0.27.112
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/moe-training/client/consent.js +47 -55
- package/moe-training/test/client/consent.test.js +23 -20
- 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 +23 -54
- package/node_modules/@groove-dev/daemon/src/index.js +8 -10
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/moe-training/client/consent.js +47 -55
- package/node_modules/moe-training/test/client/consent.test.js +23 -20
- 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 +23 -54
- package/packages/daemon/src/index.js +8 -10
- package/packages/gui/package.json +1 -1
|
@@ -1,44 +1,46 @@
|
|
|
1
1
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
2
|
|
|
3
|
-
import Database from 'better-sqlite3';
|
|
4
3
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
5
|
import { join } from 'node:path';
|
|
7
6
|
import { homedir } from 'node:os';
|
|
8
7
|
import { CURRENT_CONSENT_VERSION } from '../shared/constants.js';
|
|
9
8
|
|
|
9
|
+
function ensureDir(filePath) {
|
|
10
|
+
const dir = filePath.replace(/[/\\][^/\\]+$/, '');
|
|
11
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readJSON(filePath) {
|
|
15
|
+
if (!existsSync(filePath)) return null;
|
|
16
|
+
try { return JSON.parse(readFileSync(filePath, 'utf-8')); } catch { return null; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeJSON(filePath, data) {
|
|
20
|
+
ensureDir(filePath);
|
|
21
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
export class ConsentManager {
|
|
11
|
-
constructor(
|
|
12
|
-
this.
|
|
13
|
-
const dir = this._dbPath.replace(/[/\\][^/\\]+$/, '');
|
|
14
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
15
|
-
this._db = new Database(this._dbPath);
|
|
16
|
-
this._db.pragma('journal_mode = WAL');
|
|
17
|
-
this._db.exec(`
|
|
18
|
-
CREATE TABLE IF NOT EXISTS consent_history (
|
|
19
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
-
user_id TEXT NOT NULL,
|
|
21
|
-
opted_in INTEGER NOT NULL,
|
|
22
|
-
consent_version TEXT NOT NULL,
|
|
23
|
-
metadata TEXT,
|
|
24
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
25
|
-
)
|
|
26
|
-
`);
|
|
25
|
+
constructor(consentPath) {
|
|
26
|
+
this._path = consentPath || join(homedir(), '.groove', 'consent.json');
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
recordConsent(userId, optedIn, consentVersion
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
recordConsent(userId, optedIn, consentVersion) {
|
|
30
|
+
const data = {
|
|
31
|
+
user_id: userId,
|
|
32
|
+
opted_in: !!optedIn,
|
|
33
|
+
consent_version: consentVersion,
|
|
34
|
+
updated_at: new Date().toISOString(),
|
|
35
|
+
};
|
|
36
|
+
writeJSON(this._path, data);
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
isOptedIn(userId) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (row.consent_version !== CURRENT_CONSENT_VERSION) return false;
|
|
41
|
-
return row.opted_in === 1;
|
|
40
|
+
const data = readJSON(this._path);
|
|
41
|
+
if (!data) return false;
|
|
42
|
+
if (data.consent_version !== CURRENT_CONSENT_VERSION) return false;
|
|
43
|
+
return data.opted_in === true;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
revokeConsent(userId) {
|
|
@@ -46,34 +48,28 @@ export class ConsentManager {
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
getOptedInCount() {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
AND consent_version = ?
|
|
53
|
-
AND id = (SELECT MAX(id) FROM consent_history ch2 WHERE ch2.user_id = ch1.user_id)
|
|
54
|
-
`).get(CURRENT_CONSENT_VERSION);
|
|
55
|
-
return row?.cnt || 0;
|
|
51
|
+
const data = readJSON(this._path);
|
|
52
|
+
if (!data || !data.opted_in || data.consent_version !== CURRENT_CONSENT_VERSION) return 0;
|
|
53
|
+
return 1;
|
|
56
54
|
}
|
|
57
55
|
|
|
58
56
|
getConsentHistory(userId) {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
57
|
+
const data = readJSON(this._path);
|
|
58
|
+
if (!data) return [];
|
|
59
|
+
return [{
|
|
60
|
+
user_id: data.user_id,
|
|
61
|
+
opted_in: data.opted_in,
|
|
62
|
+
consent_version: data.consent_version,
|
|
63
|
+
created_at: data.updated_at,
|
|
64
|
+
metadata: null,
|
|
65
|
+
}];
|
|
67
66
|
}
|
|
68
67
|
|
|
69
|
-
close() {
|
|
70
|
-
this._db.close();
|
|
71
|
-
}
|
|
68
|
+
close() {}
|
|
72
69
|
|
|
73
70
|
static getOrCreateUserId(userIdPath) {
|
|
74
71
|
const filePath = userIdPath || join(homedir(), '.groove', 'user_id');
|
|
75
|
-
|
|
76
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
72
|
+
ensureDir(filePath);
|
|
77
73
|
if (existsSync(filePath)) {
|
|
78
74
|
return readFileSync(filePath, 'utf-8').trim();
|
|
79
75
|
}
|
|
@@ -82,15 +78,11 @@ export class ConsentManager {
|
|
|
82
78
|
return uid;
|
|
83
79
|
}
|
|
84
80
|
|
|
85
|
-
static isCaptureEnabled(userIdPath,
|
|
81
|
+
static isCaptureEnabled(userIdPath, consentPath) {
|
|
86
82
|
const filePath = userIdPath || join(homedir(), '.groove', 'user_id');
|
|
87
83
|
if (!existsSync(filePath)) return false;
|
|
84
|
+
const manager = new ConsentManager(consentPath);
|
|
88
85
|
const userId = readFileSync(filePath, 'utf-8').trim();
|
|
89
|
-
|
|
90
|
-
try {
|
|
91
|
-
return manager.isOptedIn(userId);
|
|
92
|
-
} finally {
|
|
93
|
-
manager.close();
|
|
94
|
-
}
|
|
86
|
+
return manager.isOptedIn(userId);
|
|
95
87
|
}
|
|
96
88
|
}
|
|
@@ -8,12 +8,12 @@ import { tmpdir } from 'node:os';
|
|
|
8
8
|
import { ConsentManager } from '../../client/consent.js';
|
|
9
9
|
|
|
10
10
|
describe('ConsentManager', () => {
|
|
11
|
-
let tmpDir,
|
|
11
|
+
let tmpDir, consentPath, manager;
|
|
12
12
|
|
|
13
13
|
beforeEach(() => {
|
|
14
14
|
tmpDir = mkdtempSync(join(tmpdir(), 'consent-test-'));
|
|
15
|
-
|
|
16
|
-
manager = new ConsentManager(
|
|
15
|
+
consentPath = join(tmpDir, 'consent.json');
|
|
16
|
+
manager = new ConsentManager(consentPath);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
afterEach(() => {
|
|
@@ -43,19 +43,24 @@ describe('ConsentManager', () => {
|
|
|
43
43
|
|
|
44
44
|
it('getOptedInCount counts correctly', () => {
|
|
45
45
|
manager.recordConsent('user1', true, '1.0');
|
|
46
|
-
manager.
|
|
47
|
-
manager.recordConsent('user3', false, '1.0');
|
|
48
|
-
assert.equal(manager.getOptedInCount(), 2);
|
|
46
|
+
assert.equal(manager.getOptedInCount(), 1);
|
|
49
47
|
});
|
|
50
48
|
|
|
51
|
-
it('getConsentHistory returns
|
|
52
|
-
manager.recordConsent('user1', true, '1.0'
|
|
53
|
-
manager.revokeConsent('user1');
|
|
49
|
+
it('getConsentHistory returns current state', () => {
|
|
50
|
+
manager.recordConsent('user1', true, '1.0');
|
|
54
51
|
const history = manager.getConsentHistory('user1');
|
|
55
|
-
assert.equal(history.length,
|
|
52
|
+
assert.equal(history.length, 1);
|
|
56
53
|
assert.equal(history[0].opted_in, true);
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('consent.json is written with 0o600 permissions', () => {
|
|
57
|
+
manager.recordConsent('user1', true, '1.0');
|
|
58
|
+
assert.ok(existsSync(consentPath));
|
|
59
|
+
const data = JSON.parse(readFileSync(consentPath, 'utf-8'));
|
|
60
|
+
assert.equal(data.opted_in, true);
|
|
61
|
+
assert.equal(data.consent_version, '1.0');
|
|
62
|
+
assert.equal(data.user_id, 'user1');
|
|
63
|
+
assert.ok(data.updated_at);
|
|
59
64
|
});
|
|
60
65
|
});
|
|
61
66
|
|
|
@@ -96,26 +101,24 @@ describe('ConsentManager.isCaptureEnabled', () => {
|
|
|
96
101
|
});
|
|
97
102
|
|
|
98
103
|
it('returns false when no user_id file exists', () => {
|
|
99
|
-
const result = ConsentManager.isCaptureEnabled(join(tmpDir, 'no_file'), join(tmpDir, '
|
|
104
|
+
const result = ConsentManager.isCaptureEnabled(join(tmpDir, 'no_file'), join(tmpDir, 'consent.json'));
|
|
100
105
|
assert.equal(result, false);
|
|
101
106
|
});
|
|
102
107
|
|
|
103
108
|
it('returns false when user not opted in', () => {
|
|
104
109
|
const uidPath = join(tmpDir, 'user_id');
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
const result = ConsentManager.isCaptureEnabled(uidPath, dbPath);
|
|
110
|
+
ConsentManager.getOrCreateUserId(uidPath);
|
|
111
|
+
const result = ConsentManager.isCaptureEnabled(uidPath, join(tmpDir, 'consent.json'));
|
|
108
112
|
assert.equal(result, false);
|
|
109
113
|
});
|
|
110
114
|
|
|
111
115
|
it('returns true when user is opted in', () => {
|
|
112
116
|
const uidPath = join(tmpDir, 'user_id');
|
|
113
117
|
const uid = ConsentManager.getOrCreateUserId(uidPath);
|
|
114
|
-
const
|
|
115
|
-
const mgr = new ConsentManager(
|
|
118
|
+
const consentPath = join(tmpDir, 'consent.json');
|
|
119
|
+
const mgr = new ConsentManager(consentPath);
|
|
116
120
|
mgr.recordConsent(uid, true, '1.0');
|
|
117
|
-
|
|
118
|
-
const result = ConsentManager.isCaptureEnabled(uidPath, dbPath);
|
|
121
|
+
const result = ConsentManager.isCaptureEnabled(uidPath, consentPath);
|
|
119
122
|
assert.equal(result, true);
|
|
120
123
|
});
|
|
121
124
|
});
|
|
@@ -4855,9 +4855,13 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4855
4855
|
|
|
4856
4856
|
app.get('/api/training/status', (req, res) => {
|
|
4857
4857
|
let userId = null;
|
|
4858
|
-
|
|
4858
|
+
let optedIn = false;
|
|
4859
|
+
try {
|
|
4860
|
+
userId = ConsentManager.getOrCreateUserId();
|
|
4861
|
+
optedIn = ConsentManager.isCaptureEnabled();
|
|
4862
|
+
} catch (e) { /* */ }
|
|
4859
4863
|
res.json({
|
|
4860
|
-
optedIn
|
|
4864
|
+
optedIn,
|
|
4861
4865
|
userId: userId ? userId.substring(0, 8) + '...' : null,
|
|
4862
4866
|
captureActive: !!daemon.trajectoryCapture,
|
|
4863
4867
|
sessionsCaptured: daemon.state.get('training_sessions_captured') || 0,
|
|
@@ -4869,52 +4873,23 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4869
4873
|
const { enabled } = req.body;
|
|
4870
4874
|
if (typeof enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be boolean' });
|
|
4871
4875
|
|
|
4872
|
-
|
|
4873
|
-
|
|
4874
|
-
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
} catch (modErr) {
|
|
4878
|
-
console.error('[training/opt-in] Native module load failed:', modErr);
|
|
4879
|
-
return res.status(500).json({
|
|
4880
|
-
error: 'Failed to enable data sharing',
|
|
4881
|
-
detail: 'Native SQLite module (better-sqlite3) is not available. On remote instances, ensure build tools are installed (gcc, g++, make, python3) and run: npm rebuild better-sqlite3',
|
|
4882
|
-
});
|
|
4883
|
-
}
|
|
4884
|
-
try {
|
|
4885
|
-
const userId = ConsentManager.getOrCreateUserId();
|
|
4886
|
-
const consent = new ConsentManager();
|
|
4887
|
-
try {
|
|
4888
|
-
consent.recordConsent(userId, true, '1.0');
|
|
4889
|
-
} finally {
|
|
4890
|
-
consent.close();
|
|
4891
|
-
}
|
|
4892
|
-
daemon.config.training_opt_in = true;
|
|
4893
|
-
saveConfig(daemon.grooveDir, daemon.config);
|
|
4876
|
+
try {
|
|
4877
|
+
const userId = ConsentManager.getOrCreateUserId();
|
|
4878
|
+
const consent = new ConsentManager();
|
|
4879
|
+
if (enabled) {
|
|
4880
|
+
consent.recordConsent(userId, true, '1.0');
|
|
4894
4881
|
await daemon._initTrajectoryCapture();
|
|
4895
4882
|
daemon.state.set('training_enrolled_at', new Date().toISOString());
|
|
4896
|
-
}
|
|
4897
|
-
|
|
4898
|
-
daemon.
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
}
|
|
4902
|
-
} else {
|
|
4903
|
-
daemon.config.training_opt_in = false;
|
|
4904
|
-
saveConfig(daemon.grooveDir, daemon.config);
|
|
4905
|
-
if (daemon.trajectoryCapture) {
|
|
4906
|
-
try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
|
|
4907
|
-
daemon.trajectoryCapture = null;
|
|
4908
|
-
}
|
|
4909
|
-
try {
|
|
4910
|
-
const userId = ConsentManager.getOrCreateUserId();
|
|
4911
|
-
const consent = new ConsentManager();
|
|
4912
|
-
try {
|
|
4913
|
-
consent.revokeConsent(userId);
|
|
4914
|
-
} finally {
|
|
4915
|
-
consent.close();
|
|
4883
|
+
} else {
|
|
4884
|
+
consent.revokeConsent(userId);
|
|
4885
|
+
if (daemon.trajectoryCapture) {
|
|
4886
|
+
try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
|
|
4887
|
+
daemon.trajectoryCapture = null;
|
|
4916
4888
|
}
|
|
4917
|
-
}
|
|
4889
|
+
}
|
|
4890
|
+
} catch (e) {
|
|
4891
|
+
console.error('[training/opt-in] Failed to update data sharing:', e);
|
|
4892
|
+
return res.status(500).json({ error: 'Failed to update data sharing', detail: e.message });
|
|
4918
4893
|
}
|
|
4919
4894
|
|
|
4920
4895
|
daemon.broadcast({ type: 'training:status', data: { optedIn: enabled, captureActive: !!daemon.trajectoryCapture } });
|
|
@@ -4924,18 +4899,13 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4924
4899
|
|
|
4925
4900
|
app.post('/api/training/opt-in/delete', async (req, res) => {
|
|
4926
4901
|
try {
|
|
4927
|
-
|
|
4928
|
-
const
|
|
4929
|
-
|
|
4902
|
+
const userId = ConsentManager.getOrCreateUserId();
|
|
4903
|
+
const consent = new ConsentManager();
|
|
4904
|
+
consent.revokeConsent(userId);
|
|
4930
4905
|
if (daemon.trajectoryCapture) {
|
|
4931
4906
|
try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
|
|
4932
4907
|
daemon.trajectoryCapture = null;
|
|
4933
4908
|
}
|
|
4934
|
-
try {
|
|
4935
|
-
const userId = ConsentManager.getOrCreateUserId();
|
|
4936
|
-
const consent = new ConsentManager();
|
|
4937
|
-
try { consent.revokeConsent(userId); } finally { consent.close(); }
|
|
4938
|
-
} catch (e) { /* */ }
|
|
4939
4909
|
daemon.broadcast({ type: 'training:status', data: { optedIn: false, captureActive: false } });
|
|
4940
4910
|
if (daemon.audit) daemon.audit.log('training.delete', {});
|
|
4941
4911
|
res.json({ ok: true, deleted: true });
|
|
@@ -4961,7 +4931,6 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4961
4931
|
'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
|
|
4962
4932
|
'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
|
|
4963
4933
|
'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
|
|
4964
|
-
'training_opt_in',
|
|
4965
4934
|
];
|
|
4966
4935
|
for (const key of Object.keys(req.body)) {
|
|
4967
4936
|
if (!ALLOWED_KEYS.includes(key)) {
|
|
@@ -666,17 +666,15 @@ export class Daemon {
|
|
|
666
666
|
}
|
|
667
667
|
|
|
668
668
|
async _initTrajectoryCapture() {
|
|
669
|
-
if (!this.config.training_opt_in) return;
|
|
670
669
|
try {
|
|
671
|
-
if (ConsentManager.isCaptureEnabled())
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
}
|
|
670
|
+
if (!ConsentManager.isCaptureEnabled()) return;
|
|
671
|
+
const pkgPath = new URL('../package.json', import.meta.url);
|
|
672
|
+
const version = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
673
|
+
this.trajectoryCapture = new TrajectoryCapture({
|
|
674
|
+
centralCommandUrl: process.env.GROOVE_CENTRAL_URL || 'https://api.groovedev.ai',
|
|
675
|
+
grooveVersion: version,
|
|
676
|
+
});
|
|
677
|
+
this.trajectoryCapture.init();
|
|
680
678
|
} catch (e) {
|
|
681
679
|
// Training capture is never critical
|
|
682
680
|
}
|
|
@@ -1,44 +1,46 @@
|
|
|
1
1
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
2
|
|
|
3
|
-
import Database from 'better-sqlite3';
|
|
4
3
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
5
|
import { join } from 'node:path';
|
|
7
6
|
import { homedir } from 'node:os';
|
|
8
7
|
import { CURRENT_CONSENT_VERSION } from '../shared/constants.js';
|
|
9
8
|
|
|
9
|
+
function ensureDir(filePath) {
|
|
10
|
+
const dir = filePath.replace(/[/\\][^/\\]+$/, '');
|
|
11
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readJSON(filePath) {
|
|
15
|
+
if (!existsSync(filePath)) return null;
|
|
16
|
+
try { return JSON.parse(readFileSync(filePath, 'utf-8')); } catch { return null; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function writeJSON(filePath, data) {
|
|
20
|
+
ensureDir(filePath);
|
|
21
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
export class ConsentManager {
|
|
11
|
-
constructor(
|
|
12
|
-
this.
|
|
13
|
-
const dir = this._dbPath.replace(/[/\\][^/\\]+$/, '');
|
|
14
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
15
|
-
this._db = new Database(this._dbPath);
|
|
16
|
-
this._db.pragma('journal_mode = WAL');
|
|
17
|
-
this._db.exec(`
|
|
18
|
-
CREATE TABLE IF NOT EXISTS consent_history (
|
|
19
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
-
user_id TEXT NOT NULL,
|
|
21
|
-
opted_in INTEGER NOT NULL,
|
|
22
|
-
consent_version TEXT NOT NULL,
|
|
23
|
-
metadata TEXT,
|
|
24
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
25
|
-
)
|
|
26
|
-
`);
|
|
25
|
+
constructor(consentPath) {
|
|
26
|
+
this._path = consentPath || join(homedir(), '.groove', 'consent.json');
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
recordConsent(userId, optedIn, consentVersion
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
recordConsent(userId, optedIn, consentVersion) {
|
|
30
|
+
const data = {
|
|
31
|
+
user_id: userId,
|
|
32
|
+
opted_in: !!optedIn,
|
|
33
|
+
consent_version: consentVersion,
|
|
34
|
+
updated_at: new Date().toISOString(),
|
|
35
|
+
};
|
|
36
|
+
writeJSON(this._path, data);
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
isOptedIn(userId) {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (row.consent_version !== CURRENT_CONSENT_VERSION) return false;
|
|
41
|
-
return row.opted_in === 1;
|
|
40
|
+
const data = readJSON(this._path);
|
|
41
|
+
if (!data) return false;
|
|
42
|
+
if (data.consent_version !== CURRENT_CONSENT_VERSION) return false;
|
|
43
|
+
return data.opted_in === true;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
revokeConsent(userId) {
|
|
@@ -46,34 +48,28 @@ export class ConsentManager {
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
getOptedInCount() {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
AND consent_version = ?
|
|
53
|
-
AND id = (SELECT MAX(id) FROM consent_history ch2 WHERE ch2.user_id = ch1.user_id)
|
|
54
|
-
`).get(CURRENT_CONSENT_VERSION);
|
|
55
|
-
return row?.cnt || 0;
|
|
51
|
+
const data = readJSON(this._path);
|
|
52
|
+
if (!data || !data.opted_in || data.consent_version !== CURRENT_CONSENT_VERSION) return 0;
|
|
53
|
+
return 1;
|
|
56
54
|
}
|
|
57
55
|
|
|
58
56
|
getConsentHistory(userId) {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
57
|
+
const data = readJSON(this._path);
|
|
58
|
+
if (!data) return [];
|
|
59
|
+
return [{
|
|
60
|
+
user_id: data.user_id,
|
|
61
|
+
opted_in: data.opted_in,
|
|
62
|
+
consent_version: data.consent_version,
|
|
63
|
+
created_at: data.updated_at,
|
|
64
|
+
metadata: null,
|
|
65
|
+
}];
|
|
67
66
|
}
|
|
68
67
|
|
|
69
|
-
close() {
|
|
70
|
-
this._db.close();
|
|
71
|
-
}
|
|
68
|
+
close() {}
|
|
72
69
|
|
|
73
70
|
static getOrCreateUserId(userIdPath) {
|
|
74
71
|
const filePath = userIdPath || join(homedir(), '.groove', 'user_id');
|
|
75
|
-
|
|
76
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
72
|
+
ensureDir(filePath);
|
|
77
73
|
if (existsSync(filePath)) {
|
|
78
74
|
return readFileSync(filePath, 'utf-8').trim();
|
|
79
75
|
}
|
|
@@ -82,15 +78,11 @@ export class ConsentManager {
|
|
|
82
78
|
return uid;
|
|
83
79
|
}
|
|
84
80
|
|
|
85
|
-
static isCaptureEnabled(userIdPath,
|
|
81
|
+
static isCaptureEnabled(userIdPath, consentPath) {
|
|
86
82
|
const filePath = userIdPath || join(homedir(), '.groove', 'user_id');
|
|
87
83
|
if (!existsSync(filePath)) return false;
|
|
84
|
+
const manager = new ConsentManager(consentPath);
|
|
88
85
|
const userId = readFileSync(filePath, 'utf-8').trim();
|
|
89
|
-
|
|
90
|
-
try {
|
|
91
|
-
return manager.isOptedIn(userId);
|
|
92
|
-
} finally {
|
|
93
|
-
manager.close();
|
|
94
|
-
}
|
|
86
|
+
return manager.isOptedIn(userId);
|
|
95
87
|
}
|
|
96
88
|
}
|
|
@@ -8,12 +8,12 @@ import { tmpdir } from 'node:os';
|
|
|
8
8
|
import { ConsentManager } from '../../client/consent.js';
|
|
9
9
|
|
|
10
10
|
describe('ConsentManager', () => {
|
|
11
|
-
let tmpDir,
|
|
11
|
+
let tmpDir, consentPath, manager;
|
|
12
12
|
|
|
13
13
|
beforeEach(() => {
|
|
14
14
|
tmpDir = mkdtempSync(join(tmpdir(), 'consent-test-'));
|
|
15
|
-
|
|
16
|
-
manager = new ConsentManager(
|
|
15
|
+
consentPath = join(tmpDir, 'consent.json');
|
|
16
|
+
manager = new ConsentManager(consentPath);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
afterEach(() => {
|
|
@@ -43,19 +43,24 @@ describe('ConsentManager', () => {
|
|
|
43
43
|
|
|
44
44
|
it('getOptedInCount counts correctly', () => {
|
|
45
45
|
manager.recordConsent('user1', true, '1.0');
|
|
46
|
-
manager.
|
|
47
|
-
manager.recordConsent('user3', false, '1.0');
|
|
48
|
-
assert.equal(manager.getOptedInCount(), 2);
|
|
46
|
+
assert.equal(manager.getOptedInCount(), 1);
|
|
49
47
|
});
|
|
50
48
|
|
|
51
|
-
it('getConsentHistory returns
|
|
52
|
-
manager.recordConsent('user1', true, '1.0'
|
|
53
|
-
manager.revokeConsent('user1');
|
|
49
|
+
it('getConsentHistory returns current state', () => {
|
|
50
|
+
manager.recordConsent('user1', true, '1.0');
|
|
54
51
|
const history = manager.getConsentHistory('user1');
|
|
55
|
-
assert.equal(history.length,
|
|
52
|
+
assert.equal(history.length, 1);
|
|
56
53
|
assert.equal(history[0].opted_in, true);
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('consent.json is written with 0o600 permissions', () => {
|
|
57
|
+
manager.recordConsent('user1', true, '1.0');
|
|
58
|
+
assert.ok(existsSync(consentPath));
|
|
59
|
+
const data = JSON.parse(readFileSync(consentPath, 'utf-8'));
|
|
60
|
+
assert.equal(data.opted_in, true);
|
|
61
|
+
assert.equal(data.consent_version, '1.0');
|
|
62
|
+
assert.equal(data.user_id, 'user1');
|
|
63
|
+
assert.ok(data.updated_at);
|
|
59
64
|
});
|
|
60
65
|
});
|
|
61
66
|
|
|
@@ -96,26 +101,24 @@ describe('ConsentManager.isCaptureEnabled', () => {
|
|
|
96
101
|
});
|
|
97
102
|
|
|
98
103
|
it('returns false when no user_id file exists', () => {
|
|
99
|
-
const result = ConsentManager.isCaptureEnabled(join(tmpDir, 'no_file'), join(tmpDir, '
|
|
104
|
+
const result = ConsentManager.isCaptureEnabled(join(tmpDir, 'no_file'), join(tmpDir, 'consent.json'));
|
|
100
105
|
assert.equal(result, false);
|
|
101
106
|
});
|
|
102
107
|
|
|
103
108
|
it('returns false when user not opted in', () => {
|
|
104
109
|
const uidPath = join(tmpDir, 'user_id');
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
const result = ConsentManager.isCaptureEnabled(uidPath, dbPath);
|
|
110
|
+
ConsentManager.getOrCreateUserId(uidPath);
|
|
111
|
+
const result = ConsentManager.isCaptureEnabled(uidPath, join(tmpDir, 'consent.json'));
|
|
108
112
|
assert.equal(result, false);
|
|
109
113
|
});
|
|
110
114
|
|
|
111
115
|
it('returns true when user is opted in', () => {
|
|
112
116
|
const uidPath = join(tmpDir, 'user_id');
|
|
113
117
|
const uid = ConsentManager.getOrCreateUserId(uidPath);
|
|
114
|
-
const
|
|
115
|
-
const mgr = new ConsentManager(
|
|
118
|
+
const consentPath = join(tmpDir, 'consent.json');
|
|
119
|
+
const mgr = new ConsentManager(consentPath);
|
|
116
120
|
mgr.recordConsent(uid, true, '1.0');
|
|
117
|
-
|
|
118
|
-
const result = ConsentManager.isCaptureEnabled(uidPath, dbPath);
|
|
121
|
+
const result = ConsentManager.isCaptureEnabled(uidPath, consentPath);
|
|
119
122
|
assert.equal(result, true);
|
|
120
123
|
});
|
|
121
124
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.112",
|
|
4
4
|
"description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
|
|
@@ -4855,9 +4855,13 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4855
4855
|
|
|
4856
4856
|
app.get('/api/training/status', (req, res) => {
|
|
4857
4857
|
let userId = null;
|
|
4858
|
-
|
|
4858
|
+
let optedIn = false;
|
|
4859
|
+
try {
|
|
4860
|
+
userId = ConsentManager.getOrCreateUserId();
|
|
4861
|
+
optedIn = ConsentManager.isCaptureEnabled();
|
|
4862
|
+
} catch (e) { /* */ }
|
|
4859
4863
|
res.json({
|
|
4860
|
-
optedIn
|
|
4864
|
+
optedIn,
|
|
4861
4865
|
userId: userId ? userId.substring(0, 8) + '...' : null,
|
|
4862
4866
|
captureActive: !!daemon.trajectoryCapture,
|
|
4863
4867
|
sessionsCaptured: daemon.state.get('training_sessions_captured') || 0,
|
|
@@ -4869,52 +4873,23 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4869
4873
|
const { enabled } = req.body;
|
|
4870
4874
|
if (typeof enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be boolean' });
|
|
4871
4875
|
|
|
4872
|
-
|
|
4873
|
-
|
|
4874
|
-
|
|
4875
|
-
|
|
4876
|
-
|
|
4877
|
-
} catch (modErr) {
|
|
4878
|
-
console.error('[training/opt-in] Native module load failed:', modErr);
|
|
4879
|
-
return res.status(500).json({
|
|
4880
|
-
error: 'Failed to enable data sharing',
|
|
4881
|
-
detail: 'Native SQLite module (better-sqlite3) is not available. On remote instances, ensure build tools are installed (gcc, g++, make, python3) and run: npm rebuild better-sqlite3',
|
|
4882
|
-
});
|
|
4883
|
-
}
|
|
4884
|
-
try {
|
|
4885
|
-
const userId = ConsentManager.getOrCreateUserId();
|
|
4886
|
-
const consent = new ConsentManager();
|
|
4887
|
-
try {
|
|
4888
|
-
consent.recordConsent(userId, true, '1.0');
|
|
4889
|
-
} finally {
|
|
4890
|
-
consent.close();
|
|
4891
|
-
}
|
|
4892
|
-
daemon.config.training_opt_in = true;
|
|
4893
|
-
saveConfig(daemon.grooveDir, daemon.config);
|
|
4876
|
+
try {
|
|
4877
|
+
const userId = ConsentManager.getOrCreateUserId();
|
|
4878
|
+
const consent = new ConsentManager();
|
|
4879
|
+
if (enabled) {
|
|
4880
|
+
consent.recordConsent(userId, true, '1.0');
|
|
4894
4881
|
await daemon._initTrajectoryCapture();
|
|
4895
4882
|
daemon.state.set('training_enrolled_at', new Date().toISOString());
|
|
4896
|
-
}
|
|
4897
|
-
|
|
4898
|
-
daemon.
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
}
|
|
4902
|
-
} else {
|
|
4903
|
-
daemon.config.training_opt_in = false;
|
|
4904
|
-
saveConfig(daemon.grooveDir, daemon.config);
|
|
4905
|
-
if (daemon.trajectoryCapture) {
|
|
4906
|
-
try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
|
|
4907
|
-
daemon.trajectoryCapture = null;
|
|
4908
|
-
}
|
|
4909
|
-
try {
|
|
4910
|
-
const userId = ConsentManager.getOrCreateUserId();
|
|
4911
|
-
const consent = new ConsentManager();
|
|
4912
|
-
try {
|
|
4913
|
-
consent.revokeConsent(userId);
|
|
4914
|
-
} finally {
|
|
4915
|
-
consent.close();
|
|
4883
|
+
} else {
|
|
4884
|
+
consent.revokeConsent(userId);
|
|
4885
|
+
if (daemon.trajectoryCapture) {
|
|
4886
|
+
try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
|
|
4887
|
+
daemon.trajectoryCapture = null;
|
|
4916
4888
|
}
|
|
4917
|
-
}
|
|
4889
|
+
}
|
|
4890
|
+
} catch (e) {
|
|
4891
|
+
console.error('[training/opt-in] Failed to update data sharing:', e);
|
|
4892
|
+
return res.status(500).json({ error: 'Failed to update data sharing', detail: e.message });
|
|
4918
4893
|
}
|
|
4919
4894
|
|
|
4920
4895
|
daemon.broadcast({ type: 'training:status', data: { optedIn: enabled, captureActive: !!daemon.trajectoryCapture } });
|
|
@@ -4924,18 +4899,13 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4924
4899
|
|
|
4925
4900
|
app.post('/api/training/opt-in/delete', async (req, res) => {
|
|
4926
4901
|
try {
|
|
4927
|
-
|
|
4928
|
-
const
|
|
4929
|
-
|
|
4902
|
+
const userId = ConsentManager.getOrCreateUserId();
|
|
4903
|
+
const consent = new ConsentManager();
|
|
4904
|
+
consent.revokeConsent(userId);
|
|
4930
4905
|
if (daemon.trajectoryCapture) {
|
|
4931
4906
|
try { await daemon.trajectoryCapture.shutdown(); } catch (e) { /* */ }
|
|
4932
4907
|
daemon.trajectoryCapture = null;
|
|
4933
4908
|
}
|
|
4934
|
-
try {
|
|
4935
|
-
const userId = ConsentManager.getOrCreateUserId();
|
|
4936
|
-
const consent = new ConsentManager();
|
|
4937
|
-
try { consent.revokeConsent(userId); } finally { consent.close(); }
|
|
4938
|
-
} catch (e) { /* */ }
|
|
4939
4909
|
daemon.broadcast({ type: 'training:status', data: { optedIn: false, captureActive: false } });
|
|
4940
4910
|
if (daemon.audit) daemon.audit.log('training.delete', {});
|
|
4941
4911
|
res.json({ ok: true, deleted: true });
|
|
@@ -4961,7 +4931,6 @@ Keep responses concise. Help them think, don't lecture them about the system the
|
|
|
4961
4931
|
'port', 'journalistInterval', 'rotationThreshold', 'autoRotation',
|
|
4962
4932
|
'qcThreshold', 'maxAgents', 'defaultProvider', 'defaultWorkingDir',
|
|
4963
4933
|
'onboardingDismissed', 'defaultModel', 'defaultChatProvider', 'defaultChatModel',
|
|
4964
|
-
'training_opt_in',
|
|
4965
4934
|
];
|
|
4966
4935
|
for (const key of Object.keys(req.body)) {
|
|
4967
4936
|
if (!ALLOWED_KEYS.includes(key)) {
|
|
@@ -666,17 +666,15 @@ export class Daemon {
|
|
|
666
666
|
}
|
|
667
667
|
|
|
668
668
|
async _initTrajectoryCapture() {
|
|
669
|
-
if (!this.config.training_opt_in) return;
|
|
670
669
|
try {
|
|
671
|
-
if (ConsentManager.isCaptureEnabled())
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
}
|
|
670
|
+
if (!ConsentManager.isCaptureEnabled()) return;
|
|
671
|
+
const pkgPath = new URL('../package.json', import.meta.url);
|
|
672
|
+
const version = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
673
|
+
this.trajectoryCapture = new TrajectoryCapture({
|
|
674
|
+
centralCommandUrl: process.env.GROOVE_CENTRAL_URL || 'https://api.groovedev.ai',
|
|
675
|
+
grooveVersion: version,
|
|
676
|
+
});
|
|
677
|
+
this.trajectoryCapture.init();
|
|
680
678
|
} catch (e) {
|
|
681
679
|
// Training capture is never critical
|
|
682
680
|
}
|