groove-dev 0.27.197 → 0.27.198
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/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 +340 -0
- package/node_modules/@groove-dev/daemon/src/axom-install.js +140 -0
- package/node_modules/@groove-dev/daemon/src/axom-server.js +229 -0
- package/node_modules/@groove-dev/daemon/src/federation.js +6 -0
- package/node_modules/@groove-dev/daemon/src/index.js +11 -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 +196 -0
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +152 -20
- package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +412 -0
- package/node_modules/@groove-dev/daemon/test/axom-server.test.js +187 -0
- package/node_modules/@groove-dev/daemon/test/innerchat-relay.test.js +251 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-RbtaI6l7.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/{index-Da2nbd6M.js → index-b9dKN6cq.js} +259 -228
- 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/layout/activity-bar.jsx +2 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +19 -0
- package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +197 -0
- package/node_modules/@groove-dev/gui/src/views/axom.jsx +1060 -0
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +178 -3
- 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 +340 -0
- package/packages/daemon/src/axom-install.js +140 -0
- package/packages/daemon/src/axom-server.js +229 -0
- package/packages/daemon/src/federation.js +6 -0
- package/packages/daemon/src/index.js +11 -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 +196 -0
- package/packages/daemon/src/routes/innerchat.js +152 -20
- package/packages/gui/dist/assets/index-RbtaI6l7.css +1 -0
- package/packages/gui/dist/assets/{index-Da2nbd6M.js → index-b9dKN6cq.js} +259 -228
- 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/layout/activity-bar.jsx +2 -1
- package/packages/gui/src/stores/groove.js +19 -0
- package/packages/gui/src/stores/slices/axom-slice.js +197 -0
- package/packages/gui/src/views/axom.jsx +1060 -0
- package/packages/gui/src/views/settings.jsx +178 -3
- package/node_modules/@groove-dev/gui/dist/assets/index-zmrIbwNm.css +0 -1
- package/packages/gui/dist/assets/index-zmrIbwNm.css +0 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// GROOVE — Axom Server Manager + Installer Tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// The server manager runs against a stub `axom` executable that honors the
|
|
5
|
+
// §11 CLI (serve --host --port --data-dir --model-dir) and serves /about.
|
|
6
|
+
// The installer runs against a local HTTP server hosting a §11 manifest.
|
|
7
|
+
|
|
8
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { createServer } from 'http';
|
|
11
|
+
import { createHash } from 'crypto';
|
|
12
|
+
import { mkdtempSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import { tmpdir } from 'os';
|
|
15
|
+
import { AxomServerManager } from '../src/axom-server.js';
|
|
16
|
+
import { AxomInstaller } from '../src/axom-install.js';
|
|
17
|
+
|
|
18
|
+
const STUB = `#!/usr/bin/env node
|
|
19
|
+
// Stub axom serve — §11 CLI, /about + /sessions, clean SIGTERM.
|
|
20
|
+
const http = require('http');
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const get = (flag) => args[args.indexOf(flag) + 1];
|
|
23
|
+
if (args[0] !== 'serve') process.exit(2);
|
|
24
|
+
const server = http.createServer((req, res) => {
|
|
25
|
+
res.setHeader('Content-Type', 'application/json');
|
|
26
|
+
if (req.url === '/about') return res.end(JSON.stringify({ name: 'axom', version: 'stub', kinds: [], instance: { port: Number(get('--port')), data_dir: get('--data-dir'), pid: process.pid } }));
|
|
27
|
+
if (req.url === '/sessions') return res.end('[]');
|
|
28
|
+
res.statusCode = 404; res.end('{}');
|
|
29
|
+
});
|
|
30
|
+
server.listen(Number(get('--port')), get('--host'));
|
|
31
|
+
process.on('SIGTERM', () => { server.close(); process.exit(0); });
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
function fakeDaemon(grooveDir) {
|
|
35
|
+
const broadcasts = [];
|
|
36
|
+
return {
|
|
37
|
+
grooveDir,
|
|
38
|
+
broadcasts,
|
|
39
|
+
config: { axom: {} },
|
|
40
|
+
broadcast: (m) => broadcasts.push(m),
|
|
41
|
+
audit: { log() {} },
|
|
42
|
+
axom: {
|
|
43
|
+
lastConfigured: null,
|
|
44
|
+
endpoints: new Map(),
|
|
45
|
+
configure(entries) { this.lastConfigured = entries; },
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('AxomServerManager', () => {
|
|
51
|
+
let daemon, manager, stubPath;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
const dir = mkdtempSync(join(tmpdir(), 'groove-axom-'));
|
|
55
|
+
stubPath = join(dir, 'axom-stub');
|
|
56
|
+
writeFileSync(stubPath, STUB);
|
|
57
|
+
chmodSync(stubPath, 0o755);
|
|
58
|
+
daemon = fakeDaemon(dir);
|
|
59
|
+
// Lifecycle tests inject ample RAM — the floor guard has its own test
|
|
60
|
+
// (and CI boxes/the 8GB dev Mac must not trip it on unrelated tests).
|
|
61
|
+
manager = new AxomServerManager(daemon, { command: stubPath, totalRamGb: 32 });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(async () => {
|
|
65
|
+
await manager.destroy();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('starts an instance with its own port and sovereign data-dir, and registers the endpoint', async () => {
|
|
69
|
+
const instance = await manager.start('proj-a');
|
|
70
|
+
assert.equal(instance.status, 'running');
|
|
71
|
+
assert.equal(instance.port, 8737);
|
|
72
|
+
assert.ok(instance.dataDir.endsWith(join('axom', 'instances', 'proj-a')));
|
|
73
|
+
assert.ok(existsSync(instance.dataDir));
|
|
74
|
+
|
|
75
|
+
const about = await (await fetch(`http://127.0.0.1:${instance.port}/about`)).json();
|
|
76
|
+
assert.equal(about.instance.data_dir, instance.dataDir);
|
|
77
|
+
|
|
78
|
+
const ep = daemon.config.axom.endpoints.find((e) => e.name === 'local-proj-a');
|
|
79
|
+
assert.equal(ep.url, 'http://127.0.0.1:8737');
|
|
80
|
+
assert.deepEqual(daemon.axom.lastConfigured, daemon.config.axom.endpoints);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('allocates distinct ports for concurrent instances', async () => {
|
|
84
|
+
const a = await manager.start('a');
|
|
85
|
+
const b = await manager.start('b');
|
|
86
|
+
assert.equal(a.port, 8737);
|
|
87
|
+
assert.equal(b.port, 8738);
|
|
88
|
+
assert.notEqual(a.dataDir, b.dataDir); // ledgers never shared
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('stops an instance and deregisters its endpoint', async () => {
|
|
92
|
+
await manager.start('gone');
|
|
93
|
+
await manager.stop('gone');
|
|
94
|
+
const instance = manager.list().find((i) => i.id === 'gone');
|
|
95
|
+
assert.equal(instance.status, 'stopped');
|
|
96
|
+
assert.ok(!daemon.config.axom.endpoints.some((e) => e.name === 'local-gone'));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('refuses non-mock start below the RAM floor — the host stays up', async () => {
|
|
100
|
+
manager = new AxomServerManager(daemon, { command: stubPath, totalRamGb: 8 });
|
|
101
|
+
await assert.rejects(() => manager.start('x'), /below Axom's 12GB floor/);
|
|
102
|
+
// Mock mode is weightless and exempt.
|
|
103
|
+
daemon.config.axom.mock = true;
|
|
104
|
+
const instance = await manager.start('x');
|
|
105
|
+
assert.equal(instance.status, 'running');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('reports a missing runtime binary honestly', async () => {
|
|
109
|
+
manager = new AxomServerManager(daemon, { command: '/nonexistent/axom', totalRamGb: 32 });
|
|
110
|
+
await assert.rejects(() => manager.start('x'), /not found — install the Axom runtime/);
|
|
111
|
+
assert.equal(manager.list().find((i) => i.id === 'x').status, 'error');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('AxomInstaller', () => {
|
|
116
|
+
let daemon, installer, server, baseUrl, modelBytes, modelSha;
|
|
117
|
+
|
|
118
|
+
beforeEach(async () => {
|
|
119
|
+
const dir = mkdtempSync(join(tmpdir(), 'groove-axom-inst-'));
|
|
120
|
+
daemon = fakeDaemon(dir);
|
|
121
|
+
installer = new AxomInstaller(daemon, { totalRamGb: 32 });
|
|
122
|
+
modelBytes = Buffer.from('fake-gguf-weights-'.repeat(1000));
|
|
123
|
+
modelSha = createHash('sha256').update(modelBytes).digest('hex');
|
|
124
|
+
server = createServer((req, res) => {
|
|
125
|
+
if (req.url === '/manifest.json') {
|
|
126
|
+
res.setHeader('Content-Type', 'application/json');
|
|
127
|
+
return res.end(JSON.stringify({
|
|
128
|
+
models: [{ file: 'chassis.gguf', url: `${baseUrl}/chassis.gguf`, sha256: modelSha, bytes: modelBytes.length }],
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
if (req.url === '/bad-manifest.json') {
|
|
132
|
+
res.setHeader('Content-Type', 'application/json');
|
|
133
|
+
return res.end(JSON.stringify({
|
|
134
|
+
models: [{ file: 'chassis.gguf', url: `${baseUrl}/chassis.gguf`, sha256: 'deadbeef'.repeat(8), bytes: modelBytes.length }],
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
if (req.url === '/chassis.gguf') return res.end(modelBytes);
|
|
138
|
+
res.statusCode = 404; res.end();
|
|
139
|
+
});
|
|
140
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
141
|
+
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
afterEach(async () => {
|
|
145
|
+
await new Promise((r) => server.close(r));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('downloads, verifies sha256, and lands the model file', async () => {
|
|
149
|
+
const result = await installer.install(`${baseUrl}/manifest.json`);
|
|
150
|
+
assert.deepEqual(result, { ok: true, models: 1 });
|
|
151
|
+
const landed = join(daemon.grooveDir, 'axom', 'models', 'chassis.gguf');
|
|
152
|
+
assert.ok(existsSync(landed));
|
|
153
|
+
assert.deepEqual(readFileSync(landed), modelBytes);
|
|
154
|
+
assert.equal(installer.getStatus().phase, 'done');
|
|
155
|
+
assert.ok(daemon.broadcasts.some((b) => b.type === 'axom:install:progress'));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('refuses a sha256 mismatch — nothing lands, no partial left behind', async () => {
|
|
159
|
+
await assert.rejects(() => installer.install(`${baseUrl}/bad-manifest.json`), /sha256 mismatch/);
|
|
160
|
+
const modelDir = join(daemon.grooveDir, 'axom', 'models');
|
|
161
|
+
assert.ok(!existsSync(join(modelDir, 'chassis.gguf')));
|
|
162
|
+
assert.ok(!existsSync(join(modelDir, 'chassis.gguf.part')));
|
|
163
|
+
assert.equal(installer.getStatus().phase, 'error');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('refuses an unverifiable manifest entry (no sha256)', async () => {
|
|
167
|
+
const s2 = createServer((req, res) => res.end(JSON.stringify({
|
|
168
|
+
models: [{ file: 'x.gguf', url: `${baseUrl}/chassis.gguf` }],
|
|
169
|
+
})));
|
|
170
|
+
await new Promise((r) => s2.listen(0, '127.0.0.1', r));
|
|
171
|
+
const url = `http://127.0.0.1:${s2.address().port}/`;
|
|
172
|
+
try {
|
|
173
|
+
await assert.rejects(() => installer.install(url), /refusing unverifiable download/);
|
|
174
|
+
} finally {
|
|
175
|
+
await new Promise((r) => s2.close(r));
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('errors without a configured manifest url — no hardcoded source exists', async () => {
|
|
180
|
+
await assert.rejects(() => installer.install(undefined), /no install manifest configured/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('refuses to download weights below the RAM floor', async () => {
|
|
184
|
+
installer = new AxomInstaller(daemon, { totalRamGb: 8 });
|
|
185
|
+
await assert.rejects(() => installer.install(`${baseUrl}/manifest.json`), /below Axom's 12GB floor/);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// GROOVE — Cross-Daemon InnerChat Relay Tests
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Two in-process daemons, each with a REAL federation keypair, relay to each
|
|
5
|
+
// other through an injected transport that mirrors the relay route's trust
|
|
6
|
+
// gates (configured-peer check + Ed25519 signature verify). This exercises the
|
|
7
|
+
// signing/verification path end-to-end with real keys, the guest-identity
|
|
8
|
+
// delivery, the blocking ask round trip, and the async tell → outbox → reply
|
|
9
|
+
// path — without binding two HTTP servers.
|
|
10
|
+
|
|
11
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { InnerChat } from '../src/innerchat.js';
|
|
17
|
+
import { RelayClient, parsePeerRef, validatePeer } from '../src/innerchat-relay.js';
|
|
18
|
+
import { Federation } from '../src/federation.js';
|
|
19
|
+
|
|
20
|
+
const result = (text) => ({ type: 'result', data: text });
|
|
21
|
+
const tick = () => new Promise((r) => setImmediate(r));
|
|
22
|
+
|
|
23
|
+
const tmpDirs = [];
|
|
24
|
+
function makeDaemon() {
|
|
25
|
+
const grooveDir = mkdtempSync(join(tmpdir(), 'groove-relay-'));
|
|
26
|
+
tmpDirs.push(grooveDir);
|
|
27
|
+
|
|
28
|
+
const sent = [];
|
|
29
|
+
const daemon = {
|
|
30
|
+
grooveDir,
|
|
31
|
+
host: '127.0.0.1',
|
|
32
|
+
port: 31415,
|
|
33
|
+
config: { innerchatPeers: [] },
|
|
34
|
+
sent,
|
|
35
|
+
registry: {
|
|
36
|
+
_agents: new Map(),
|
|
37
|
+
get(id) { return this._agents.get(id) || null; },
|
|
38
|
+
getAll() { return [...this._agents.values()]; },
|
|
39
|
+
add(a) { this._agents.set(a.id, a); return a; },
|
|
40
|
+
remove(id) { this._agents.delete(id); },
|
|
41
|
+
flushPendingRemovals() {},
|
|
42
|
+
update() {},
|
|
43
|
+
},
|
|
44
|
+
locks: { release() {} },
|
|
45
|
+
processes: {
|
|
46
|
+
_loops: new Set(),
|
|
47
|
+
_running: new Set(),
|
|
48
|
+
hasAgentLoop(id) { return this._loops.has(id); },
|
|
49
|
+
isRunning(id) { return this._running.has(id); },
|
|
50
|
+
async sendMessage(id, msg) { sent.push({ id, msg }); return true; },
|
|
51
|
+
queueMessage() {},
|
|
52
|
+
sessionClock() { return null; },
|
|
53
|
+
},
|
|
54
|
+
broadcast() {},
|
|
55
|
+
audit: { log() {} },
|
|
56
|
+
};
|
|
57
|
+
daemon.federation = new Federation(daemon);
|
|
58
|
+
return daemon;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A fetch stub that routes a relay POST to the target daemon, applying the same
|
|
62
|
+
// verification the /api/innerchat/relay route does.
|
|
63
|
+
function makeTransport(routes) {
|
|
64
|
+
return async (url, opts) => {
|
|
65
|
+
const body = JSON.parse(opts.body);
|
|
66
|
+
const entry = routes.find((r) => url.startsWith(r.prefix));
|
|
67
|
+
if (!entry) return reply(502, { error: 'no route' });
|
|
68
|
+
const target = entry.daemon;
|
|
69
|
+
const { payload, signature } = body;
|
|
70
|
+
|
|
71
|
+
const peer = target.innerchat._peerByDaemonId(payload.fromDaemonId);
|
|
72
|
+
if (!peer) return reply(403, { error: 'unknown daemon' });
|
|
73
|
+
if (!target.federation.verify(payload.fromDaemonId, payload, signature)) {
|
|
74
|
+
return reply(403, { error: 'bad signature' });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const path = new URL(url).pathname;
|
|
78
|
+
if (path === '/api/innerchat/relay/outbox') {
|
|
79
|
+
return reply(200, { entries: target.innerchat.drainOutbox(payload.fromDaemonId) });
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const out = await target.innerchat.receiveRelay({ ...payload, peerAlias: peer.alias });
|
|
83
|
+
return reply(200, out);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return reply(404, { error: err.message, availableAgents: err.availableAgents });
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function reply(status, json) {
|
|
91
|
+
return { ok: status < 400, status, json: () => Promise.resolve(json) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Cross-register a as a federation peer of b (b can verify a's signatures) and
|
|
95
|
+
// tell a it can reach b at `url` under `alias`.
|
|
96
|
+
function link(a, b, alias, url) {
|
|
97
|
+
b.federation._savePeer({
|
|
98
|
+
id: a.federation.getDaemonId(),
|
|
99
|
+
name: alias,
|
|
100
|
+
host: '127.0.0.1',
|
|
101
|
+
port: 1,
|
|
102
|
+
publicKey: a.federation.getPublicKeyPem(),
|
|
103
|
+
});
|
|
104
|
+
a.config.innerchatPeers.push({ alias, url, daemonId: b.federation.getDaemonId() });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('Cross-daemon InnerChat relay', () => {
|
|
108
|
+
let A, B;
|
|
109
|
+
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
A = makeDaemon();
|
|
112
|
+
B = makeDaemon();
|
|
113
|
+
|
|
114
|
+
// A knows B as "bee" (and B trusts A's key); B knows A as "ay".
|
|
115
|
+
link(A, B, 'bee', 'http://peer-b');
|
|
116
|
+
link(B, A, 'ay', 'http://peer-a');
|
|
117
|
+
|
|
118
|
+
const transport = makeTransport([
|
|
119
|
+
{ prefix: 'http://peer-b', daemon: B },
|
|
120
|
+
{ prefix: 'http://peer-a', daemon: A },
|
|
121
|
+
]);
|
|
122
|
+
A.innerchat = new InnerChat(A, { relay: new RelayClient({ fetchImpl: transport }) });
|
|
123
|
+
B.innerchat = new InnerChat(B, { relay: new RelayClient({ fetchImpl: transport }) });
|
|
124
|
+
|
|
125
|
+
// One agent on each daemon, both running interactive loops.
|
|
126
|
+
A.registry.add({ id: 'a1', name: 'Integration-Manager', role: 'fullstack', provider: 'claude-code' });
|
|
127
|
+
A.processes._loops.add('a1'); A.processes._running.add('a1');
|
|
128
|
+
B.registry.add({ id: 'b1', name: 'Architecture-Fable', role: 'architect', provider: 'claude-code' });
|
|
129
|
+
B.processes._loops.add('b1'); B.processes._running.add('b1');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
A.innerchat.stop();
|
|
134
|
+
B.innerchat.stop();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('parses local vs peer references', () => {
|
|
138
|
+
assert.deepEqual(parsePeerRef('Architecture-Fable'), { name: 'Architecture-Fable', alias: null });
|
|
139
|
+
assert.deepEqual(parsePeerRef('Architecture-Fable@spark'), { name: 'Architecture-Fable', alias: 'spark' });
|
|
140
|
+
assert.equal(parsePeerRef('a@b@c'), null);
|
|
141
|
+
assert.equal(A.innerchat.isRemoteRef('local-name'), false);
|
|
142
|
+
assert.equal(A.innerchat.isRemoteRef('name@bee'), true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('relays a blocking ask and returns the peer agent\'s reply', async () => {
|
|
146
|
+
const from = A.registry.get('a1');
|
|
147
|
+
const p = A.innerchat.askRemote(from, 'Architecture-Fable', 'bee', 'What is the envelope shape?');
|
|
148
|
+
await tick(); await tick();
|
|
149
|
+
|
|
150
|
+
// B received the question delivered to its local agent, with a guest identity.
|
|
151
|
+
const delivered = B.sent.at(-1);
|
|
152
|
+
assert.equal(delivered.id, 'b1');
|
|
153
|
+
assert.match(delivered.msg, /Integration-Manager@ay.*asking you a question/s);
|
|
154
|
+
|
|
155
|
+
// B's agent answers → the reply rides back to A.
|
|
156
|
+
B.innerchat.onAgentOutput('b1', result('It is an additive envelope.'));
|
|
157
|
+
const out = await p;
|
|
158
|
+
assert.equal(out.reply, 'It is an additive envelope.');
|
|
159
|
+
assert.equal(out.remote, true);
|
|
160
|
+
assert.equal(out.peer, 'bee');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rejects a relay from an unconfigured daemon', async () => {
|
|
164
|
+
// B no longer lists A as an InnerChat peer — the config gate rejects it
|
|
165
|
+
// before signature verification even runs.
|
|
166
|
+
B.config.innerchatPeers = [];
|
|
167
|
+
const from = A.registry.get('a1');
|
|
168
|
+
await assert.rejects(
|
|
169
|
+
A.innerchat.askRemote(from, 'Architecture-Fable', 'bee', 'hi'),
|
|
170
|
+
/unknown daemon|refused/i,
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('rejects a tampered signature', async () => {
|
|
175
|
+
// Point A's relay client at a transport that mangles the signature.
|
|
176
|
+
const good = makeTransport([{ prefix: 'http://peer-b', daemon: B }]);
|
|
177
|
+
const tampering = async (url, opts) => {
|
|
178
|
+
const body = JSON.parse(opts.body);
|
|
179
|
+
body.signature = Buffer.from('nope').toString('base64');
|
|
180
|
+
return good(url, { ...opts, body: JSON.stringify(body) });
|
|
181
|
+
};
|
|
182
|
+
A.innerchat.relay = new RelayClient({ fetchImpl: tampering });
|
|
183
|
+
const from = A.registry.get('a1');
|
|
184
|
+
await assert.rejects(
|
|
185
|
+
A.innerchat.askRemote(from, 'Architecture-Fable', 'bee', 'hi'),
|
|
186
|
+
/bad signature|refused/i,
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('returns availableAgents when the peer has no such agent', async () => {
|
|
191
|
+
const from = A.registry.get('a1');
|
|
192
|
+
await assert.rejects(
|
|
193
|
+
A.innerchat.askRemote(from, 'Nobody-Here', 'bee', 'hi'),
|
|
194
|
+
(err) => /No agent named/.test(err.message),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('refuses a transitive (two-hop) relay target', async () => {
|
|
199
|
+
await assert.rejects(
|
|
200
|
+
B.innerchat.receiveRelay({
|
|
201
|
+
fromName: 'X', fromDaemonId: A.federation.getDaemonId(), peerAlias: 'ay',
|
|
202
|
+
toName: 'Architecture-Fable@somewhere', message: 'hi', kind: 'ask',
|
|
203
|
+
}),
|
|
204
|
+
/one hop only/i,
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('relays a tell, queues the reply in the outbox, and routes it back on drain', async () => {
|
|
209
|
+
const from = A.registry.get('a1');
|
|
210
|
+
const res = await A.innerchat.tellRemote(from, 'Architecture-Fable', 'bee', 'FYI: spec landed.');
|
|
211
|
+
assert.equal(res.delivered, true);
|
|
212
|
+
|
|
213
|
+
// B's agent answers asynchronously → queued in B's outbox for A.
|
|
214
|
+
B.innerchat.onAgentOutput('b1', result('Acknowledged, thanks.'));
|
|
215
|
+
const forA = B.innerchat.drainOutbox(A.federation.getDaemonId());
|
|
216
|
+
assert.equal(forA.length, 1);
|
|
217
|
+
assert.equal(forA[0].text, 'Acknowledged, thanks.');
|
|
218
|
+
assert.equal(forA[0].toName, 'Integration-Manager');
|
|
219
|
+
|
|
220
|
+
// Draining removes it (at-least-once).
|
|
221
|
+
assert.equal(B.innerchat.drainOutbox(A.federation.getDaemonId()).length, 0);
|
|
222
|
+
|
|
223
|
+
// A routes the queued reply to its local sender.
|
|
224
|
+
await A.innerchat._deliverRemoteReply(A.config.innerchatPeers[0], forA[0]);
|
|
225
|
+
const routed = A.sent.at(-1);
|
|
226
|
+
assert.equal(routed.id, 'a1');
|
|
227
|
+
assert.match(routed.msg, /InnerChat reply from Architecture-Fable@bee/);
|
|
228
|
+
assert.match(routed.msg, /Acknowledged, thanks\./);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('surfaces a helpful error for an unknown peer alias', async () => {
|
|
232
|
+
const from = A.registry.get('a1');
|
|
233
|
+
await assert.rejects(
|
|
234
|
+
A.innerchat.askRemote(from, 'Architecture-Fable', 'no-such-peer', 'hi'),
|
|
235
|
+
/No configured InnerChat peer "no-such-peer".*Known peers: bee/s,
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('validates peer config shape', () => {
|
|
240
|
+
assert.equal(validatePeer({ alias: 'spark', url: 'http://localhost:62686', daemonId: 'a1b2c3d4e5f6' }), null);
|
|
241
|
+
assert.match(validatePeer({ alias: 'sp ace', url: 'http://x', daemonId: 'a1b2c3d4e5f6' }), /alias/);
|
|
242
|
+
assert.match(validatePeer({ alias: 'ok', url: 'ftp://x', daemonId: 'a1b2c3d4e5f6' }), /http/);
|
|
243
|
+
assert.match(validatePeer({ alias: 'ok', url: 'http://u:p@x', daemonId: 'a1b2c3d4e5f6' }), /credentials/);
|
|
244
|
+
assert.match(validatePeer({ alias: 'ok', url: 'http://x', daemonId: 'NOTHEX' }), /daemonId/);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Clean up temp federation dirs.
|
|
249
|
+
process.on('exit', () => {
|
|
250
|
+
for (const d of tmpDirs) { try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } }
|
|
251
|
+
});
|