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
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// GROOVE — Axom Remote Runtime Control (SSH)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// Start and stop an `axom serve` on a machine you own, over SSH, from the
|
|
5
|
+
// GROOVE that talks to it. Deliberately MANUAL: nothing here auto-starts, and
|
|
6
|
+
// nothing supervises or restarts a runtime that stops. The user decides when
|
|
7
|
+
// the workhorse is working (Ryan, 2026-07-25: "I will turn it off and on as I
|
|
8
|
+
// need... I just need the control").
|
|
9
|
+
//
|
|
10
|
+
// Only ever runs commands the user configured, on a host the user configured,
|
|
11
|
+
// with their own SSH credentials. GROOVE holds no keys of its own.
|
|
12
|
+
|
|
13
|
+
import { execFile, spawn } from 'child_process';
|
|
14
|
+
import { AXOM_DEFAULT_PORT } from './axom-connector.js';
|
|
15
|
+
|
|
16
|
+
const SSH_OPTS = [
|
|
17
|
+
'-o', 'ConnectTimeout=8',
|
|
18
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
19
|
+
'-o', 'BatchMode=yes',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function validateHost(host) {
|
|
23
|
+
// Hostname or IP only — no user@, no flags, no shell metacharacters. The
|
|
24
|
+
// value reaches execFile as a single argv entry, never a shell string.
|
|
25
|
+
return typeof host === 'string' && /^[a-zA-Z0-9._-]{1,253}$/.test(host);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validateUser(user) {
|
|
29
|
+
return typeof user === 'string' && /^[a-zA-Z0-9._-]{1,32}$/.test(user);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateRemote(remote) {
|
|
33
|
+
if (!remote || typeof remote !== 'object') return 'remote config must be an object';
|
|
34
|
+
if (!validateHost(remote.host)) return 'invalid host';
|
|
35
|
+
if (!validateUser(remote.user)) return 'invalid user';
|
|
36
|
+
if (remote.sshPort !== undefined && !(Number.isInteger(remote.sshPort) && remote.sshPort > 0 && remote.sshPort < 65536)) {
|
|
37
|
+
return 'invalid sshPort';
|
|
38
|
+
}
|
|
39
|
+
if (remote.port !== undefined && !(Number.isInteger(remote.port) && remote.port > 0 && remote.port < 65536)) {
|
|
40
|
+
return 'invalid port';
|
|
41
|
+
}
|
|
42
|
+
if (remote.command !== undefined && (typeof remote.command !== 'string' || remote.command.length > 500)) {
|
|
43
|
+
return 'command must be a string of at most 500 chars';
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class AxomRemote {
|
|
49
|
+
constructor(daemon, opts = {}) {
|
|
50
|
+
this.daemon = daemon;
|
|
51
|
+
this.exec = opts.exec || execFile; // tests inject
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_config() {
|
|
55
|
+
return this.daemon.config?.axom?.remote || null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_ssh(command, timeoutMs = 30000) {
|
|
59
|
+
const cfg = this._config();
|
|
60
|
+
if (!cfg) return Promise.reject(new Error('no remote Axom host configured'));
|
|
61
|
+
const problem = validateRemote(cfg);
|
|
62
|
+
if (problem) return Promise.reject(new Error(`remote config invalid: ${problem}`));
|
|
63
|
+
|
|
64
|
+
const args = [...SSH_OPTS, '-p', String(cfg.sshPort || 22), `${cfg.user}@${cfg.host}`, command];
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
this.exec('ssh', args, { encoding: 'utf8', timeout: timeoutMs }, (err, stdout, stderr) => {
|
|
67
|
+
if (err && !stdout) return reject(new Error((stderr || err.message || '').trim().split('\n').pop()));
|
|
68
|
+
resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Is a runtime listening on the remote port right now?
|
|
74
|
+
async status() {
|
|
75
|
+
const cfg = this._config();
|
|
76
|
+
if (!cfg) return { configured: false, running: null };
|
|
77
|
+
const port = cfg.port || AXOM_DEFAULT_PORT;
|
|
78
|
+
try {
|
|
79
|
+
const { stdout } = await this._ssh(
|
|
80
|
+
`curl -sf --max-time 4 http://127.0.0.1:${port}/about >/dev/null 2>&1 && echo UP || echo DOWN`,
|
|
81
|
+
15000,
|
|
82
|
+
);
|
|
83
|
+
return {
|
|
84
|
+
configured: true,
|
|
85
|
+
host: cfg.host,
|
|
86
|
+
user: cfg.user,
|
|
87
|
+
port,
|
|
88
|
+
running: stdout.includes('UP'),
|
|
89
|
+
error: null,
|
|
90
|
+
};
|
|
91
|
+
} catch (err) {
|
|
92
|
+
// Unreachable host is not "not running" — we genuinely do not know.
|
|
93
|
+
return { configured: true, host: cfg.host, user: cfg.user, port, running: null, error: err.message };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async start() {
|
|
98
|
+
const cfg = this._config();
|
|
99
|
+
if (!cfg) throw new Error('no remote Axom host configured');
|
|
100
|
+
const port = cfg.port || AXOM_DEFAULT_PORT;
|
|
101
|
+
const already = await this.status();
|
|
102
|
+
if (already.running) return { started: false, alreadyRunning: true, port };
|
|
103
|
+
|
|
104
|
+
const command = cfg.command
|
|
105
|
+
|| `axom serve --port ${port} --data-dir ~/axom-serve/data --model-dir ~/axom-serve/models`;
|
|
106
|
+
// Detached from this SSH session so it survives the connection closing.
|
|
107
|
+
// No supervisor, no restart-on-exit: manual control means manual.
|
|
108
|
+
const log = cfg.logPath || '~/axom-serve/serve.log';
|
|
109
|
+
await this._ssh(`nohup ${command} >> ${log} 2>&1 < /dev/null & disown; echo STARTED`, 30000);
|
|
110
|
+
|
|
111
|
+
// Confirm it actually came up rather than reporting optimism.
|
|
112
|
+
for (let i = 0; i < 30; i++) {
|
|
113
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
114
|
+
const s = await this.status();
|
|
115
|
+
if (s.running) {
|
|
116
|
+
this.daemon.audit.log('axom.remote.start', { host: cfg.host, port });
|
|
117
|
+
this._broadcast();
|
|
118
|
+
return { started: true, port, logPath: log };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
throw new Error(`started the command but nothing answered on port ${port} within 60s — check ${log} on ${cfg.host}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async stop({ force = false } = {}) {
|
|
125
|
+
const cfg = this._config();
|
|
126
|
+
if (!cfg) throw new Error('no remote Axom host configured');
|
|
127
|
+
const port = cfg.port || AXOM_DEFAULT_PORT;
|
|
128
|
+
|
|
129
|
+
// Prefer the contract's own verb (§14): it flushes the ledger and
|
|
130
|
+
// releases the lockfile. Fall back to a signal only if the runtime
|
|
131
|
+
// predates the verb.
|
|
132
|
+
try {
|
|
133
|
+
const { stdout } = await this._ssh(
|
|
134
|
+
`curl -s -o /dev/null -w '%{http_code}' --max-time 8 -X POST `
|
|
135
|
+
+ `-H 'Content-Type: application/json' -d '{"force":${force ? 'true' : 'false'}}' `
|
|
136
|
+
+ `http://127.0.0.1:${port}/shutdown`,
|
|
137
|
+
20000,
|
|
138
|
+
);
|
|
139
|
+
const code = parseInt(stdout.trim().slice(-3), 10);
|
|
140
|
+
if (code === 202) {
|
|
141
|
+
this.daemon.audit.log('axom.remote.stop', { host: cfg.host, port, via: 'shutdown' });
|
|
142
|
+
this._broadcast();
|
|
143
|
+
return { stopped: true, via: 'shutdown' };
|
|
144
|
+
}
|
|
145
|
+
if (code === 409) return { stopped: false, turnInFlight: true };
|
|
146
|
+
if (code !== 404) throw new Error(`shutdown returned HTTP ${code}`);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
if (!/HTTP 404/.test(err.message)) throw err;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Pre-§14 runtime: signal the process that owns the port. Still the
|
|
152
|
+
// user's own machine, still an explicit action they asked for.
|
|
153
|
+
await this._ssh(`PID=$(lsof -t -i:${port} 2>/dev/null | head -1); [ -n "$PID" ] && kill $PID && echo KILLED || echo NOTFOUND`, 20000);
|
|
154
|
+
this.daemon.audit.log('axom.remote.stop', { host: cfg.host, port, via: 'signal' });
|
|
155
|
+
this._broadcast();
|
|
156
|
+
return { stopped: true, via: 'signal' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Reachability (the port-forward), distinct from runtime lifecycle ──────
|
|
160
|
+
//
|
|
161
|
+
// The runtime is the user's workhorse and is never touched automatically.
|
|
162
|
+
// The TUNNEL is plumbing: it dies on every sleep/wake (ssh's keepalive exits
|
|
163
|
+
// on resume and nothing respawns it), costs the remote machine nothing, and
|
|
164
|
+
// its death looks identical to "Axom is down" from the GUI. So we heal it on
|
|
165
|
+
// demand — but only ever a tunnel GROOVE opened, and only after a health
|
|
166
|
+
// check proves the port is genuinely dead (an unconditional teardown kills
|
|
167
|
+
// slow-but-working tunnels — learned by fullstack-3 the hard way).
|
|
168
|
+
|
|
169
|
+
async _portAnswers(port, timeoutMs = 2500) {
|
|
170
|
+
try {
|
|
171
|
+
const res = await fetch(`http://127.0.0.1:${port}/about`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
172
|
+
return res.ok;
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async ensureTunnel() {
|
|
179
|
+
const cfg = this._config();
|
|
180
|
+
if (!cfg) return { tunneled: false, reason: 'no remote configured' };
|
|
181
|
+
const problem = validateRemote(cfg);
|
|
182
|
+
if (problem) return { tunneled: false, reason: problem };
|
|
183
|
+
const port = cfg.port || AXOM_DEFAULT_PORT;
|
|
184
|
+
|
|
185
|
+
if (await this._portAnswers(port)) return { tunneled: true, healed: false, port };
|
|
186
|
+
|
|
187
|
+
// Port is genuinely dead. If a tunnel process of ours is lingering, drop
|
|
188
|
+
// it before opening another (a stale forward holds the local port and the
|
|
189
|
+
// new ssh would fail with "address already in use").
|
|
190
|
+
if (this._tunnelProc && !this._tunnelProc.killed) {
|
|
191
|
+
try { this._tunnelProc.kill(); } catch { /* already gone */ }
|
|
192
|
+
this._tunnelProc = null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const args = [
|
|
196
|
+
'-N', '-L', `127.0.0.1:${port}:localhost:${port}`,
|
|
197
|
+
...SSH_OPTS,
|
|
198
|
+
'-o', 'ServerAliveInterval=30',
|
|
199
|
+
'-o', 'ExitOnForwardFailure=yes',
|
|
200
|
+
'-p', String(cfg.sshPort || 22),
|
|
201
|
+
`${cfg.user}@${cfg.host}`,
|
|
202
|
+
];
|
|
203
|
+
const proc = spawn('ssh', args, { stdio: 'ignore', detached: false });
|
|
204
|
+
this._tunnelProc = proc;
|
|
205
|
+
proc.on('exit', () => { if (this._tunnelProc === proc) this._tunnelProc = null; });
|
|
206
|
+
|
|
207
|
+
for (let i = 0; i < 10; i++) {
|
|
208
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
209
|
+
if (await this._portAnswers(port, 1500)) {
|
|
210
|
+
this.daemon.audit.log('axom.remote.tunnel', { host: cfg.host, port });
|
|
211
|
+
return { tunneled: true, healed: true, port };
|
|
212
|
+
}
|
|
213
|
+
if (proc.exitCode !== null) break;
|
|
214
|
+
}
|
|
215
|
+
// Honest failure: the forward may be up while the runtime is down — say
|
|
216
|
+
// what we know (port silent) rather than asserting a cause.
|
|
217
|
+
return { tunneled: false, healed: false, port, reason: `nothing answers on 127.0.0.1:${port} after opening the forward — the runtime may be stopped` };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
closeTunnel() {
|
|
221
|
+
if (this._tunnelProc && !this._tunnelProc.killed) {
|
|
222
|
+
try { this._tunnelProc.kill(); } catch { /* already gone */ }
|
|
223
|
+
}
|
|
224
|
+
this._tunnelProc = null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
_broadcast() {
|
|
228
|
+
this.status().then((s) => this.daemon.broadcast({ type: 'axom:remote', data: s })).catch(() => {});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -56,6 +56,7 @@ export class AxomServerManager {
|
|
|
56
56
|
this.daemon = daemon;
|
|
57
57
|
this.command = opts.command || null; // resolved lazily from config
|
|
58
58
|
this.totalRamGbOverride = opts.totalRamGb; // tests inject; prod reads os
|
|
59
|
+
this.portBase = opts.portBase || AXOM_DEFAULT_PORT;
|
|
59
60
|
this.instances = new Map(); // id -> {id, proc, port, dataDir, status, startedAt, error}
|
|
60
61
|
}
|
|
61
62
|
|
|
@@ -73,7 +74,7 @@ export class AxomServerManager {
|
|
|
73
74
|
for (const ep of this.daemon.axom?.endpoints?.values?.() || []) {
|
|
74
75
|
try { used.add(Number(new URL(ep.url).port)); } catch { /* non-numeric */ }
|
|
75
76
|
}
|
|
76
|
-
let port =
|
|
77
|
+
let port = this.portBase;
|
|
77
78
|
while (used.has(port)) port += 1;
|
|
78
79
|
return port;
|
|
79
80
|
}
|
|
@@ -191,7 +192,26 @@ export class AxomServerManager {
|
|
|
191
192
|
if (instance.status === 'error') return;
|
|
192
193
|
try {
|
|
193
194
|
const res = await fetch(`http://127.0.0.1:${port}/about`, { signal: AbortSignal.timeout(2000) });
|
|
194
|
-
if (res.ok)
|
|
195
|
+
if (res.ok) {
|
|
196
|
+
// Verify the answer is OURS. Another process (or an SSH tunnel to a
|
|
197
|
+
// remote runtime) can already own this port; adopting it would let
|
|
198
|
+
// GROOVE claim — and offer to shut down — a runtime it never
|
|
199
|
+
// launched. §11 gives /about an instance block; when it names a
|
|
200
|
+
// different pid or data-dir, this port is not ours.
|
|
201
|
+
const about = await res.json().catch(() => null);
|
|
202
|
+
const claimed = about?.instance;
|
|
203
|
+
const foreign = claimed
|
|
204
|
+
&& ((claimed.pid && instance.proc?.pid && claimed.pid !== instance.proc.pid)
|
|
205
|
+
|| (claimed.data_dir && claimed.data_dir !== instance.dataDir));
|
|
206
|
+
if (foreign) {
|
|
207
|
+
instance.status = 'error';
|
|
208
|
+
instance.error = `port ${port} is already served by another Axom runtime `
|
|
209
|
+
+ `(${claimed.data_dir || `pid ${claimed.pid}`}) — refusing to adopt it`;
|
|
210
|
+
instance.proc?.kill('SIGKILL');
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
195
215
|
} catch { /* not up yet */ }
|
|
196
216
|
await new Promise((r) => setTimeout(r, 500));
|
|
197
217
|
}
|
|
@@ -57,6 +57,7 @@ import { bindDaemon as bindAxomDaemon } from './providers/axom.js';
|
|
|
57
57
|
import { AxomConnector } from './axom-connector.js';
|
|
58
58
|
import { AxomServerManager } from './axom-server.js';
|
|
59
59
|
import { AxomInstaller } from './axom-install.js';
|
|
60
|
+
import { AxomRemote } from './axom-remote.js';
|
|
60
61
|
import { setProviderPaths } from './providers/index.js';
|
|
61
62
|
|
|
62
63
|
const DEFAULT_PORT = 31415;
|
|
@@ -173,6 +174,7 @@ export class Daemon {
|
|
|
173
174
|
this.axom = new AxomConnector(this);
|
|
174
175
|
this.axomServer = new AxomServerManager(this);
|
|
175
176
|
this.axomInstaller = new AxomInstaller(this);
|
|
177
|
+
this.axomRemote = new AxomRemote(this);
|
|
176
178
|
this.trajectoryCapture = null;
|
|
177
179
|
|
|
178
180
|
// Hook teams.delete to clean up agent-loop session files
|
|
@@ -886,6 +888,7 @@ export class Daemon {
|
|
|
886
888
|
this.chatStore.stop();
|
|
887
889
|
this.orchestrator.stop();
|
|
888
890
|
this.timeline.stop();
|
|
891
|
+
this.axomRemote.closeTunnel();
|
|
889
892
|
await this.axomServer.destroy();
|
|
890
893
|
this.axom.destroy();
|
|
891
894
|
if (this._gcInterval) clearInterval(this._gcInterval);
|
|
@@ -4,6 +4,7 @@ import os from 'os';
|
|
|
4
4
|
import { saveConfig } from '../firstrun.js';
|
|
5
5
|
import { validateEndpoint, AXOM_DEFAULT_PORT } from '../axom-connector.js';
|
|
6
6
|
import { hardwareReport } from '../axom-server.js';
|
|
7
|
+
import { validateRemote } from '../axom-remote.js';
|
|
7
8
|
|
|
8
9
|
// Interrupt text is capped runtime-side at 2000 chars (contract §2, with a
|
|
9
10
|
// `truncated` flag in the response); we allow headroom and let the runtime be
|
|
@@ -37,11 +38,14 @@ export function registerAxomRoutes(app, daemon) {
|
|
|
37
38
|
// through verbatim — the GUI reads them, we don't reinterpret.
|
|
38
39
|
app.post('/api/axom/sessions/:id/message', async (req, res) => {
|
|
39
40
|
try {
|
|
40
|
-
const { text, endpoint } = req.body || {};
|
|
41
|
+
const { text, endpoint, clientRef } = req.body || {};
|
|
41
42
|
if (!text || typeof text !== 'string' || !text.trim()) {
|
|
42
43
|
return res.status(400).json({ error: 'text is required' });
|
|
43
44
|
}
|
|
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);
|
|
45
49
|
daemon.audit.log('axom.message', { session: req.params.id, chars: text.length, status: result.status });
|
|
46
50
|
res.status(result.status).json(result.body);
|
|
47
51
|
} catch (err) {
|
|
@@ -101,6 +105,73 @@ export function registerAxomRoutes(app, daemon) {
|
|
|
101
105
|
});
|
|
102
106
|
});
|
|
103
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
|
+
|
|
104
175
|
// ── Managed local instances (contract §11) ──────────────────────────────
|
|
105
176
|
|
|
106
177
|
app.get('/api/axom/instances', (req, res) => {
|
|
@@ -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) => {
|