groove-dev 0.27.198 → 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_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/axom-connector.js +30 -2
- package/node_modules/@groove-dev/daemon/src/axom-install.js +24 -2
- package/node_modules/@groove-dev/daemon/src/axom-remote.js +230 -0
- package/node_modules/@groove-dev/daemon/src/axom-server.js +22 -2
- package/node_modules/@groove-dev/daemon/src/index.js +3 -0
- package/node_modules/@groove-dev/daemon/src/routes/axom.js +73 -2
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +190 -8
- package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +44 -1
- package/node_modules/@groove-dev/daemon/test/axom-remote.test.js +115 -0
- package/node_modules/@groove-dev/daemon/test/axom-server.test.js +50 -9
- package/node_modules/@groove-dev/gui/dist/assets/{index-b9dKN6cq.js → index-BbA-X4CE.js} +243 -233
- 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/components/agents/agent-feed.jsx +3 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +4 -1
- package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +83 -1
- package/node_modules/@groove-dev/gui/src/views/axom.jsx +1147 -310
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +123 -70
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/axom-connector.js +30 -2
- package/packages/daemon/src/axom-install.js +24 -2
- package/packages/daemon/src/axom-remote.js +230 -0
- package/packages/daemon/src/axom-server.js +22 -2
- package/packages/daemon/src/index.js +3 -0
- package/packages/daemon/src/routes/axom.js +73 -2
- package/packages/daemon/src/routes/innerchat.js +190 -8
- package/packages/gui/dist/assets/{index-b9dKN6cq.js → index-BbA-X4CE.js} +243 -233
- 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/components/agents/agent-feed.jsx +3 -1
- package/packages/gui/src/stores/groove.js +4 -1
- package/packages/gui/src/stores/slices/axom-slice.js +83 -1
- package/packages/gui/src/views/axom.jsx +1147 -310
- package/packages/gui/src/views/settings.jsx +123 -70
- package/node_modules/@groove-dev/gui/dist/assets/index-RbtaI6l7.css +0 -1
- package/packages/gui/dist/assets/index-RbtaI6l7.css +0 -1
|
@@ -1,9 +1,12 @@
|
|
|
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';
|
|
4
5
|
import { validatePeer, parsePeerRef } from '../innerchat-relay.js';
|
|
5
6
|
import { saveConfig } from '../firstrun.js';
|
|
6
7
|
|
|
8
|
+
const pkgVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
9
|
+
|
|
7
10
|
// Agents know each other by name, not id. Exact matches win first so
|
|
8
11
|
// `fullstack-1` never resolves to `fullstack-14`; only if nothing matches
|
|
9
12
|
// exactly do we accept a single unambiguous partial, since agents routinely
|
|
@@ -73,7 +76,101 @@ function verifyRelayCaller(daemon, body, res) {
|
|
|
73
76
|
return peer;
|
|
74
77
|
}
|
|
75
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
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
76
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
|
+
|
|
77
174
|
/**
|
|
78
175
|
* Ask another agent a question and BLOCK until it answers.
|
|
79
176
|
*
|
|
@@ -223,19 +320,104 @@ export function registerInnerChatRoutes(app, daemon) {
|
|
|
223
320
|
res.json({ peers: daemon.innerchat._peers() });
|
|
224
321
|
});
|
|
225
322
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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 });
|
|
231
367
|
|
|
232
368
|
const peers = Array.isArray(daemon.config.innerchatPeers) ? daemon.config.innerchatPeers : [];
|
|
233
|
-
const next = peers.filter((p) => p.alias !==
|
|
369
|
+
const next = peers.filter((p) => p.alias !== alias);
|
|
234
370
|
next.push(entry);
|
|
235
371
|
daemon.config.innerchatPeers = next;
|
|
236
372
|
saveConfig(daemon.grooveDir, daemon.config);
|
|
237
|
-
daemon.audit.log('innerchat.peer.set', { alias
|
|
238
|
-
|
|
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
|
+
}
|
|
239
421
|
});
|
|
240
422
|
|
|
241
423
|
app.delete('/api/innerchat/peers/:alias', (req, res) => {
|
|
@@ -31,6 +31,8 @@ class MockBridge {
|
|
|
31
31
|
this.sessions = sessions || { 's-test0001': { started: 1753000000, live: true, events: [] } };
|
|
32
32
|
this.interrupts = [];
|
|
33
33
|
this.stops = [];
|
|
34
|
+
this.shutdowns = [];
|
|
35
|
+
this.messages = [];
|
|
34
36
|
this.sinceSeen = []; // ?since values observed on WS connects
|
|
35
37
|
this.sockets = new Set();
|
|
36
38
|
}
|
|
@@ -95,6 +97,18 @@ class MockBridge {
|
|
|
95
97
|
const interrupt = url.pathname.match(/^\/session\/([^/]+)\/interrupt$/);
|
|
96
98
|
const stop = url.pathname.match(/^\/session\/([^/]+)\/stop$/);
|
|
97
99
|
const message = url.pathname.match(/^\/session\/([^/]+)\/message$/);
|
|
100
|
+
if (req.method === 'POST' && url.pathname === '/shutdown') {
|
|
101
|
+
let body = '';
|
|
102
|
+
req.on('data', (c) => { body += c; });
|
|
103
|
+
req.on('end', () => {
|
|
104
|
+
const { force } = JSON.parse(body || '{}');
|
|
105
|
+
const busy = Object.values(this.sessions).some((s) => s.live);
|
|
106
|
+
if (busy && !force) return json(409, { error: 'turn in flight' });
|
|
107
|
+
this.shutdowns.push({ force: !!force });
|
|
108
|
+
return json(202, { stopping: true });
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
98
112
|
if (req.method === 'POST' && (interrupt || stop || message)) {
|
|
99
113
|
let body = '';
|
|
100
114
|
req.on('data', (c) => { body += c; });
|
|
@@ -108,7 +122,11 @@ class MockBridge {
|
|
|
108
122
|
// §12 semantics: caller-chosen id, first message creates; one turn
|
|
109
123
|
// at a time; hard max with 413, never truncation.
|
|
110
124
|
const id = decodeURIComponent(message[1]);
|
|
111
|
-
const
|
|
125
|
+
const parsed = JSON.parse(body || '{}');
|
|
126
|
+
const { text } = parsed;
|
|
127
|
+
// Store the parsed body verbatim so tests can assert on key
|
|
128
|
+
// PRESENCE (a destructured undefined would fabricate the key).
|
|
129
|
+
this.messages.push({ session: id, body: parsed });
|
|
112
130
|
if (text.length > 32768) return json(413, { error: 'too_long', max: 32768 });
|
|
113
131
|
let session = this.sessions[id];
|
|
114
132
|
if (!session) { session = this.sessions[id] = { started: Date.now() / 1000, live: false, events: [] }; }
|
|
@@ -380,6 +398,31 @@ describe('AxomConnector', () => {
|
|
|
380
398
|
assert.equal(s.watching, true);
|
|
381
399
|
});
|
|
382
400
|
|
|
401
|
+
it('sends client_ref only when given, and omits it otherwise (§15)', async () => {
|
|
402
|
+
connect();
|
|
403
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
404
|
+
await connector.message('local', 's-ref', 'with a ref', 'g-abc123');
|
|
405
|
+
await connector.message('local', 's-noref', 'without one');
|
|
406
|
+
assert.equal(bridge.messages[0].body.client_ref, 'g-abc123');
|
|
407
|
+
// Pre-§15 runtimes must see exactly today's payload — no null key.
|
|
408
|
+
assert.equal('client_ref' in bridge.messages[1].body, false);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('shuts the runtime down, refusing mid-turn unless forced (§14)', async () => {
|
|
412
|
+
connect();
|
|
413
|
+
await waitFor(() => connector.status().endpoints[0]?.status === 'connected');
|
|
414
|
+
// s-test0001 starts live: a turn is in flight.
|
|
415
|
+
const refused = await connector.shutdown('local');
|
|
416
|
+
assert.equal(refused.status, 409);
|
|
417
|
+
assert.equal(refused.body.error, 'turn in flight');
|
|
418
|
+
assert.equal(bridge.shutdowns.length, 0);
|
|
419
|
+
|
|
420
|
+
const forced = await connector.shutdown('local', { force: true });
|
|
421
|
+
assert.equal(forced.status, 202);
|
|
422
|
+
assert.deepEqual(forced.body, { stopping: true });
|
|
423
|
+
assert.deepEqual(bridge.shutdowns, [{ force: true }]);
|
|
424
|
+
});
|
|
425
|
+
|
|
383
426
|
it('reports an unreachable endpoint honestly and recovers by retry', async () => {
|
|
384
427
|
const deadUrl = bridge.url;
|
|
385
428
|
const port = Number(new URL(deadUrl).port);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// GROOVE — Axom Remote Control Tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// SSH is injected, so these assert the COMMANDS we would run and the honesty
|
|
5
|
+
// of what we report — never that ssh itself works.
|
|
6
|
+
|
|
7
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { AxomRemote, validateRemote } from '../src/axom-remote.js';
|
|
10
|
+
|
|
11
|
+
function harness({ responses = {}, remote = { host: 'spark.local', user: 'axom' } } = {}) {
|
|
12
|
+
const calls = [];
|
|
13
|
+
const daemon = {
|
|
14
|
+
config: { axom: { remote } },
|
|
15
|
+
broadcast() {},
|
|
16
|
+
audit: { log() {} },
|
|
17
|
+
};
|
|
18
|
+
const exec = (bin, args, opts, cb) => {
|
|
19
|
+
const command = args[args.length - 1];
|
|
20
|
+
calls.push({ bin, target: args[args.length - 2], command });
|
|
21
|
+
const match = Object.keys(responses).find((k) => command.includes(k));
|
|
22
|
+
const out = match ? responses[match] : '';
|
|
23
|
+
if (out instanceof Error) return cb(out, '', out.message);
|
|
24
|
+
cb(null, out, '');
|
|
25
|
+
};
|
|
26
|
+
return { remote: new AxomRemote(daemon, { exec }), calls, daemon };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('AxomRemote', () => {
|
|
30
|
+
it('reports running/not-running from a probe on the remote host', async () => {
|
|
31
|
+
const up = harness({ responses: { '/about': 'UP\n' } });
|
|
32
|
+
assert.deepEqual(await up.remote.status(), {
|
|
33
|
+
configured: true, host: 'spark.local', user: 'axom', port: 8737, running: true, error: null,
|
|
34
|
+
});
|
|
35
|
+
const down = harness({ responses: { '/about': 'DOWN\n' } });
|
|
36
|
+
assert.equal((await down.remote.status()).running, false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('reports UNKNOWN (not "stopped") when the host is unreachable', async () => {
|
|
40
|
+
const h = harness({ responses: { '/about': new Error('ssh: connect to host spark.local port 22: No route to host') } });
|
|
41
|
+
const s = await h.remote.status();
|
|
42
|
+
// A host we cannot reach tells us nothing about the runtime. Saying
|
|
43
|
+
// "stopped" would be a guess presented as fact.
|
|
44
|
+
assert.equal(s.running, null);
|
|
45
|
+
assert.match(s.error, /No route to host/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('starts detached with no supervisor and confirms it actually came up', async () => {
|
|
49
|
+
let probes = 0;
|
|
50
|
+
const h = harness({
|
|
51
|
+
responses: {
|
|
52
|
+
get '/about'() { probes += 1; return probes === 1 ? 'DOWN\n' : 'UP\n'; },
|
|
53
|
+
'nohup': 'STARTED\n',
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
const result = await h.remote.start();
|
|
57
|
+
assert.equal(result.started, true);
|
|
58
|
+
const startCmd = h.calls.find((c) => c.command.includes('nohup')).command;
|
|
59
|
+
assert.match(startCmd, /axom serve --port 8737/);
|
|
60
|
+
assert.match(startCmd, /disown/); // survives the ssh session
|
|
61
|
+
assert.equal(/while|until|restart|systemctl/.test(startCmd), false); // never supervised
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('does not start a second runtime when one is already up', async () => {
|
|
65
|
+
const h = harness({ responses: { '/about': 'UP\n' } });
|
|
66
|
+
assert.deepEqual(await h.remote.start(), { started: false, alreadyRunning: true, port: 8737 });
|
|
67
|
+
assert.equal(h.calls.some((c) => c.command.includes('nohup')), false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('stops via the §14 shutdown verb when the runtime supports it', async () => {
|
|
71
|
+
const h = harness({ responses: { '/shutdown': '202', '/about': 'DOWN\n' } });
|
|
72
|
+
const result = await h.remote.stop();
|
|
73
|
+
assert.deepEqual(result, { stopped: true, via: 'shutdown' });
|
|
74
|
+
assert.equal(h.calls.some((c) => c.command.includes('kill')), false); // no signal needed
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('refuses to stop mid-turn unless forced, and passes force through', async () => {
|
|
78
|
+
const busy = harness({ responses: { '/shutdown': '409', '/about': 'UP\n' } });
|
|
79
|
+
assert.deepEqual(await busy.remote.stop(), { stopped: false, turnInFlight: true });
|
|
80
|
+
|
|
81
|
+
const forced = harness({ responses: { '/shutdown': '202', '/about': 'UP\n' } });
|
|
82
|
+
await forced.remote.stop({ force: true });
|
|
83
|
+
assert.match(forced.calls.find((c) => c.command.includes('/shutdown')).command, /"force":true/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('falls back to a signal only for runtimes predating §14', async () => {
|
|
87
|
+
const h = harness({ responses: { '/shutdown': '404', '/about': 'UP\n' } });
|
|
88
|
+
const result = await h.remote.stop();
|
|
89
|
+
assert.deepEqual(result, { stopped: true, via: 'signal' });
|
|
90
|
+
assert.match(h.calls.find((c) => c.command.includes('kill')).command, /lsof -t -i:8737/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('reports unconfigured rather than pretending', async () => {
|
|
94
|
+
const h = harness({ remote: null });
|
|
95
|
+
assert.deepEqual(await h.remote.status(), { configured: false, running: null });
|
|
96
|
+
await assert.rejects(() => h.remote.start(), /no remote Axom host configured/);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('validateRemote', () => {
|
|
101
|
+
it('accepts a clean config', () => {
|
|
102
|
+
assert.equal(validateRemote({ host: 'spark.local', user: 'axom', sshPort: 22, port: 8737 }), null);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects shell metacharacters and malformed values', () => {
|
|
106
|
+
// host/user reach execFile as single argv entries, but a permissive
|
|
107
|
+
// validator would still invite injection attempts through config.
|
|
108
|
+
assert.ok(validateRemote({ host: 'spark.local; rm -rf /', user: 'axom' }));
|
|
109
|
+
assert.ok(validateRemote({ host: 'spark.local', user: 'axom$(id)' }));
|
|
110
|
+
assert.ok(validateRemote({ host: 'a'.repeat(300), user: 'axom' }));
|
|
111
|
+
assert.ok(validateRemote({ host: 'spark.local', user: 'axom', sshPort: 99999 }));
|
|
112
|
+
assert.ok(validateRemote({ host: 'spark.local', user: 'axom', command: 'x'.repeat(600) }));
|
|
113
|
+
assert.ok(validateRemote(null));
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -58,7 +58,7 @@ describe('AxomServerManager', () => {
|
|
|
58
58
|
daemon = fakeDaemon(dir);
|
|
59
59
|
// Lifecycle tests inject ample RAM — the floor guard has its own test
|
|
60
60
|
// (and CI boxes/the 8GB dev Mac must not trip it on unrelated tests).
|
|
61
|
-
manager = new AxomServerManager(daemon, { command: stubPath, totalRamGb: 32 });
|
|
61
|
+
manager = new AxomServerManager(daemon, { command: stubPath, totalRamGb: 32, portBase: 18737 });
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
afterEach(async () => {
|
|
@@ -68,7 +68,7 @@ describe('AxomServerManager', () => {
|
|
|
68
68
|
it('starts an instance with its own port and sovereign data-dir, and registers the endpoint', async () => {
|
|
69
69
|
const instance = await manager.start('proj-a');
|
|
70
70
|
assert.equal(instance.status, 'running');
|
|
71
|
-
assert.equal(instance.port,
|
|
71
|
+
assert.equal(instance.port, 18737);
|
|
72
72
|
assert.ok(instance.dataDir.endsWith(join('axom', 'instances', 'proj-a')));
|
|
73
73
|
assert.ok(existsSync(instance.dataDir));
|
|
74
74
|
|
|
@@ -76,15 +76,15 @@ describe('AxomServerManager', () => {
|
|
|
76
76
|
assert.equal(about.instance.data_dir, instance.dataDir);
|
|
77
77
|
|
|
78
78
|
const ep = daemon.config.axom.endpoints.find((e) => e.name === 'local-proj-a');
|
|
79
|
-
assert.equal(ep.url, 'http://127.0.0.1:
|
|
79
|
+
assert.equal(ep.url, 'http://127.0.0.1:18737');
|
|
80
80
|
assert.deepEqual(daemon.axom.lastConfigured, daemon.config.axom.endpoints);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
it('allocates distinct ports for concurrent instances', async () => {
|
|
84
84
|
const a = await manager.start('a');
|
|
85
85
|
const b = await manager.start('b');
|
|
86
|
-
assert.equal(a.port,
|
|
87
|
-
assert.equal(b.port,
|
|
86
|
+
assert.equal(a.port, 18737);
|
|
87
|
+
assert.equal(b.port, 18738);
|
|
88
88
|
assert.notEqual(a.dataDir, b.dataDir); // ledgers never shared
|
|
89
89
|
});
|
|
90
90
|
|
|
@@ -97,7 +97,7 @@ describe('AxomServerManager', () => {
|
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
it('refuses non-mock start below the RAM floor — the host stays up', async () => {
|
|
100
|
-
manager = new AxomServerManager(daemon, { command: stubPath, totalRamGb: 8 });
|
|
100
|
+
manager = new AxomServerManager(daemon, { command: stubPath, totalRamGb: 8, portBase: 18737 });
|
|
101
101
|
await assert.rejects(() => manager.start('x'), /below Axom's 12GB floor/);
|
|
102
102
|
// Mock mode is weightless and exempt.
|
|
103
103
|
daemon.config.axom.mock = true;
|
|
@@ -105,8 +105,29 @@ describe('AxomServerManager', () => {
|
|
|
105
105
|
assert.equal(instance.status, 'running');
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
it('refuses to adopt a foreign runtime already holding the port', async () => {
|
|
109
|
+
// A tunnel to a remote Axom (or any other runtime) can already own the
|
|
110
|
+
// port. Claiming it would let GROOVE offer to shut down a process it
|
|
111
|
+
// never launched — found live when an SSH tunnel occupied 8737.
|
|
112
|
+
const foreign = createServer((req, res) => {
|
|
113
|
+
res.setHeader('Content-Type', 'application/json');
|
|
114
|
+
res.end(JSON.stringify({
|
|
115
|
+
name: 'axom', version: 'someone-elses',
|
|
116
|
+
instance: { port: 18737, data_dir: '/somewhere/else', pid: 999999 },
|
|
117
|
+
}));
|
|
118
|
+
});
|
|
119
|
+
await new Promise((r) => foreign.listen(18737, '127.0.0.1', r));
|
|
120
|
+
try {
|
|
121
|
+
await assert.rejects(() => manager.start('hijack'), /already served by another Axom runtime/);
|
|
122
|
+
assert.equal(manager.list().find((i) => i.id === 'hijack').status, 'error');
|
|
123
|
+
} finally {
|
|
124
|
+
foreign.closeAllConnections?.();
|
|
125
|
+
await new Promise((r) => foreign.close(r));
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
108
129
|
it('reports a missing runtime binary honestly', async () => {
|
|
109
|
-
manager = new AxomServerManager(daemon, { command: '/nonexistent/axom', totalRamGb: 32 });
|
|
130
|
+
manager = new AxomServerManager(daemon, { command: '/nonexistent/axom', totalRamGb: 32, portBase: 18737 });
|
|
110
131
|
await assert.rejects(() => manager.start('x'), /not found — install the Axom runtime/);
|
|
111
132
|
assert.equal(manager.list().find((i) => i.id === 'x').status, 'error');
|
|
112
133
|
});
|
|
@@ -176,8 +197,28 @@ describe('AxomInstaller', () => {
|
|
|
176
197
|
}
|
|
177
198
|
});
|
|
178
199
|
|
|
179
|
-
it('
|
|
180
|
-
|
|
200
|
+
it('reports unavailable (not an error) when no distribution is configured', async () => {
|
|
201
|
+
// A public GROOVE build ships with no manifest: the UI must render
|
|
202
|
+
// "Coming soon" rather than offering an action that fails on click.
|
|
203
|
+
const status = installer.getStatus();
|
|
204
|
+
assert.equal(status.available, false);
|
|
205
|
+
assert.equal(status.unavailableReason, 'Coming soon');
|
|
206
|
+
assert.equal(status.manifestUrl, null);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('gates install server-side too — a hand-crafted POST cannot bypass it', async () => {
|
|
210
|
+
await assert.rejects(() => installer.install(undefined), /Coming soon/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('explains a gated private distribution instead of leaking HTTP codes', async () => {
|
|
214
|
+
const gated = createServer((req, res) => { res.statusCode = 403; res.end('{}'); });
|
|
215
|
+
await new Promise((r) => gated.listen(0, '127.0.0.1', r));
|
|
216
|
+
const url = `http://127.0.0.1:${gated.address().port}/manifest.json`;
|
|
217
|
+
try {
|
|
218
|
+
await assert.rejects(() => installer.install(url), /private — your account does not have access/);
|
|
219
|
+
} finally {
|
|
220
|
+
await new Promise((r) => gated.close(r));
|
|
221
|
+
}
|
|
181
222
|
});
|
|
182
223
|
|
|
183
224
|
it('refuses to download weights below the RAM floor', async () => {
|