groove-dev 0.27.15 → 0.27.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +0 -10
- package/README.md +37 -1
- package/developerID_application.cer +0 -0
- package/node_modules/@groove-dev/daemon/src/api.js +586 -67
- package/node_modules/@groove-dev/daemon/src/classifier.js +24 -0
- package/node_modules/@groove-dev/daemon/src/credentials.js +12 -2
- package/node_modules/@groove-dev/daemon/src/federation/ambassador.js +204 -0
- package/node_modules/@groove-dev/daemon/src/federation/connection.js +359 -0
- package/node_modules/@groove-dev/daemon/src/federation/contracts.js +112 -0
- package/node_modules/@groove-dev/daemon/src/federation/whitelist.js +190 -0
- package/node_modules/@groove-dev/daemon/src/federation.js +166 -7
- package/node_modules/@groove-dev/daemon/src/index.js +172 -19
- package/node_modules/@groove-dev/daemon/src/introducer.js +52 -7
- package/node_modules/@groove-dev/daemon/src/journalist.js +46 -1
- package/node_modules/@groove-dev/daemon/src/memory.js +36 -16
- package/node_modules/@groove-dev/daemon/src/process.js +140 -23
- package/node_modules/@groove-dev/daemon/src/providers/base.js +1 -0
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +14 -0
- package/node_modules/@groove-dev/daemon/src/providers/codex.js +124 -28
- package/node_modules/@groove-dev/daemon/src/providers/gemini.js +104 -17
- package/node_modules/@groove-dev/daemon/src/providers/index.js +17 -0
- package/node_modules/@groove-dev/daemon/src/registry.js +10 -1
- package/node_modules/@groove-dev/daemon/src/rotator.js +93 -30
- package/node_modules/@groove-dev/daemon/src/skills.js +33 -3
- package/node_modules/@groove-dev/daemon/src/terminal-pty.js +9 -1
- package/node_modules/@groove-dev/daemon/src/tool-executor.js +11 -5
- package/node_modules/@groove-dev/daemon/src/toys.js +69 -0
- package/node_modules/@groove-dev/daemon/src/tunnel-manager.js +24 -5
- package/node_modules/@groove-dev/daemon/templates/toys-catalog.json +242 -0
- package/node_modules/@groove-dev/daemon/test/classifier.test.js +98 -0
- package/node_modules/@groove-dev/daemon/test/introducer.test.js +72 -1
- package/node_modules/@groove-dev/daemon/test/journalist.test.js +117 -0
- package/node_modules/@groove-dev/daemon/test/memory.test.js +37 -1
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +183 -4
- package/node_modules/@groove-dev/gui/dist/assets/index-Bg6_D2xK.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-D3rvwTHD.js +8607 -0
- package/node_modules/@groove-dev/gui/dist/index.html +3 -2
- package/node_modules/@groove-dev/gui/index.html +1 -0
- package/node_modules/@groove-dev/gui/src/app.css +7 -0
- package/node_modules/@groove-dev/gui/src/app.jsx +37 -10
- package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +21 -31
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +11 -6
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +42 -1
- package/node_modules/@groove-dev/gui/src/components/editor/breadcrumbs.jsx +30 -0
- package/node_modules/@groove-dev/gui/src/components/editor/code-editor.jsx +33 -2
- package/node_modules/@groove-dev/gui/src/components/editor/editor-status-bar.jsx +26 -0
- package/node_modules/@groove-dev/gui/src/components/editor/editor-tabs.jsx +113 -34
- package/node_modules/@groove-dev/gui/src/components/editor/goto-line.jsx +35 -0
- package/node_modules/@groove-dev/gui/src/components/editor/terminal.jsx +12 -6
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +15 -3
- package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +0 -1
- package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +165 -47
- package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +6 -2
- package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +11 -1
- package/node_modules/@groove-dev/gui/src/components/layout/terminal-panel.jsx +10 -9
- package/node_modules/@groove-dev/gui/src/components/marketplace/repo-import.jsx +9 -1
- package/node_modules/@groove-dev/gui/src/components/onboarding/provider-card.jsx +134 -0
- package/node_modules/@groove-dev/gui/src/components/onboarding/setup-wizard.jsx +819 -0
- package/node_modules/@groove-dev/gui/src/components/pro/pro-gate.jsx +12 -5
- package/node_modules/@groove-dev/gui/src/components/pro/upgrade-card.jsx +15 -8
- package/node_modules/@groove-dev/gui/src/components/pro/upgrade-modal.jsx +151 -0
- package/node_modules/@groove-dev/gui/src/components/settings/federation-activity.jsx +98 -0
- package/node_modules/@groove-dev/gui/src/components/settings/federation-panel.jsx +290 -0
- package/node_modules/@groove-dev/gui/src/components/settings/federation-peers.jsx +126 -0
- package/node_modules/@groove-dev/gui/src/components/settings/federation-wizard.jsx +293 -0
- package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +110 -67
- package/node_modules/@groove-dev/gui/src/components/settings/remote-server-card.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/settings/server-detail.jsx +310 -0
- package/node_modules/@groove-dev/gui/src/components/settings/server-dialog.jsx +4 -1
- package/node_modules/@groove-dev/gui/src/components/settings/server-list.jsx +59 -0
- package/node_modules/@groove-dev/gui/src/components/settings/ssh-wizard.jsx +549 -0
- package/node_modules/@groove-dev/gui/src/components/toys/toy-card.jsx +78 -0
- package/node_modules/@groove-dev/gui/src/components/toys/toy-creator.jsx +144 -0
- package/node_modules/@groove-dev/gui/src/components/toys/toy-launcher.jsx +187 -0
- package/node_modules/@groove-dev/gui/src/components/ui/toast.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/lib/electron.js +15 -0
- package/node_modules/@groove-dev/gui/src/lib/format.js +1 -0
- package/node_modules/@groove-dev/gui/src/stores/groove.js +388 -63
- package/node_modules/@groove-dev/gui/src/views/agents.jsx +148 -42
- package/node_modules/@groove-dev/gui/src/views/editor.jsx +92 -2
- package/node_modules/@groove-dev/gui/src/views/federation.jsx +37 -0
- package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +2 -42
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +35 -134
- package/node_modules/@groove-dev/gui/src/views/subscription-panel.jsx +327 -0
- package/node_modules/@groove-dev/gui/src/views/teams.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/views/toys.jsx +162 -0
- package/package.json +1 -1
- package/packages/daemon/src/api.js +586 -67
- package/packages/daemon/src/classifier.js +24 -0
- package/packages/daemon/src/credentials.js +12 -2
- package/packages/daemon/src/federation/ambassador.js +204 -0
- package/packages/daemon/src/federation/connection.js +359 -0
- package/packages/daemon/src/federation/contracts.js +112 -0
- package/packages/daemon/src/federation/whitelist.js +190 -0
- package/packages/daemon/src/federation.js +166 -7
- package/packages/daemon/src/index.js +172 -19
- package/packages/daemon/src/introducer.js +52 -7
- package/packages/daemon/src/journalist.js +46 -1
- package/packages/daemon/src/memory.js +36 -16
- package/packages/daemon/src/process.js +140 -23
- package/packages/daemon/src/providers/base.js +1 -0
- package/packages/daemon/src/providers/claude-code.js +14 -0
- package/packages/daemon/src/providers/codex.js +124 -28
- package/packages/daemon/src/providers/gemini.js +104 -17
- package/packages/daemon/src/providers/index.js +17 -0
- package/packages/daemon/src/registry.js +10 -1
- package/packages/daemon/src/rotator.js +93 -30
- package/packages/daemon/src/skills.js +33 -3
- package/packages/daemon/src/terminal-pty.js +9 -1
- package/packages/daemon/src/tool-executor.js +11 -5
- package/packages/daemon/src/toys.js +69 -0
- package/packages/daemon/src/tunnel-manager.js +24 -5
- package/packages/daemon/templates/toys-catalog.json +242 -0
- package/packages/gui/dist/assets/index-Bg6_D2xK.css +1 -0
- package/packages/gui/dist/assets/index-D3rvwTHD.js +8607 -0
- package/packages/gui/dist/index.html +3 -2
- package/packages/gui/index.html +1 -0
- package/packages/gui/src/app.css +7 -0
- package/packages/gui/src/app.jsx +37 -10
- package/packages/gui/src/components/agents/agent-chat.jsx +21 -31
- package/packages/gui/src/components/agents/agent-config.jsx +11 -6
- package/packages/gui/src/components/agents/agent-feed.jsx +2 -2
- package/packages/gui/src/components/agents/spawn-wizard.jsx +42 -1
- package/packages/gui/src/components/editor/breadcrumbs.jsx +30 -0
- package/packages/gui/src/components/editor/code-editor.jsx +33 -2
- package/packages/gui/src/components/editor/editor-status-bar.jsx +26 -0
- package/packages/gui/src/components/editor/editor-tabs.jsx +113 -34
- package/packages/gui/src/components/editor/goto-line.jsx +35 -0
- package/packages/gui/src/components/editor/terminal.jsx +12 -6
- package/packages/gui/src/components/layout/activity-bar.jsx +15 -3
- package/packages/gui/src/components/layout/app-shell.jsx +0 -1
- package/packages/gui/src/components/layout/breadcrumb-bar.jsx +165 -47
- package/packages/gui/src/components/layout/command-palette.jsx +6 -2
- package/packages/gui/src/components/layout/status-bar.jsx +11 -1
- package/packages/gui/src/components/layout/terminal-panel.jsx +10 -9
- package/packages/gui/src/components/marketplace/repo-import.jsx +9 -1
- package/packages/gui/src/components/onboarding/provider-card.jsx +134 -0
- package/packages/gui/src/components/onboarding/setup-wizard.jsx +819 -0
- package/packages/gui/src/components/pro/pro-gate.jsx +12 -5
- package/packages/gui/src/components/pro/upgrade-card.jsx +15 -8
- package/packages/gui/src/components/pro/upgrade-modal.jsx +151 -0
- package/packages/gui/src/components/settings/federation-activity.jsx +98 -0
- package/packages/gui/src/components/settings/federation-panel.jsx +290 -0
- package/packages/gui/src/components/settings/federation-peers.jsx +126 -0
- package/packages/gui/src/components/settings/federation-wizard.jsx +293 -0
- package/packages/gui/src/components/settings/quick-connect.jsx +110 -67
- package/packages/gui/src/components/settings/remote-server-card.jsx +3 -3
- package/packages/gui/src/components/settings/server-detail.jsx +310 -0
- package/packages/gui/src/components/settings/server-dialog.jsx +4 -1
- package/packages/gui/src/components/settings/server-list.jsx +59 -0
- package/packages/gui/src/components/settings/ssh-wizard.jsx +549 -0
- package/packages/gui/src/components/toys/toy-card.jsx +78 -0
- package/packages/gui/src/components/toys/toy-creator.jsx +144 -0
- package/packages/gui/src/components/toys/toy-launcher.jsx +187 -0
- package/packages/gui/src/components/ui/toast.jsx +2 -2
- package/packages/gui/src/lib/electron.js +15 -0
- package/packages/gui/src/lib/format.js +1 -0
- package/packages/gui/src/stores/groove.js +388 -63
- package/packages/gui/src/views/agents.jsx +148 -42
- package/packages/gui/src/views/editor.jsx +92 -2
- package/packages/gui/src/views/federation.jsx +37 -0
- package/packages/gui/src/views/marketplace.jsx +2 -42
- package/packages/gui/src/views/settings.jsx +35 -134
- package/packages/gui/src/views/subscription-panel.jsx +327 -0
- package/packages/gui/src/views/teams.jsx +3 -3
- package/packages/gui/src/views/toys.jsx +162 -0
- package/plans/chat-persistence-refactor.md +154 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BE6lYcd7.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-zdzOLAZM.js +0 -677
- package/packages/gui/dist/assets/index-BE6lYcd7.css +0 -1
- package/packages/gui/dist/assets/index-zdzOLAZM.js +0 -677
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// GROOVE — Federation Diplomatic Pouch Contracts
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
const CONTRACT_SCHEMAS = {
|
|
5
|
+
'task-request': {
|
|
6
|
+
required: ['taskId', 'description', 'requesterRole'],
|
|
7
|
+
optional: ['priority', 'suggestedRole', 'context', 'deadline'],
|
|
8
|
+
},
|
|
9
|
+
'task-accepted': {
|
|
10
|
+
required: ['taskId', 'assignedAgentId', 'assignedRole'],
|
|
11
|
+
optional: ['estimatedCompletion'],
|
|
12
|
+
},
|
|
13
|
+
'task-rejected': {
|
|
14
|
+
required: ['taskId', 'reason'],
|
|
15
|
+
optional: ['suggestion'],
|
|
16
|
+
},
|
|
17
|
+
'status-update': {
|
|
18
|
+
required: ['taskId', 'status'],
|
|
19
|
+
optional: ['progress', 'details', 'blockers'],
|
|
20
|
+
},
|
|
21
|
+
'delivery': {
|
|
22
|
+
required: ['taskId', 'result'],
|
|
23
|
+
optional: ['artifacts', 'summary', 'followUp'],
|
|
24
|
+
},
|
|
25
|
+
'question': {
|
|
26
|
+
required: ['questionId', 'text'],
|
|
27
|
+
optional: ['taskId', 'context', 'urgency'],
|
|
28
|
+
},
|
|
29
|
+
'capability-query': {
|
|
30
|
+
required: [],
|
|
31
|
+
optional: ['filter'],
|
|
32
|
+
},
|
|
33
|
+
'capability-response': {
|
|
34
|
+
required: ['roles', 'providerCount', 'agentCount'],
|
|
35
|
+
optional: ['availableRoles', 'busyAgents'],
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const VALID_TYPES = new Set(Object.keys(CONTRACT_SCHEMAS));
|
|
40
|
+
|
|
41
|
+
export function validateContract(contract) {
|
|
42
|
+
if (!contract || typeof contract !== 'object') {
|
|
43
|
+
return { valid: false, error: 'Contract must be an object' };
|
|
44
|
+
}
|
|
45
|
+
if (!contract.type || !VALID_TYPES.has(contract.type)) {
|
|
46
|
+
return { valid: false, error: `Invalid contract type: ${contract.type}` };
|
|
47
|
+
}
|
|
48
|
+
if (!contract.spec || typeof contract.spec !== 'object') {
|
|
49
|
+
return { valid: false, error: 'Contract must have a spec object' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const schema = CONTRACT_SCHEMAS[contract.type];
|
|
53
|
+
for (const field of schema.required) {
|
|
54
|
+
if (contract.spec[field] === undefined || contract.spec[field] === null) {
|
|
55
|
+
return { valid: false, error: `Missing required field: spec.${field}` };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { valid: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getContractTypes() {
|
|
63
|
+
return Object.keys(CONTRACT_SCHEMAS);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class ContractHandlerRegistry {
|
|
67
|
+
constructor() {
|
|
68
|
+
this.handlers = new Map();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
register(type, handler) {
|
|
72
|
+
if (!VALID_TYPES.has(type)) {
|
|
73
|
+
throw new Error(`Cannot register handler for unknown type: ${type}`);
|
|
74
|
+
}
|
|
75
|
+
this.handlers.set(type, handler);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async handle(contract, context) {
|
|
79
|
+
const validation = validateContract(contract);
|
|
80
|
+
if (!validation.valid) {
|
|
81
|
+
throw new Error(validation.error);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const handler = this.handlers.get(contract.type);
|
|
85
|
+
if (!handler) {
|
|
86
|
+
throw new Error(`No handler registered for type: ${contract.type}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return handler(contract, context);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
has(type) {
|
|
93
|
+
return this.handlers.has(type);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createCapabilityResponse(daemon) {
|
|
98
|
+
const agents = daemon.registry.getAll();
|
|
99
|
+
const roles = new Set(agents.map(a => a.role));
|
|
100
|
+
const busy = agents.filter(a => a.status === 'running');
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
type: 'capability-response',
|
|
104
|
+
spec: {
|
|
105
|
+
roles: Array.from(roles),
|
|
106
|
+
providerCount: agents.reduce((s, a) => { s.add(a.provider); return s; }, new Set()).size,
|
|
107
|
+
agentCount: agents.length,
|
|
108
|
+
availableRoles: Array.from(roles),
|
|
109
|
+
busyAgents: busy.length,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// GROOVE — Federation IP Whitelist Manager
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
5
|
+
import { resolve } from 'path';
|
|
6
|
+
import { EventEmitter } from 'events';
|
|
7
|
+
|
|
8
|
+
const PROBE_INTERVAL = 15_000;
|
|
9
|
+
const PROBE_TIMEOUT = 5_000;
|
|
10
|
+
|
|
11
|
+
const PRIVATE_PATTERNS = [
|
|
12
|
+
/^127\./, /^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./,
|
|
13
|
+
/^0\./, /^169\.254\./, /^localhost$/i, /^::1$/, /^\[::1\]$/,
|
|
14
|
+
/^fc/i, /^fd/i, /^fe80/i,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function isPrivateIp(host) {
|
|
18
|
+
return PRIVATE_PATTERNS.some(p => p.test(host));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function validateIp(ip) {
|
|
22
|
+
if (!ip || typeof ip !== 'string') throw new Error('IP is required');
|
|
23
|
+
const trimmed = ip.trim();
|
|
24
|
+
if (trimmed.length > 253) throw new Error('IP too long');
|
|
25
|
+
if (isPrivateIp(trimmed)) throw new Error('Cannot whitelist private/local addresses');
|
|
26
|
+
if (/[;&|`$(){}]/.test(trimmed)) throw new Error('Invalid characters in IP');
|
|
27
|
+
return trimmed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class WhitelistManager extends EventEmitter {
|
|
31
|
+
constructor(federation) {
|
|
32
|
+
super();
|
|
33
|
+
this.federation = federation;
|
|
34
|
+
this.daemon = federation.daemon;
|
|
35
|
+
this.filePath = resolve(federation.fedDir, 'whitelist.json');
|
|
36
|
+
this.entries = this._load();
|
|
37
|
+
this._probeTimer = null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_load() {
|
|
41
|
+
if (!existsSync(this.filePath)) return new Map();
|
|
42
|
+
try {
|
|
43
|
+
const data = JSON.parse(readFileSync(this.filePath, 'utf8'));
|
|
44
|
+
const map = new Map();
|
|
45
|
+
for (const entry of data) {
|
|
46
|
+
map.set(entry.ip, entry);
|
|
47
|
+
}
|
|
48
|
+
return map;
|
|
49
|
+
} catch {
|
|
50
|
+
return new Map();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_save() {
|
|
55
|
+
const dir = resolve(this.federation.fedDir);
|
|
56
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
57
|
+
writeFileSync(this.filePath, JSON.stringify(Array.from(this.entries.values()), null, 2), { mode: 0o600 });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
add(ip, port = 31415, name = '') {
|
|
61
|
+
const validated = validateIp(ip);
|
|
62
|
+
if (this.entries.has(validated)) {
|
|
63
|
+
throw new Error(`IP already whitelisted: ${validated}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const entry = {
|
|
67
|
+
ip: validated,
|
|
68
|
+
port: typeof port === 'number' ? port : 31415,
|
|
69
|
+
name: typeof name === 'string' ? name.trim().slice(0, 128) : '',
|
|
70
|
+
status: 'waiting',
|
|
71
|
+
addedAt: new Date().toISOString(),
|
|
72
|
+
lastProbe: null,
|
|
73
|
+
remoteDaemonId: null,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
this.entries.set(validated, entry);
|
|
77
|
+
this._save();
|
|
78
|
+
this.daemon.audit.log('federation.whitelist.add', { ip: validated, port: entry.port });
|
|
79
|
+
this.emit('added', entry);
|
|
80
|
+
return entry;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
remove(ip) {
|
|
84
|
+
const trimmed = ip?.trim();
|
|
85
|
+
if (!this.entries.has(trimmed)) {
|
|
86
|
+
throw new Error(`IP not in whitelist: ${trimmed}`);
|
|
87
|
+
}
|
|
88
|
+
const entry = this.entries.get(trimmed);
|
|
89
|
+
this.entries.delete(trimmed);
|
|
90
|
+
this._save();
|
|
91
|
+
this.daemon.audit.log('federation.whitelist.remove', { ip: trimmed });
|
|
92
|
+
this.emit('removed', { ip: trimmed, previousStatus: entry.status });
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
list() {
|
|
97
|
+
return Array.from(this.entries.values());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
isWhitelisted(ip) {
|
|
101
|
+
return this.entries.has(ip?.trim());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
getEntry(ip) {
|
|
105
|
+
return this.entries.get(ip?.trim()) || null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
startProbing() {
|
|
109
|
+
if (this._probeTimer) return;
|
|
110
|
+
this._probeTimer = setInterval(() => this._probeAll(), PROBE_INTERVAL);
|
|
111
|
+
this._probeAll();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
stopProbing() {
|
|
115
|
+
if (this._probeTimer) {
|
|
116
|
+
clearInterval(this._probeTimer);
|
|
117
|
+
this._probeTimer = null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async _probeAll() {
|
|
122
|
+
for (const entry of this.entries.values()) {
|
|
123
|
+
if (entry.status === 'connected') continue;
|
|
124
|
+
try {
|
|
125
|
+
await this._probePeer(entry);
|
|
126
|
+
} catch {
|
|
127
|
+
// Individual probe failures are expected
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async _probePeer(entry) {
|
|
133
|
+
const url = `http://${entry.ip}:${entry.port}/api/federation/whitelist-check`;
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT);
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(url, {
|
|
139
|
+
signal: controller.signal,
|
|
140
|
+
headers: { 'X-Groove-DaemonId': this.federation._daemonId() },
|
|
141
|
+
});
|
|
142
|
+
clearTimeout(timeout);
|
|
143
|
+
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
this._updateStatus(entry, 'waiting');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const data = await res.json();
|
|
150
|
+
entry.lastProbe = new Date().toISOString();
|
|
151
|
+
entry.remoteDaemonId = data.daemonId || null;
|
|
152
|
+
|
|
153
|
+
if (data.whitelisted) {
|
|
154
|
+
this._updateStatus(entry, 'mutual');
|
|
155
|
+
} else {
|
|
156
|
+
this._updateStatus(entry, 'waiting');
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
clearTimeout(timeout);
|
|
160
|
+
entry.lastProbe = new Date().toISOString();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
_updateStatus(entry, newStatus) {
|
|
165
|
+
const oldStatus = entry.status;
|
|
166
|
+
if (oldStatus === newStatus) return;
|
|
167
|
+
entry.status = newStatus;
|
|
168
|
+
this._save();
|
|
169
|
+
this.emit('status-change', { ip: entry.ip, oldStatus, newStatus, entry });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
setConnected(ip) {
|
|
173
|
+
const entry = this.entries.get(ip?.trim());
|
|
174
|
+
if (entry) {
|
|
175
|
+
this._updateStatus(entry, 'connected');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
setDisconnected(ip) {
|
|
180
|
+
const entry = this.entries.get(ip?.trim());
|
|
181
|
+
if (entry && entry.status === 'connected') {
|
|
182
|
+
this._updateStatus(entry, 'mutual');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
destroy() {
|
|
187
|
+
this.stopProbing();
|
|
188
|
+
this.removeAllListeners();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
// GROOVE — Federation (Ed25519 key exchange + contract signing)
|
|
1
|
+
// GROOVE — Federation (Ed25519 key exchange + contract signing + v1 protocol)
|
|
2
2
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
3
|
|
|
4
|
-
import { generateKeyPairSync, sign, verify, createPublicKey, createPrivateKey, createHash } from 'crypto';
|
|
4
|
+
import { generateKeyPairSync, sign, verify, createPublicKey, createPrivateKey, createHash, randomBytes } from 'crypto';
|
|
5
5
|
import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, readdirSync } from 'fs';
|
|
6
6
|
import { resolve } from 'path';
|
|
7
|
+
import { WhitelistManager } from './federation/whitelist.js';
|
|
8
|
+
import { ConnectionManager } from './federation/connection.js';
|
|
9
|
+
import { AmbassadorManager } from './federation/ambassador.js';
|
|
10
|
+
import { ContractHandlerRegistry, createCapabilityResponse } from './federation/contracts.js';
|
|
7
11
|
|
|
8
12
|
// Peer IDs must be safe for filenames — hex only (from SHA-256 fingerprint)
|
|
9
13
|
const PEER_ID_PATTERN = /^[a-f0-9]{1,64}$/;
|
|
@@ -66,10 +70,11 @@ export class Federation {
|
|
|
66
70
|
* @returns {{ payload: object, signature: string }}
|
|
67
71
|
*/
|
|
68
72
|
sign(payload) {
|
|
69
|
-
const
|
|
73
|
+
const enriched = { ...payload, timestamp: Date.now(), nonce: randomBytes(16).toString('hex') };
|
|
74
|
+
const data = Buffer.from(JSON.stringify(enriched), 'utf8');
|
|
70
75
|
const sig = sign(null, data, this.privateKey);
|
|
71
76
|
return {
|
|
72
|
-
payload,
|
|
77
|
+
payload: enriched,
|
|
73
78
|
signature: sig.toString('base64'),
|
|
74
79
|
};
|
|
75
80
|
}
|
|
@@ -86,6 +91,12 @@ export class Federation {
|
|
|
86
91
|
if (!peer) return false;
|
|
87
92
|
|
|
88
93
|
try {
|
|
94
|
+
// Replay protection: reject messages older than 5 minutes
|
|
95
|
+
if (payload.timestamp && (Date.now() - payload.timestamp > 5 * 60 * 1000)) {
|
|
96
|
+
console.warn(`[federation] Rejected stale message from ${peerId}`);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
89
100
|
const data = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
90
101
|
const sig = Buffer.from(signature, 'base64');
|
|
91
102
|
const pubKey = createPublicKey(peer.publicKey);
|
|
@@ -132,6 +143,26 @@ export class Federation {
|
|
|
132
143
|
* @returns {object} pairing result
|
|
133
144
|
*/
|
|
134
145
|
async initiatePairing(remoteUrl) {
|
|
146
|
+
// SSRF protection: block private/reserved IPs
|
|
147
|
+
try {
|
|
148
|
+
const parsed = new URL(remoteUrl);
|
|
149
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
150
|
+
throw new Error('Only HTTP(S) URLs allowed');
|
|
151
|
+
}
|
|
152
|
+
const host = parsed.hostname;
|
|
153
|
+
const privatePatterns = [
|
|
154
|
+
/^127\./, /^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./,
|
|
155
|
+
/^0\./, /^169\.254\./, /^localhost$/i, /^::1$/, /^\[::1\]$/,
|
|
156
|
+
/^fc/i, /^fd/i, /^fe80/i,
|
|
157
|
+
];
|
|
158
|
+
if (privatePatterns.some(p => p.test(host))) {
|
|
159
|
+
throw new Error('Cannot pair with private/local addresses');
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (err.message.includes('Cannot pair') || err.message.includes('Only HTTP')) throw err;
|
|
163
|
+
throw new Error(`Invalid remote URL: ${err.message}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
135
166
|
const localInfo = this._localInfo();
|
|
136
167
|
|
|
137
168
|
// Send our public key to the remote daemon's pairing endpoint
|
|
@@ -189,7 +220,12 @@ export class Federation {
|
|
|
189
220
|
* @param {object} remoteInfo — { id, name, host, port, publicKey }
|
|
190
221
|
* @returns {object} our info + public key for the remote to store
|
|
191
222
|
*/
|
|
192
|
-
acceptPairing(remoteInfo) {
|
|
223
|
+
acceptPairing(remoteInfo, callerIp) {
|
|
224
|
+
// Gate: caller IP must be whitelisted (bi-directional requirement)
|
|
225
|
+
if (!callerIp || !this.whitelist?.isWhitelisted(callerIp)) {
|
|
226
|
+
throw new Error('Pairing rejected: caller IP not whitelisted');
|
|
227
|
+
}
|
|
228
|
+
|
|
193
229
|
if (!remoteInfo.id || !remoteInfo.publicKey) {
|
|
194
230
|
throw new Error('Invalid pairing request: missing id or publicKey');
|
|
195
231
|
}
|
|
@@ -207,13 +243,13 @@ export class Federation {
|
|
|
207
243
|
this._savePeer({
|
|
208
244
|
id: remoteInfo.id,
|
|
209
245
|
name: remoteInfo.name || remoteInfo.id,
|
|
210
|
-
host:
|
|
246
|
+
host: callerIp,
|
|
211
247
|
port: remoteInfo.port,
|
|
212
248
|
publicKey: remoteInfo.publicKey,
|
|
213
249
|
pairedAt: new Date().toISOString(),
|
|
214
250
|
});
|
|
215
251
|
|
|
216
|
-
this.daemon.audit.log('federation.pair', { peerId: remoteInfo.id, peerHost:
|
|
252
|
+
this.daemon.audit.log('federation.pair', { peerId: remoteInfo.id, peerHost: callerIp });
|
|
217
253
|
|
|
218
254
|
// Return our info so the remote can store us
|
|
219
255
|
const localInfo = this._localInfo();
|
|
@@ -341,12 +377,135 @@ export class Federation {
|
|
|
341
377
|
}));
|
|
342
378
|
}
|
|
343
379
|
|
|
380
|
+
// --- v1 Protocol ---
|
|
381
|
+
|
|
382
|
+
initialize() {
|
|
383
|
+
this.whitelist = new WhitelistManager(this);
|
|
384
|
+
this.connections = new ConnectionManager(this);
|
|
385
|
+
this.ambassadors = new AmbassadorManager(this);
|
|
386
|
+
this.contractHandlers = new ContractHandlerRegistry();
|
|
387
|
+
|
|
388
|
+
this.contractHandlers.register('capability-query', (_contract, _ctx) => {
|
|
389
|
+
return createCapabilityResponse(this.daemon);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
this.whitelist.on('status-change', ({ ip, newStatus, entry }) => {
|
|
393
|
+
this.daemon.broadcast({ type: 'federation:whitelist', data: this.whitelist.list() });
|
|
394
|
+
if (newStatus === 'mutual') {
|
|
395
|
+
this.connections.onMutual(ip, entry.port, entry.remoteDaemonId);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
this.connections.on('message', ({ message, peerId, daemonId, inbound }) => {
|
|
400
|
+
const senderId = inbound ? daemonId : peerId;
|
|
401
|
+
if (message.type === 'pouch' && message.senderId && message.payload && message.signature) {
|
|
402
|
+
try {
|
|
403
|
+
this.ambassadors.receivePouch(message.senderId, message.payload, message.signature);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
console.warn(`[federation] Pouch error from ${senderId}: ${err.message}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
this.whitelist.startProbing();
|
|
411
|
+
console.log(`[federation] v1 initialized — daemon ${this._daemonId()}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
handleKnock(senderId, publicKey, payload, signature, callerIp) {
|
|
415
|
+
if (!senderId || !publicKey || !payload || !signature) {
|
|
416
|
+
throw new Error('Missing knock fields');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Gate: caller IP must be in our whitelist (bi-directional requirement)
|
|
420
|
+
if (!callerIp || !this.whitelist?.isWhitelisted(callerIp)) {
|
|
421
|
+
throw new Error('Knock rejected: caller IP not whitelisted');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
validatePeerId(senderId);
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
createPublicKey(publicKey);
|
|
428
|
+
} catch {
|
|
429
|
+
throw new Error('Invalid public key in knock');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Register peer key only after whitelist gate passes
|
|
433
|
+
if (!this.peers.has(senderId)) {
|
|
434
|
+
this._savePeer({
|
|
435
|
+
id: senderId,
|
|
436
|
+
name: senderId,
|
|
437
|
+
host: callerIp,
|
|
438
|
+
port: null,
|
|
439
|
+
publicKey,
|
|
440
|
+
pairedAt: new Date().toISOString(),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (!this.verify(senderId, payload, signature)) {
|
|
445
|
+
throw new Error('Knock signature verification failed');
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
this.daemon.audit.log('federation.knock', { senderId, callerIp });
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
accepted: true,
|
|
452
|
+
peerId: this._daemonId(),
|
|
453
|
+
peerName: this._daemonId(),
|
|
454
|
+
publicKey: this.getPublicKeyPem(),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
handleWsUpgrade(ws, daemonId, callerIp, signatureHeader) {
|
|
459
|
+
// Gate: caller IP must be whitelisted (bi-directional requirement)
|
|
460
|
+
if (!callerIp || !this.whitelist?.isWhitelisted(callerIp)) {
|
|
461
|
+
ws.close(4001, 'IP not whitelisted');
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (!daemonId || !this.peers.has(daemonId)) {
|
|
466
|
+
ws.close(4001, 'Unknown daemon');
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Verify the cryptographic signature header
|
|
471
|
+
if (!signatureHeader) {
|
|
472
|
+
ws.close(4003, 'Missing signature');
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
const decoded = JSON.parse(Buffer.from(signatureHeader, 'base64').toString());
|
|
477
|
+
if (!decoded.payload || !decoded.signature || !this.verify(daemonId, decoded.payload, decoded.signature)) {
|
|
478
|
+
ws.close(4003, 'Signature verification failed');
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
} catch {
|
|
482
|
+
ws.close(4003, 'Malformed signature header');
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
this.connections.handleInboundConnection(ws, daemonId);
|
|
487
|
+
this.daemon.broadcast({ type: 'federation:whitelist', data: this.whitelist?.list() || [] });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
isWhitelisted(ip) {
|
|
491
|
+
return this.whitelist?.isWhitelisted(ip) || false;
|
|
492
|
+
}
|
|
493
|
+
|
|
344
494
|
getStatus() {
|
|
345
495
|
return {
|
|
346
496
|
id: this._daemonId(),
|
|
347
497
|
peers: this.getPeers(),
|
|
348
498
|
peerCount: this.peers.size,
|
|
349
499
|
hasKeypair: existsSync(this.keyPath),
|
|
500
|
+
whitelist: this.whitelist?.list() || [],
|
|
501
|
+
connections: this.connections?.getStatus() || [],
|
|
502
|
+
ambassadors: this.ambassadors?.getStatus() || { ambassadors: [], totalQueued: 0 },
|
|
350
503
|
};
|
|
351
504
|
}
|
|
505
|
+
|
|
506
|
+
destroy() {
|
|
507
|
+
this.whitelist?.destroy();
|
|
508
|
+
this.connections?.destroy();
|
|
509
|
+
this.ambassadors?.destroy();
|
|
510
|
+
}
|
|
352
511
|
}
|