groove-dev 0.27.197 → 0.27.199
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/axom-integration/Screenshot_2026-07-25_at_5.25.30_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_5.33.26_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_6.11.53_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_6.44.56_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_6.45.35_PM.png +0 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +2 -0
- package/node_modules/@groove-dev/daemon/src/axom-connector.js +368 -0
- package/node_modules/@groove-dev/daemon/src/axom-install.js +162 -0
- package/node_modules/@groove-dev/daemon/src/axom-remote.js +230 -0
- package/node_modules/@groove-dev/daemon/src/axom-server.js +249 -0
- package/node_modules/@groove-dev/daemon/src/federation.js +6 -0
- package/node_modules/@groove-dev/daemon/src/index.js +14 -0
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +8 -1
- package/node_modules/@groove-dev/daemon/src/innerchat-relay.js +89 -0
- package/node_modules/@groove-dev/daemon/src/innerchat.js +261 -1
- package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
- package/node_modules/@groove-dev/daemon/src/network-guard.js +56 -0
- package/node_modules/@groove-dev/daemon/src/process.js +1 -1
- package/node_modules/@groove-dev/daemon/src/providers/axom.js +66 -0
- package/node_modules/@groove-dev/daemon/src/providers/index.js +2 -0
- package/node_modules/@groove-dev/daemon/src/routes/axom.js +267 -0
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +334 -20
- package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +455 -0
- package/node_modules/@groove-dev/daemon/test/axom-remote.test.js +115 -0
- package/node_modules/@groove-dev/daemon/test/axom-server.test.js +228 -0
- package/node_modules/@groove-dev/daemon/test/innerchat-relay.test.js +251 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BbA-X4CE.js +1084 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BbE3qX83.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/App.jsx +2 -0
- package/node_modules/@groove-dev/gui/src/app.css +53 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +3 -1
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +22 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +279 -0
- package/node_modules/@groove-dev/gui/src/views/axom.jsx +1897 -0
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +232 -4
- package/package.json +2 -2
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +2 -0
- package/packages/daemon/src/axom-connector.js +368 -0
- package/packages/daemon/src/axom-install.js +162 -0
- package/packages/daemon/src/axom-remote.js +230 -0
- package/packages/daemon/src/axom-server.js +249 -0
- package/packages/daemon/src/federation.js +6 -0
- package/packages/daemon/src/index.js +14 -0
- package/packages/daemon/src/innerchat-docs.js +8 -1
- package/packages/daemon/src/innerchat-relay.js +89 -0
- package/packages/daemon/src/innerchat.js +261 -1
- package/packages/daemon/src/introducer.js +1 -1
- package/packages/daemon/src/network-guard.js +56 -0
- package/packages/daemon/src/process.js +1 -1
- package/packages/daemon/src/providers/axom.js +66 -0
- package/packages/daemon/src/providers/index.js +2 -0
- package/packages/daemon/src/routes/axom.js +267 -0
- package/packages/daemon/src/routes/innerchat.js +334 -20
- package/packages/gui/dist/assets/index-BbA-X4CE.js +1084 -0
- package/packages/gui/dist/assets/index-BbE3qX83.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/App.jsx +2 -0
- package/packages/gui/src/app.css +53 -0
- package/packages/gui/src/components/agents/agent-feed.jsx +3 -1
- package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
- package/packages/gui/src/stores/groove.js +22 -0
- package/packages/gui/src/stores/slices/axom-slice.js +279 -0
- package/packages/gui/src/views/axom.jsx +1897 -0
- package/packages/gui/src/views/settings.jsx +232 -4
- package/node_modules/@groove-dev/gui/dist/assets/index-Da2nbd6M.js +0 -1043
- package/node_modules/@groove-dev/gui/dist/assets/index-zmrIbwNm.css +0 -1
- package/packages/gui/dist/assets/index-Da2nbd6M.js +0 -1043
- package/packages/gui/dist/assets/index-zmrIbwNm.css +0 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { saveConfig } from '../firstrun.js';
|
|
5
|
+
import { validateEndpoint, AXOM_DEFAULT_PORT } from '../axom-connector.js';
|
|
6
|
+
import { hardwareReport } from '../axom-server.js';
|
|
7
|
+
import { validateRemote } from '../axom-remote.js';
|
|
8
|
+
|
|
9
|
+
// Interrupt text is capped runtime-side at 2000 chars (contract §2, with a
|
|
10
|
+
// `truncated` flag in the response); we allow headroom and let the runtime be
|
|
11
|
+
// the authority on truncation — its flag is the honest signal, not ours.
|
|
12
|
+
const MAX_INTERRUPT_CHARS = 8000;
|
|
13
|
+
|
|
14
|
+
export function registerAxomRoutes(app, daemon) {
|
|
15
|
+
app.get('/api/axom/status', (req, res) => {
|
|
16
|
+
res.json(daemon.axom.status());
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
app.get('/api/axom/sessions', (req, res) => {
|
|
20
|
+
const sessions = daemon.axom.status().endpoints.flatMap((ep) =>
|
|
21
|
+
ep.sessions.map((s) => ({ endpoint: ep.name, ...s })));
|
|
22
|
+
res.json(sessions);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// In-daemon ring backfill for the GUI (initial load / scrubber). `since` is
|
|
26
|
+
// an envelope id ("ev-000123"); events after it are returned.
|
|
27
|
+
app.get('/api/axom/sessions/:id/events', (req, res) => {
|
|
28
|
+
try {
|
|
29
|
+
const sinceSeq = /^ev-\d+$/.test(req.query.since || '')
|
|
30
|
+
? parseInt(req.query.since.slice(3), 10) : 0;
|
|
31
|
+
res.json(daemon.axom.events(req.query.endpoint, req.params.id, sinceSeq));
|
|
32
|
+
} catch (err) {
|
|
33
|
+
res.status(404).json({ error: err.message });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// §12: message starts a turn. Contract statuses (202/409/413) mirror
|
|
38
|
+
// through verbatim — the GUI reads them, we don't reinterpret.
|
|
39
|
+
app.post('/api/axom/sessions/:id/message', async (req, res) => {
|
|
40
|
+
try {
|
|
41
|
+
const { text, endpoint, clientRef } = req.body || {};
|
|
42
|
+
if (!text || typeof text !== 'string' || !text.trim()) {
|
|
43
|
+
return res.status(400).json({ error: 'text is required' });
|
|
44
|
+
}
|
|
45
|
+
if (clientRef !== undefined && (typeof clientRef !== 'string' || clientRef.length > 64)) {
|
|
46
|
+
return res.status(400).json({ error: 'clientRef must be a string of at most 64 chars' });
|
|
47
|
+
}
|
|
48
|
+
const result = await daemon.axom.message(endpoint, req.params.id, text, clientRef);
|
|
49
|
+
daemon.audit.log('axom.message', { session: req.params.id, chars: text.length, status: result.status });
|
|
50
|
+
res.status(result.status).json(result.body);
|
|
51
|
+
} catch (err) {
|
|
52
|
+
res.status(502).json({ error: err.message });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
app.post('/api/axom/sessions/:id/interrupt', async (req, res) => {
|
|
57
|
+
try {
|
|
58
|
+
const { text, endpoint } = req.body || {};
|
|
59
|
+
if (!text || typeof text !== 'string' || !text.trim()) {
|
|
60
|
+
return res.status(400).json({ error: 'text is required' });
|
|
61
|
+
}
|
|
62
|
+
if (text.length > MAX_INTERRUPT_CHARS) {
|
|
63
|
+
return res.status(400).json({ error: `text exceeds ${MAX_INTERRUPT_CHARS} chars` });
|
|
64
|
+
}
|
|
65
|
+
const result = await daemon.axom.interrupt(endpoint, req.params.id, text);
|
|
66
|
+
daemon.audit.log('axom.interrupt', { session: req.params.id, chars: text.length });
|
|
67
|
+
res.json(result); // verbatim {id, truncated} from the runtime
|
|
68
|
+
} catch (err) {
|
|
69
|
+
res.status(502).json({ error: err.message });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
app.post('/api/axom/sessions/:id/stop', async (req, res) => {
|
|
74
|
+
try {
|
|
75
|
+
const result = await daemon.axom.stop(req.body?.endpoint, req.params.id);
|
|
76
|
+
daemon.audit.log('axom.stop', { session: req.params.id });
|
|
77
|
+
res.json(result);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
res.status(502).json({ error: err.message });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Machine readiness for local inference — powers the welcome page's
|
|
84
|
+
// "can this device run Axom?" verdict.
|
|
85
|
+
app.get('/api/axom/hardware', (req, res) => {
|
|
86
|
+
res.json(hardwareReport(daemon.grooveDir));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// This GROOVE's own Axom endpoint — what another instance would paste.
|
|
90
|
+
// serve binds 127.0.0.1 (§6), so cross-machine reach is the user's own
|
|
91
|
+
// channel; we hand them the exact tunnel one-liner rather than pretending
|
|
92
|
+
// the URL travels on its own.
|
|
93
|
+
app.get('/api/axom/my-endpoint', (req, res) => {
|
|
94
|
+
const running = daemon.axomServer.list().find((i) => i.status === 'running');
|
|
95
|
+
const port = running?.port || AXOM_DEFAULT_PORT;
|
|
96
|
+
const host = os.hostname();
|
|
97
|
+
const user = os.userInfo().username;
|
|
98
|
+
res.json({
|
|
99
|
+
url: `http://127.0.0.1:${port}`,
|
|
100
|
+
port,
|
|
101
|
+
host,
|
|
102
|
+
running: !!running,
|
|
103
|
+
instanceId: running?.id || null,
|
|
104
|
+
tunnelCommand: `ssh -N -L ${port}:localhost:${port} ${user}@${host}`,
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// §14: shut down the runtime itself. Works for any endpoint that supports
|
|
109
|
+
// the verb — including remote ones, which GROOVE could never reach with a
|
|
110
|
+
// signal. A 404 means the runtime predates §14; the GUI must say so rather
|
|
111
|
+
// than claim the runtime is gone.
|
|
112
|
+
app.post('/api/axom/shutdown', async (req, res) => {
|
|
113
|
+
try {
|
|
114
|
+
const result = await daemon.axom.shutdown(req.body?.endpoint, { force: !!req.body?.force });
|
|
115
|
+
daemon.audit.log('axom.shutdown', { endpoint: req.body?.endpoint || null, status: result.status });
|
|
116
|
+
res.status(result.status === 404 ? 501 : result.status)
|
|
117
|
+
.json(result.status === 404
|
|
118
|
+
? { error: 'this runtime does not support remote shutdown (predates contract §14)' }
|
|
119
|
+
: result.body);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
res.status(502).json({ error: err.message });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── Remote runtime control over SSH (manual only, never automatic) ──────
|
|
126
|
+
|
|
127
|
+
app.get('/api/axom/remote', async (req, res) => {
|
|
128
|
+
res.json(await daemon.axomRemote.status());
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
app.patch('/api/axom/remote', (req, res) => {
|
|
132
|
+
const remote = req.body || {};
|
|
133
|
+
if (remote.clear === true) {
|
|
134
|
+
delete daemon.config.axom?.remote;
|
|
135
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
136
|
+
return res.json({ configured: false });
|
|
137
|
+
}
|
|
138
|
+
const problem = validateRemote(remote);
|
|
139
|
+
if (problem) return res.status(400).json({ error: problem });
|
|
140
|
+
const { host, user, sshPort, port, command, logPath } = remote;
|
|
141
|
+
daemon.config.axom = {
|
|
142
|
+
...(daemon.config.axom || {}),
|
|
143
|
+
remote: { host, user, sshPort, port, command, logPath },
|
|
144
|
+
};
|
|
145
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
146
|
+
daemon.audit.log('axom.remote.config', { host, user });
|
|
147
|
+
res.json(daemon.config.axom.remote);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Reachability only — opens/heals the port-forward. Never starts a runtime.
|
|
151
|
+
app.post('/api/axom/remote/tunnel', async (req, res) => {
|
|
152
|
+
try {
|
|
153
|
+
res.json(await daemon.axomRemote.ensureTunnel());
|
|
154
|
+
} catch (err) {
|
|
155
|
+
res.status(502).json({ error: err.message });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
app.post('/api/axom/remote/start', async (req, res) => {
|
|
160
|
+
try {
|
|
161
|
+
res.json(await daemon.axomRemote.start());
|
|
162
|
+
} catch (err) {
|
|
163
|
+
res.status(502).json({ error: err.message });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
app.post('/api/axom/remote/stop', async (req, res) => {
|
|
168
|
+
try {
|
|
169
|
+
res.json(await daemon.axomRemote.stop({ force: !!req.body?.force }));
|
|
170
|
+
} catch (err) {
|
|
171
|
+
res.status(502).json({ error: err.message });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ── Managed local instances (contract §11) ──────────────────────────────
|
|
176
|
+
|
|
177
|
+
app.get('/api/axom/instances', (req, res) => {
|
|
178
|
+
res.json(daemon.axomServer.list());
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
app.post('/api/axom/instances', async (req, res) => {
|
|
182
|
+
try {
|
|
183
|
+
const instance = await daemon.axomServer.start(req.body?.id || 'default');
|
|
184
|
+
res.json(instance);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
res.status(500).json({ error: err.message });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
app.delete('/api/axom/instances/:id', async (req, res) => {
|
|
191
|
+
try {
|
|
192
|
+
await daemon.axomServer.stop(req.params.id);
|
|
193
|
+
res.json({ ok: true });
|
|
194
|
+
} catch (err) {
|
|
195
|
+
res.status(400).json({ error: err.message });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ── Manifest-driven install (contract §11) ──────────────────────────────
|
|
200
|
+
|
|
201
|
+
app.get('/api/axom/install', (req, res) => {
|
|
202
|
+
res.json(daemon.axomInstaller.getStatus());
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
app.post('/api/axom/install', (req, res) => {
|
|
206
|
+
// Long-running — kick off and let the axom:install:progress broadcasts
|
|
207
|
+
// carry the story; the GUI polls GET /api/axom/install as backstop.
|
|
208
|
+
daemon.axomInstaller.install(req.body?.manifestUrl).catch(() => { /* status carries the error */ });
|
|
209
|
+
res.json({ started: true });
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
app.get('/api/axom/config', (req, res) => {
|
|
213
|
+
res.json({ endpoints: daemon.config.axom?.endpoints || [] });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
app.patch('/api/axom/config', (req, res) => {
|
|
217
|
+
const { endpoints, command, mock, manifestUrl, modelDir } = req.body || {};
|
|
218
|
+
|
|
219
|
+
// Runtime settings (contract §11): serve binary path, mock mode,
|
|
220
|
+
// install-manifest URL, model dir. Any subset may be patched alone.
|
|
221
|
+
const settings = {};
|
|
222
|
+
if (command !== undefined) {
|
|
223
|
+
if (command !== null && (typeof command !== 'string' || !command.trim())) {
|
|
224
|
+
return res.status(400).json({ error: 'command must be a non-empty string or null' });
|
|
225
|
+
}
|
|
226
|
+
settings.command = command;
|
|
227
|
+
}
|
|
228
|
+
if (mock !== undefined) settings.mock = !!mock;
|
|
229
|
+
if (manifestUrl !== undefined) {
|
|
230
|
+
if (manifestUrl !== null && !/^https?:\/\//.test(manifestUrl)) {
|
|
231
|
+
return res.status(400).json({ error: 'manifestUrl must be http(s) or null' });
|
|
232
|
+
}
|
|
233
|
+
settings.manifestUrl = manifestUrl;
|
|
234
|
+
}
|
|
235
|
+
if (modelDir !== undefined) settings.modelDir = modelDir;
|
|
236
|
+
if (Object.keys(settings).length > 0) {
|
|
237
|
+
daemon.config.axom = { ...(daemon.config.axom || {}), ...settings };
|
|
238
|
+
if (endpoints === undefined) {
|
|
239
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
240
|
+
daemon.audit.log('axom.config', { keys: Object.keys(settings) });
|
|
241
|
+
return res.json({ ...daemon.config.axom });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!Array.isArray(endpoints)) {
|
|
246
|
+
return res.status(400).json({ error: 'endpoints array is required' });
|
|
247
|
+
}
|
|
248
|
+
if (endpoints.length > 16) {
|
|
249
|
+
return res.status(400).json({ error: 'too many endpoints (max 16)' });
|
|
250
|
+
}
|
|
251
|
+
const seen = new Set();
|
|
252
|
+
for (const entry of endpoints) {
|
|
253
|
+
const problem = validateEndpoint(entry);
|
|
254
|
+
if (problem) return res.status(400).json({ error: problem });
|
|
255
|
+
if (seen.has(entry.name)) {
|
|
256
|
+
return res.status(400).json({ error: `duplicate endpoint name: ${entry.name}` });
|
|
257
|
+
}
|
|
258
|
+
seen.add(entry.name);
|
|
259
|
+
}
|
|
260
|
+
const cleaned = endpoints.map(({ name, url }) => ({ name, url: url.replace(/\/+$/, '') }));
|
|
261
|
+
daemon.config.axom = { ...(daemon.config.axom || {}), endpoints: cleaned };
|
|
262
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
263
|
+
daemon.axom.configure(cleaned);
|
|
264
|
+
daemon.audit.log('axom.config', { endpoints: cleaned.map((e) => e.name) });
|
|
265
|
+
res.json({ endpoints: cleaned });
|
|
266
|
+
});
|
|
267
|
+
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
2
|
|
|
3
3
|
import { MAX_EXCHANGES } from '../innerchat.js';
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
5
|
+
import { validatePeer, parsePeerRef } from '../innerchat-relay.js';
|
|
6
|
+
import { saveConfig } from '../firstrun.js';
|
|
7
|
+
|
|
8
|
+
const pkgVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
4
9
|
|
|
5
10
|
// Agents know each other by name, not id. Exact matches win first so
|
|
6
11
|
// `fullstack-1` never resolves to `fullstack-14`; only if nothing matches
|
|
@@ -22,14 +27,16 @@ function resolveAgent(daemon, ref) {
|
|
|
22
27
|
return partial.length === 1 ? partial[0] : null;
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
function resolveParties(daemon, req, res) {
|
|
28
|
-
const { from, to, message } = req.body || {};
|
|
29
|
-
|
|
30
|
+
// The calling agent is always local. Writes a 404 and returns null on miss.
|
|
31
|
+
function resolveFrom(daemon, from, res) {
|
|
30
32
|
const fromAgent = resolveAgent(daemon, from);
|
|
31
33
|
if (!fromAgent) { res.status(404).json({ error: `Unknown calling agent: ${from}` }); return null; }
|
|
34
|
+
return fromAgent;
|
|
35
|
+
}
|
|
32
36
|
|
|
37
|
+
// Resolve a LOCAL to/message pair, or write the appropriate 400/404 and return
|
|
38
|
+
// null. Used only when `to` is a plain (non-peer) name.
|
|
39
|
+
function resolveLocalTo(daemon, fromAgent, to, message, res) {
|
|
33
40
|
const toAgent = resolveAgent(daemon, to);
|
|
34
41
|
if (!toAgent) {
|
|
35
42
|
const others = daemon.registry.getAll().filter((a) => a.id !== fromAgent.id);
|
|
@@ -42,32 +49,157 @@ function resolveParties(daemon, req, res) {
|
|
|
42
49
|
}
|
|
43
50
|
return null;
|
|
44
51
|
}
|
|
45
|
-
|
|
46
52
|
if (!message || typeof message !== 'string' || !message.trim()) {
|
|
47
53
|
res.status(400).json({ error: 'message is required' }); return null;
|
|
48
54
|
}
|
|
49
|
-
return {
|
|
55
|
+
return { toAgent, message: message.trim() };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Confirm a relay request is from a configured, signature-verified peer.
|
|
59
|
+
// Returns the peer record, or writes a 403 and returns null.
|
|
60
|
+
function verifyRelayCaller(daemon, body, res) {
|
|
61
|
+
const { payload, signature } = body || {};
|
|
62
|
+
if (!payload || typeof payload !== 'object' || !signature) {
|
|
63
|
+
res.status(400).json({ error: 'signed relay envelope required ({ payload, signature })' });
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const fromDaemonId = payload.fromDaemonId;
|
|
67
|
+
const peer = daemon.innerchat._peerByDaemonId(fromDaemonId);
|
|
68
|
+
if (!peer) {
|
|
69
|
+
res.status(403).json({ error: `Unknown daemon "${String(fromDaemonId).slice(0, 16)}" — not a configured InnerChat peer.` });
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (!daemon.federation.verify(fromDaemonId, payload, signature)) {
|
|
73
|
+
res.status(403).json({ error: 'Signature verification failed.' });
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return peer;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A peer relay is accepted only when the sender is BOTH a configured peer
|
|
80
|
+
// (in innerchatPeers — the human's decision) AND signature-verifies against a
|
|
81
|
+
// stored federation public key. Those are independent gates, which is what
|
|
82
|
+
// makes automatic key exchange safe: holding someone's public key grants
|
|
83
|
+
// nothing on its own. So adding a peer can fetch and store its key without a
|
|
84
|
+
// separate manual pairing step.
|
|
85
|
+
async function exchangeKeys(daemon, url, alias) {
|
|
86
|
+
const base = url.replace(/\/+$/, '');
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
const timer = setTimeout(() => controller.abort(), 8000);
|
|
89
|
+
try {
|
|
90
|
+
const res = await fetch(`${base}/api/innerchat/identity`, { signal: controller.signal });
|
|
91
|
+
if (!res.ok) throw new Error(`peer returned HTTP ${res.status}`);
|
|
92
|
+
const id = await res.json();
|
|
93
|
+
if (!id?.daemonId || !id?.publicKey) throw new Error('peer did not return an identity');
|
|
94
|
+
|
|
95
|
+
// Store their key so we can verify what they send us.
|
|
96
|
+
daemon.federation._savePeer({
|
|
97
|
+
id: id.daemonId,
|
|
98
|
+
name: alias,
|
|
99
|
+
host: new URL(base).hostname,
|
|
100
|
+
port: Number(new URL(base).port) || 80,
|
|
101
|
+
publicKey: id.publicKey,
|
|
102
|
+
pairedAt: new Date().toISOString(),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Push ours so they can verify what WE send — this is the half that
|
|
106
|
+
// otherwise required a manual step on the other machine.
|
|
107
|
+
let pushed = false;
|
|
108
|
+
try {
|
|
109
|
+
const push = await fetch(`${base}/api/innerchat/identity`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'Content-Type': 'application/json' },
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
daemonId: daemon.federation.getDaemonId(),
|
|
114
|
+
publicKey: daemon.federation.getPublicKeyPem(),
|
|
115
|
+
name: daemon.config?.daemonName || 'groove',
|
|
116
|
+
}),
|
|
117
|
+
signal: controller.signal,
|
|
118
|
+
});
|
|
119
|
+
pushed = push.ok;
|
|
120
|
+
} catch { /* reachable for GET but not POST — report partial */ }
|
|
121
|
+
|
|
122
|
+
return { ok: true, peerDaemonId: id.daemonId, peerVersion: id.version, pushed };
|
|
123
|
+
} finally {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
}
|
|
50
126
|
}
|
|
51
127
|
|
|
52
128
|
export function registerInnerChatRoutes(app, daemon) {
|
|
129
|
+
/**
|
|
130
|
+
* This daemon's federation identity. Read-only and non-secret (a public key
|
|
131
|
+
* plus an id); the peer needs it to verify signed relays from us.
|
|
132
|
+
*/
|
|
133
|
+
app.get('/api/innerchat/identity', (req, res) => {
|
|
134
|
+
res.json({
|
|
135
|
+
daemonId: daemon.federation.getDaemonId(),
|
|
136
|
+
publicKey: daemon.federation.getPublicKeyPem(),
|
|
137
|
+
version: pkgVersion,
|
|
138
|
+
port: daemon.port,
|
|
139
|
+
agents: daemon.registry.getAll().map((a) => a.name),
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Accept a peer's public key so we can verify its signed relays. Storing a
|
|
145
|
+
* key authorizes nothing by itself — a relay ALSO requires that daemon to be
|
|
146
|
+
* listed in innerchatPeers, which only the user can do. That second gate is
|
|
147
|
+
* what keeps this endpoint safe to accept without a pairing dance.
|
|
148
|
+
*/
|
|
149
|
+
app.post('/api/innerchat/identity', (req, res) => {
|
|
150
|
+
const { daemonId, publicKey, name } = req.body || {};
|
|
151
|
+
if (!daemonId || !/^[a-f0-9]{6,64}$/.test(String(daemonId))) {
|
|
152
|
+
return res.status(400).json({ error: 'valid daemonId required' });
|
|
153
|
+
}
|
|
154
|
+
if (!publicKey || typeof publicKey !== 'string' || !publicKey.includes('PUBLIC KEY')) {
|
|
155
|
+
return res.status(400).json({ error: 'publicKey (PEM) required' });
|
|
156
|
+
}
|
|
157
|
+
daemon.federation._savePeer({
|
|
158
|
+
id: daemonId,
|
|
159
|
+
name: typeof name === 'string' && name.trim() ? name.trim().slice(0, 40) : daemonId,
|
|
160
|
+
host: (req.ip || '').replace('::ffff:', '') || '127.0.0.1',
|
|
161
|
+
port: 0,
|
|
162
|
+
publicKey,
|
|
163
|
+
pairedAt: new Date().toISOString(),
|
|
164
|
+
});
|
|
165
|
+
daemon.audit.log('innerchat.key.received', { daemonId });
|
|
166
|
+
res.json({
|
|
167
|
+
ok: true,
|
|
168
|
+
// Hand back ours so a single call completes the exchange either way.
|
|
169
|
+
daemonId: daemon.federation.getDaemonId(),
|
|
170
|
+
publicKey: daemon.federation.getPublicKeyPem(),
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
53
174
|
/**
|
|
54
175
|
* Ask another agent a question and BLOCK until it answers.
|
|
55
176
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* interface negotiation, where the finite exchange budget keeps both sides
|
|
59
|
-
* writing decision-dense messages.
|
|
177
|
+
* `to` may be a local name or `name@peer`. A peer target is relayed to the
|
|
178
|
+
* configured peer daemon; the reply rides the still-open response back.
|
|
60
179
|
*/
|
|
61
180
|
app.post('/api/innerchat/ask', async (req, res) => {
|
|
62
181
|
try {
|
|
63
|
-
const
|
|
64
|
-
|
|
182
|
+
const { from, to, message } = req.body || {};
|
|
183
|
+
const fromAgent = resolveFrom(daemon, from, res);
|
|
184
|
+
if (!fromAgent) return;
|
|
65
185
|
|
|
66
186
|
// Held open until the target answers — see the class doc in innerchat.js.
|
|
67
187
|
req.setTimeout(0);
|
|
68
188
|
res.setTimeout(0);
|
|
69
189
|
|
|
70
|
-
|
|
190
|
+
if (daemon.innerchat.isRemoteRef(to)) {
|
|
191
|
+
const { name, alias } = parsePeerRef(to);
|
|
192
|
+
if (!message || !String(message).trim()) return res.status(400).json({ error: 'message is required' });
|
|
193
|
+
const result = await daemon.innerchat.askRemote(fromAgent, name, alias, String(message).trim(), {
|
|
194
|
+
timeoutMs: req.body?.timeoutMs,
|
|
195
|
+
});
|
|
196
|
+
return res.json({ from: `${name}@${alias}`, reply: result.reply, threadId: result.threadId, remote: true });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const parties = resolveLocalTo(daemon, fromAgent, to, message, res);
|
|
200
|
+
if (!parties) return;
|
|
201
|
+
|
|
202
|
+
const result = await daemon.innerchat.ask(fromAgent.id, parties.toAgent.id, parties.message, {
|
|
71
203
|
timeoutMs: req.body?.timeoutMs,
|
|
72
204
|
});
|
|
73
205
|
|
|
@@ -86,17 +218,34 @@ export function registerInnerChatRoutes(app, daemon) {
|
|
|
86
218
|
});
|
|
87
219
|
|
|
88
220
|
/**
|
|
89
|
-
* Send a message WITHOUT blocking
|
|
90
|
-
* target
|
|
91
|
-
*
|
|
92
|
-
* where waiting out a timeout would waste the turn.
|
|
221
|
+
* Send a message WITHOUT blocking. `to` may be local or `name@peer`. For a
|
|
222
|
+
* peer target the reply (if any) is queued on the peer and picked up by this
|
|
223
|
+
* daemon's outbox poll, then routed back to the sender.
|
|
93
224
|
*/
|
|
94
225
|
app.post('/api/innerchat/tell', async (req, res) => {
|
|
95
226
|
try {
|
|
96
|
-
const
|
|
227
|
+
const { from, to, message } = req.body || {};
|
|
228
|
+
const fromAgent = resolveFrom(daemon, from, res);
|
|
229
|
+
if (!fromAgent) return;
|
|
230
|
+
|
|
231
|
+
if (daemon.innerchat.isRemoteRef(to)) {
|
|
232
|
+
const { name, alias } = parsePeerRef(to);
|
|
233
|
+
if (!message || !String(message).trim()) return res.status(400).json({ error: 'message is required' });
|
|
234
|
+
const result = await daemon.innerchat.tellRemote(fromAgent, name, alias, String(message).trim());
|
|
235
|
+
return res.json({
|
|
236
|
+
ok: true,
|
|
237
|
+
to: `${name}@${alias}`,
|
|
238
|
+
delivered: result.delivered,
|
|
239
|
+
threadId: result.threadId,
|
|
240
|
+
remote: true,
|
|
241
|
+
note: `Message relayed to ${name}@${alias}. Its reply, if any, will be routed back to you — you can end your turn.`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const parties = resolveLocalTo(daemon, fromAgent, to, message, res);
|
|
97
246
|
if (!parties) return;
|
|
98
247
|
|
|
99
|
-
const result = await daemon.innerchat.tell(
|
|
248
|
+
const result = await daemon.innerchat.tell(fromAgent.id, parties.toAgent.id, parties.message, {
|
|
100
249
|
threadId: req.body?.threadId,
|
|
101
250
|
});
|
|
102
251
|
|
|
@@ -115,6 +264,171 @@ export function registerInnerChatRoutes(app, daemon) {
|
|
|
115
264
|
}
|
|
116
265
|
});
|
|
117
266
|
|
|
267
|
+
// ── Cross-daemon relay endpoints (peer daemon → this daemon) ──
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Receive a relayed ask/tell from a peer daemon. Signature-verified and
|
|
271
|
+
* restricted to configured peers. Resolves the target locally and delivers
|
|
272
|
+
* with a guest identity. For 'ask' the response is held open until the local
|
|
273
|
+
* agent answers.
|
|
274
|
+
*/
|
|
275
|
+
app.post('/api/innerchat/relay', async (req, res) => {
|
|
276
|
+
const peer = verifyRelayCaller(daemon, req.body, res);
|
|
277
|
+
if (!peer) return;
|
|
278
|
+
const p = req.body.payload;
|
|
279
|
+
if (p.type !== 'innerchat.relay' || (p.kind !== 'ask' && p.kind !== 'tell')) {
|
|
280
|
+
return res.status(400).json({ error: 'invalid relay payload' });
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
req.setTimeout(0);
|
|
284
|
+
res.setTimeout(0);
|
|
285
|
+
const result = await daemon.innerchat.receiveRelay({
|
|
286
|
+
fromName: p.fromName,
|
|
287
|
+
fromRole: p.fromRole,
|
|
288
|
+
fromDaemonId: p.fromDaemonId,
|
|
289
|
+
peerAlias: peer.alias,
|
|
290
|
+
toName: p.toName,
|
|
291
|
+
message: p.message,
|
|
292
|
+
kind: p.kind,
|
|
293
|
+
threadId: p.threadId,
|
|
294
|
+
timeoutMs: p.timeoutMs,
|
|
295
|
+
});
|
|
296
|
+
res.json(result);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
const body = { error: err.message };
|
|
299
|
+
if (err.availableAgents) body.availableAgents = err.availableAgents;
|
|
300
|
+
res.status(404).json(body);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Drain this daemon's outbox of async (tell) replies queued for the calling
|
|
306
|
+
* peer. Signature-verified; returns and removes only that peer's entries.
|
|
307
|
+
*/
|
|
308
|
+
app.post('/api/innerchat/relay/outbox', (req, res) => {
|
|
309
|
+
const peer = verifyRelayCaller(daemon, req.body, res);
|
|
310
|
+
if (!peer) return;
|
|
311
|
+
if (req.body.payload.type !== 'innerchat.outbox') {
|
|
312
|
+
return res.status(400).json({ error: 'invalid outbox payload' });
|
|
313
|
+
}
|
|
314
|
+
res.json({ entries: daemon.innerchat.drainOutbox(req.body.payload.fromDaemonId) });
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ── Peer configuration (alias ↔ url ↔ daemonId) ──────────────
|
|
318
|
+
|
|
319
|
+
app.get('/api/innerchat/peers', (req, res) => {
|
|
320
|
+
res.json({ peers: daemon.innerchat._peers() });
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Add a peer. The daemonId is discovered from the peer itself rather than
|
|
325
|
+
* typed, and public keys are exchanged automatically, so the user only has
|
|
326
|
+
* to supply a name and a reachable URL.
|
|
327
|
+
*/
|
|
328
|
+
app.post('/api/innerchat/peers', async (req, res) => {
|
|
329
|
+
const alias = req.body?.alias;
|
|
330
|
+
const rawUrl = req.body?.url;
|
|
331
|
+
if (!alias || typeof alias !== 'string' || !/^[a-zA-Z0-9_-]{1,40}$/.test(alias)) {
|
|
332
|
+
return res.status(400).json({ error: 'peer name must be 1-40 chars (letters, digits, dash, underscore)' });
|
|
333
|
+
}
|
|
334
|
+
if (!rawUrl || typeof rawUrl !== 'string') return res.status(400).json({ error: 'peer url is required' });
|
|
335
|
+
|
|
336
|
+
let url;
|
|
337
|
+
try {
|
|
338
|
+
const parsed = new URL(rawUrl.trim());
|
|
339
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
340
|
+
return res.status(400).json({ error: 'peer url must be http(s)' });
|
|
341
|
+
}
|
|
342
|
+
if (parsed.username || parsed.password) return res.status(400).json({ error: 'credentials in the url are not allowed' });
|
|
343
|
+
url = rawUrl.trim().replace(/\/+$/, '');
|
|
344
|
+
} catch { return res.status(400).json({ error: `invalid url: ${rawUrl}` }); }
|
|
345
|
+
|
|
346
|
+
// Reach the peer, learn its id, and trade keys. This is the step that used
|
|
347
|
+
// to be a manual federation pairing.
|
|
348
|
+
let exchange;
|
|
349
|
+
try {
|
|
350
|
+
exchange = await exchangeKeys(daemon, url, alias);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
return res.status(502).json({
|
|
353
|
+
error: `Could not reach a Groove daemon at ${url} — ${err.message}. `
|
|
354
|
+
+ 'Check the URL (for a tunnelled peer this is the forwarded local port, not 31415) and that the peer daemon is running the current version.',
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (exchange.peerDaemonId === daemon.federation.getDaemonId()) {
|
|
359
|
+
return res.status(400).json({
|
|
360
|
+
error: `That URL points at THIS daemon, not a peer. For a tunnelled machine use the forwarded local port (e.g. http://127.0.0.1:31416), not ${url}.`,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const entry = { alias, url, daemonId: exchange.peerDaemonId };
|
|
365
|
+
const check = validatePeer(entry);
|
|
366
|
+
if (check) return res.status(400).json({ error: check });
|
|
367
|
+
|
|
368
|
+
const peers = Array.isArray(daemon.config.innerchatPeers) ? daemon.config.innerchatPeers : [];
|
|
369
|
+
const next = peers.filter((p) => p.alias !== alias);
|
|
370
|
+
next.push(entry);
|
|
371
|
+
daemon.config.innerchatPeers = next;
|
|
372
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
373
|
+
daemon.audit.log('innerchat.peer.set', { alias, url, daemonId: entry.daemonId });
|
|
374
|
+
// Registry files carry the @peer instructions + alias list — refresh now so
|
|
375
|
+
// running agents see the new peer without waiting for a spawn or restart.
|
|
376
|
+
try { daemon.introducer?.writeRegistryFile?.(daemon.projectDir); } catch { /* best effort */ }
|
|
377
|
+
|
|
378
|
+
res.json({
|
|
379
|
+
peers: next,
|
|
380
|
+
exchanged: true,
|
|
381
|
+
keyPushed: exchange.pushed,
|
|
382
|
+
peerDaemonId: exchange.peerDaemonId,
|
|
383
|
+
note: exchange.pushed
|
|
384
|
+
? `Keys exchanged with ${alias}. Add this machine as a peer there too if you want its agents to start conversations.`
|
|
385
|
+
: `Stored ${alias}'s key, but could not send ours — ${alias} may be running an older version. Update it, then re-add.`,
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Verify a configured peer end to end: reachable, identity matches what we
|
|
391
|
+
* stored, and keys are present on both sides.
|
|
392
|
+
*/
|
|
393
|
+
app.get('/api/innerchat/peers/:alias/test', async (req, res) => {
|
|
394
|
+
const peer = daemon.innerchat._peerByAlias(req.params.alias);
|
|
395
|
+
if (!peer) return res.status(404).json({ error: `No peer named "${req.params.alias}"` });
|
|
396
|
+
const controller = new AbortController();
|
|
397
|
+
const timer = setTimeout(() => controller.abort(), 8000);
|
|
398
|
+
try {
|
|
399
|
+
const r = await fetch(`${peer.url}/api/innerchat/identity`, { signal: controller.signal });
|
|
400
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
401
|
+
const id = await r.json();
|
|
402
|
+
const idMatches = id.daemonId === peer.daemonId;
|
|
403
|
+
const weHaveTheirKey = daemon.federation.peers.has(peer.daemonId);
|
|
404
|
+
res.json({
|
|
405
|
+
ok: idMatches && weHaveTheirKey,
|
|
406
|
+
reachable: true,
|
|
407
|
+
idMatches,
|
|
408
|
+
weHaveTheirKey,
|
|
409
|
+
peerDaemonId: id.daemonId,
|
|
410
|
+
peerVersion: id.version,
|
|
411
|
+
agents: Array.isArray(id.agents) ? id.agents : [],
|
|
412
|
+
error: !idMatches
|
|
413
|
+
? `The daemon at ${peer.url} reports id ${id.daemonId}, but this peer is configured as ${peer.daemonId}. Re-add the peer.`
|
|
414
|
+
: !weHaveTheirKey ? 'Missing this peer\'s public key — re-add the peer to exchange keys.' : null,
|
|
415
|
+
});
|
|
416
|
+
} catch (err) {
|
|
417
|
+
res.json({ ok: false, reachable: false, error: `Could not reach ${peer.url} — ${err.message}` });
|
|
418
|
+
} finally {
|
|
419
|
+
clearTimeout(timer);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
app.delete('/api/innerchat/peers/:alias', (req, res) => {
|
|
424
|
+
const peers = Array.isArray(daemon.config.innerchatPeers) ? daemon.config.innerchatPeers : [];
|
|
425
|
+
const next = peers.filter((p) => p.alias !== req.params.alias);
|
|
426
|
+
daemon.config.innerchatPeers = next;
|
|
427
|
+
saveConfig(daemon.grooveDir, daemon.config);
|
|
428
|
+
daemon.audit.log('innerchat.peer.remove', { alias: req.params.alias });
|
|
429
|
+
res.json({ peers: next });
|
|
430
|
+
});
|
|
431
|
+
|
|
118
432
|
app.get('/api/innerchat/threads', (req, res) => {
|
|
119
433
|
const { agentId } = req.query;
|
|
120
434
|
res.json({ threads: daemon.innerchat.getThreads(agentId || null) });
|