@sonde/agent 0.2.2 → 0.2.4
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/.turbo/turbo-build.log +6 -4
- package/.turbo/turbo-test.log +66 -21
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +16 -0
- package/dist/cli/mcp-bridge.d.ts +2 -0
- package/dist/cli/mcp-bridge.d.ts.map +1 -0
- package/dist/cli/mcp-bridge.js +193 -0
- package/dist/cli/mcp-bridge.js.map +1 -0
- package/dist/cli/mcp-bridge.test.d.ts +2 -0
- package/dist/cli/mcp-bridge.test.d.ts.map +1 -0
- package/dist/cli/mcp-bridge.test.js +54 -0
- package/dist/cli/mcp-bridge.test.js.map +1 -0
- package/dist/cli/update.d.ts +18 -0
- package/dist/cli/update.d.ts.map +1 -0
- package/dist/cli/update.js +71 -0
- package/dist/cli/update.js.map +1 -0
- package/dist/cli/update.test.d.ts +2 -0
- package/dist/cli/update.test.d.ts.map +1 -0
- package/dist/cli/update.test.js +55 -0
- package/dist/cli/update.test.js.map +1 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +16 -2
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +65 -6
- package/dist/index.js.map +1 -1
- package/dist/runtime/connection.d.ts +1 -0
- package/dist/runtime/connection.d.ts.map +1 -1
- package/dist/runtime/connection.js +33 -7
- package/dist/runtime/connection.js.map +1 -1
- package/dist/runtime/executor.d.ts +6 -0
- package/dist/runtime/executor.d.ts.map +1 -1
- package/dist/runtime/executor.js +4 -4
- package/dist/runtime/executor.js.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/package.json +1 -1
- package/src/cli/mcp-bridge.test.ts +58 -0
- package/src/cli/mcp-bridge.ts +217 -0
- package/src/cli/update.test.ts +69 -0
- package/src/cli/update.ts +78 -0
- package/src/config.ts +16 -2
- package/src/index.ts +73 -8
- package/src/runtime/connection.ts +43 -9
- package/src/runtime/executor.ts +17 -5
- package/src/version.ts +10 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdio → StreamableHTTP MCP bridge.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code spawns `sonde mcp-bridge` and speaks MCP over stdin/stdout.
|
|
5
|
+
* This bridge forwards every JSON-RPC message to the hub's /mcp endpoint
|
|
6
|
+
* using the StreamableHTTP transport and relays responses back on stdout.
|
|
7
|
+
*
|
|
8
|
+
* Protocol details:
|
|
9
|
+
* stdin – newline-delimited JSON-RPC (MCP stdio transport)
|
|
10
|
+
* stdout – newline-delimited JSON-RPC (MCP stdio transport)
|
|
11
|
+
* hub – HTTP POST /mcp with JSON body, response is JSON or SSE
|
|
12
|
+
*/
|
|
13
|
+
import { loadConfig } from '../config.js';
|
|
14
|
+
|
|
15
|
+
// ── stdio helpers ──────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** Write a JSON-RPC message to stdout (newline-delimited). */
|
|
18
|
+
function writeMessage(msg: unknown): void {
|
|
19
|
+
const json = JSON.stringify(msg);
|
|
20
|
+
process.stdout.write(`${json}\n`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Log to stderr so it doesn't interfere with the JSON-RPC channel. */
|
|
24
|
+
function log(msg: string): void {
|
|
25
|
+
process.stderr.write(`[sonde-bridge] ${msg}\n`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── SSE parsing ────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/** Parse SSE text into individual data payloads. */
|
|
31
|
+
function parseSseEvents(text: string): string[] {
|
|
32
|
+
const payloads: string[] = [];
|
|
33
|
+
let currentData = '';
|
|
34
|
+
for (const line of text.split('\n')) {
|
|
35
|
+
if (line.startsWith('data: ')) {
|
|
36
|
+
currentData += line.slice(6);
|
|
37
|
+
} else if (line === '' && currentData) {
|
|
38
|
+
payloads.push(currentData);
|
|
39
|
+
currentData = '';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Flush any remaining data (stream may not end with blank line)
|
|
43
|
+
if (currentData) {
|
|
44
|
+
payloads.push(currentData);
|
|
45
|
+
}
|
|
46
|
+
return payloads;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── HTTP transport ─────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
interface BridgeOptions {
|
|
52
|
+
mcpUrl: string;
|
|
53
|
+
apiKey: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class McpHttpTransport {
|
|
57
|
+
private sessionId: string | undefined;
|
|
58
|
+
private mcpUrl: string;
|
|
59
|
+
private apiKey: string;
|
|
60
|
+
|
|
61
|
+
constructor(opts: BridgeOptions) {
|
|
62
|
+
this.mcpUrl = opts.mcpUrl;
|
|
63
|
+
this.apiKey = opts.apiKey;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Send a JSON-RPC message to the hub and relay response(s) to stdout. */
|
|
67
|
+
async send(message: unknown): Promise<void> {
|
|
68
|
+
const headers: Record<string, string> = {
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
Accept: 'application/json, text/event-stream',
|
|
71
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (this.sessionId) {
|
|
75
|
+
headers['Mcp-Session-Id'] = this.sessionId;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const res = await fetch(this.mcpUrl, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers,
|
|
81
|
+
body: JSON.stringify(message),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Capture session ID from response
|
|
85
|
+
const newSessionId = res.headers.get('mcp-session-id');
|
|
86
|
+
if (newSessionId) {
|
|
87
|
+
this.sessionId = newSessionId;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
const text = await res.text();
|
|
92
|
+
log(`Hub returned ${res.status}: ${text}`);
|
|
93
|
+
// Send JSON-RPC error response if the message had an id
|
|
94
|
+
const msg = message as { id?: string | number };
|
|
95
|
+
if (msg.id !== undefined) {
|
|
96
|
+
writeMessage({
|
|
97
|
+
jsonrpc: '2.0',
|
|
98
|
+
id: msg.id,
|
|
99
|
+
error: { code: -32603, message: `Hub returned HTTP ${res.status}` },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
106
|
+
|
|
107
|
+
if (contentType.includes('text/event-stream')) {
|
|
108
|
+
// SSE response — parse events and forward each as a JSON-RPC message
|
|
109
|
+
const text = await res.text();
|
|
110
|
+
for (const payload of parseSseEvents(text)) {
|
|
111
|
+
try {
|
|
112
|
+
const parsed: unknown = JSON.parse(payload);
|
|
113
|
+
writeMessage(parsed);
|
|
114
|
+
} catch {
|
|
115
|
+
log(`Failed to parse SSE payload: ${payload.slice(0, 200)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
// JSON response — forward directly
|
|
120
|
+
const text = await res.text();
|
|
121
|
+
if (text) {
|
|
122
|
+
try {
|
|
123
|
+
const parsed: unknown = JSON.parse(text);
|
|
124
|
+
writeMessage(parsed);
|
|
125
|
+
} catch {
|
|
126
|
+
log(`Failed to parse JSON response: ${text.slice(0, 200)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Terminate the session cleanly. */
|
|
133
|
+
async close(): Promise<void> {
|
|
134
|
+
if (!this.sessionId) return;
|
|
135
|
+
try {
|
|
136
|
+
await fetch(this.mcpUrl, {
|
|
137
|
+
method: 'DELETE',
|
|
138
|
+
headers: {
|
|
139
|
+
'Mcp-Session-Id': this.sessionId,
|
|
140
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
} catch {
|
|
144
|
+
// Best-effort cleanup
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Main ───────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export async function startMcpBridge(): Promise<void> {
|
|
152
|
+
const config = loadConfig();
|
|
153
|
+
if (!config) {
|
|
154
|
+
log('Agent not enrolled. Run "sonde enroll" first.');
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!config.apiKey) {
|
|
159
|
+
log('No API key found in agent config. Re-enroll the agent with "sonde enroll".');
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const mcpUrl = `${config.hubUrl}/mcp`;
|
|
164
|
+
log(`Bridging stdio ↔ ${mcpUrl}`);
|
|
165
|
+
|
|
166
|
+
const transport = new McpHttpTransport({
|
|
167
|
+
mcpUrl,
|
|
168
|
+
apiKey: config.apiKey,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Read newline-delimited JSON-RPC from stdin
|
|
172
|
+
let buffer = '';
|
|
173
|
+
|
|
174
|
+
process.stdin.setEncoding('utf-8');
|
|
175
|
+
process.stdin.on('data', (chunk: string) => {
|
|
176
|
+
buffer += chunk;
|
|
177
|
+
// Process complete lines
|
|
178
|
+
let newlineIdx: number;
|
|
179
|
+
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
|
|
180
|
+
const line = buffer.slice(0, newlineIdx).trim();
|
|
181
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
182
|
+
if (!line) continue;
|
|
183
|
+
|
|
184
|
+
let message: unknown;
|
|
185
|
+
try {
|
|
186
|
+
message = JSON.parse(line);
|
|
187
|
+
} catch {
|
|
188
|
+
log(`Failed to parse stdin message: ${line.slice(0, 200)}`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Fire and forget — errors are handled inside send()
|
|
193
|
+
transport.send(message).catch((err: unknown) => {
|
|
194
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
195
|
+
log(`Transport error: ${errorMsg}`);
|
|
196
|
+
// Try to send a JSON-RPC error if we can extract the message id
|
|
197
|
+
const msg = message as { id?: string | number };
|
|
198
|
+
if (msg.id !== undefined) {
|
|
199
|
+
writeMessage({
|
|
200
|
+
jsonrpc: '2.0',
|
|
201
|
+
id: msg.id,
|
|
202
|
+
error: { code: -32603, message: `Bridge transport error: ${errorMsg}` },
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
process.stdin.on('end', async () => {
|
|
210
|
+
log('stdin closed, shutting down');
|
|
211
|
+
await transport.close();
|
|
212
|
+
process.exit(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Keep the process alive
|
|
216
|
+
process.stdin.resume();
|
|
217
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { checkForUpdate, semverLt } from './update.js';
|
|
3
|
+
|
|
4
|
+
describe('semverLt', () => {
|
|
5
|
+
it('returns true when a < b (major)', () => {
|
|
6
|
+
expect(semverLt('0.1.0', '1.0.0')).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('returns true when a < b (minor)', () => {
|
|
10
|
+
expect(semverLt('1.0.0', '1.1.0')).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns true when a < b (patch)', () => {
|
|
14
|
+
expect(semverLt('1.1.0', '1.1.1')).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns false when equal', () => {
|
|
18
|
+
expect(semverLt('1.2.3', '1.2.3')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns false when a > b', () => {
|
|
22
|
+
expect(semverLt('2.0.0', '1.9.9')).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('checkForUpdate', () => {
|
|
27
|
+
const originalFetch = globalThis.fetch;
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
globalThis.fetch = originalFetch;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns update info when newer version is available', async () => {
|
|
34
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
35
|
+
ok: true,
|
|
36
|
+
json: async () => ({ version: '99.0.0' }),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const result = await checkForUpdate();
|
|
40
|
+
expect(result.latestVersion).toBe('99.0.0');
|
|
41
|
+
expect(result.updateAvailable).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns no update when on latest version', async () => {
|
|
45
|
+
// Use 0.0.0 which is lower than any real version
|
|
46
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
47
|
+
ok: true,
|
|
48
|
+
json: async () => ({ version: '0.0.0' }),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const result = await checkForUpdate();
|
|
52
|
+
expect(result.updateAvailable).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('throws on network error', async () => {
|
|
56
|
+
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
|
|
57
|
+
|
|
58
|
+
await expect(checkForUpdate()).rejects.toThrow('Network error');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('throws on non-ok response', async () => {
|
|
62
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
63
|
+
ok: false,
|
|
64
|
+
status: 500,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await expect(checkForUpdate()).rejects.toThrow('Failed to check npm registry');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { VERSION } from '../version.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lightweight semver comparison: returns true if a < b.
|
|
6
|
+
*/
|
|
7
|
+
export function semverLt(a: string, b: string): boolean {
|
|
8
|
+
const pa = a.split('.').map(Number);
|
|
9
|
+
const pb = b.split('.').map(Number);
|
|
10
|
+
for (let i = 0; i < 3; i++) {
|
|
11
|
+
const av = pa[i] ?? 0;
|
|
12
|
+
const bv = pb[i] ?? 0;
|
|
13
|
+
if (av < bv) return true;
|
|
14
|
+
if (av > bv) return false;
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check for available updates by querying the npm registry.
|
|
21
|
+
*/
|
|
22
|
+
export async function checkForUpdate(): Promise<{
|
|
23
|
+
currentVersion: string;
|
|
24
|
+
latestVersion: string;
|
|
25
|
+
updateAvailable: boolean;
|
|
26
|
+
}> {
|
|
27
|
+
const res = await fetch('https://registry.npmjs.org/@sonde/agent/latest', {
|
|
28
|
+
headers: { Accept: 'application/json' },
|
|
29
|
+
signal: AbortSignal.timeout(10_000),
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
throw new Error(`Failed to check npm registry (HTTP ${res.status})`);
|
|
33
|
+
}
|
|
34
|
+
const data = (await res.json()) as { version?: string };
|
|
35
|
+
const latestVersion = data.version;
|
|
36
|
+
if (!latestVersion) {
|
|
37
|
+
throw new Error('No version found in npm registry response');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
currentVersion: VERSION,
|
|
42
|
+
latestVersion,
|
|
43
|
+
updateAvailable: semverLt(VERSION, latestVersion),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Perform the update by running npm install -g.
|
|
49
|
+
* After install, tries to restart the systemd service (best-effort).
|
|
50
|
+
*/
|
|
51
|
+
export function performUpdate(targetVersion: string): void {
|
|
52
|
+
console.log(`Installing @sonde/agent@${targetVersion}...`);
|
|
53
|
+
execFileSync('npm', ['install', '-g', `@sonde/agent@${targetVersion}`], {
|
|
54
|
+
stdio: 'inherit',
|
|
55
|
+
timeout: 120_000,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Verify the installed version
|
|
59
|
+
const output = execFileSync('sonde', ['--version'], {
|
|
60
|
+
encoding: 'utf-8',
|
|
61
|
+
timeout: 5_000,
|
|
62
|
+
}).trim();
|
|
63
|
+
if (output !== targetVersion) {
|
|
64
|
+
throw new Error(`Version mismatch after install: expected ${targetVersion}, got ${output}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log(`Successfully updated to v${targetVersion}`);
|
|
68
|
+
|
|
69
|
+
// Best-effort systemd restart
|
|
70
|
+
try {
|
|
71
|
+
execFileSync('systemctl', ['restart', 'sonde-agent'], {
|
|
72
|
+
timeout: 10_000,
|
|
73
|
+
});
|
|
74
|
+
console.log('Restarted sonde-agent systemd service');
|
|
75
|
+
} catch {
|
|
76
|
+
// systemd not available or service not installed — that's fine
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface AgentConfig {
|
|
|
12
12
|
keyPath?: string;
|
|
13
13
|
caCertPath?: string;
|
|
14
14
|
scrubPatterns?: string[];
|
|
15
|
+
allowUnsignedPacks?: boolean;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
const CONFIG_DIR = path.join(os.homedir(), '.sonde');
|
|
@@ -22,11 +23,24 @@ export function getConfigPath(): string {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export function loadConfig(): AgentConfig | undefined {
|
|
26
|
+
let raw: string;
|
|
27
|
+
try {
|
|
28
|
+
raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
29
|
+
} catch (err: unknown) {
|
|
30
|
+
const code = (err as { code?: string }).code;
|
|
31
|
+
if (code === 'ENOENT') return undefined;
|
|
32
|
+
if (code === 'EACCES') {
|
|
33
|
+
console.error(`Cannot read config at ${CONFIG_FILE}. Check file permissions.`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
try {
|
|
26
|
-
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
27
40
|
return JSON.parse(raw) as AgentConfig;
|
|
28
41
|
} catch {
|
|
29
|
-
|
|
42
|
+
console.error(`Config file corrupted at ${CONFIG_FILE}. Re-enroll with "sonde enroll".`);
|
|
43
|
+
process.exit(1);
|
|
30
44
|
}
|
|
31
45
|
}
|
|
32
46
|
|
package/src/index.ts
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { handlePacksCommand } from './cli/packs.js';
|
|
5
|
+
import { checkForUpdate, performUpdate } from './cli/update.js';
|
|
5
6
|
import { type AgentConfig, getConfigPath, loadConfig, saveConfig } from './config.js';
|
|
6
7
|
import { AgentConnection, type ConnectionEvents, enrollWithHub } from './runtime/connection.js';
|
|
7
8
|
import { ProbeExecutor } from './runtime/executor.js';
|
|
8
9
|
import { checkNotRoot } from './runtime/privilege.js';
|
|
9
10
|
import { buildPatterns } from './runtime/scrubber.js';
|
|
11
|
+
import { VERSION } from './version.js';
|
|
10
12
|
|
|
11
13
|
const args = process.argv.slice(2);
|
|
12
14
|
const command = args[0];
|
|
@@ -25,6 +27,8 @@ function printUsage(): void {
|
|
|
25
27
|
console.log(' start Start the agent (TUI by default, --headless for daemon)');
|
|
26
28
|
console.log(' status Show agent status');
|
|
27
29
|
console.log(' packs Manage packs (list, scan, install, uninstall)');
|
|
30
|
+
console.log(' update Check for and install agent updates');
|
|
31
|
+
console.log(' mcp-bridge stdio MCP bridge (for Claude Code integration)');
|
|
28
32
|
console.log('');
|
|
29
33
|
console.log('Enroll options:');
|
|
30
34
|
console.log(' --hub <url> Hub URL (e.g. http://localhost:3000)');
|
|
@@ -63,6 +67,21 @@ function createRuntime(events: ConnectionEvents): Runtime {
|
|
|
63
67
|
return { config, executor, connection };
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
async function cmdUpdate(): Promise<void> {
|
|
71
|
+
console.log(`Current version: v${VERSION}`);
|
|
72
|
+
console.log('Checking for updates...');
|
|
73
|
+
|
|
74
|
+
const { latestVersion, updateAvailable } = await checkForUpdate();
|
|
75
|
+
|
|
76
|
+
if (!updateAvailable) {
|
|
77
|
+
console.log(`Already on the latest version (v${latestVersion}).`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
console.log(`New version available: v${latestVersion}`);
|
|
82
|
+
performUpdate(latestVersion);
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
async function cmdEnroll(): Promise<void> {
|
|
67
86
|
const hubUrl = getArg('--hub');
|
|
68
87
|
const apiKey = getArg('--key');
|
|
@@ -128,20 +147,28 @@ function cmdStart(): void {
|
|
|
128
147
|
config.agentId = agentId;
|
|
129
148
|
saveConfig(config);
|
|
130
149
|
},
|
|
150
|
+
onUpdateAvailable: (latestVersion, currentVersion) => {
|
|
151
|
+
console.log(
|
|
152
|
+
`Update available: v${currentVersion} → v${latestVersion}. Run "sonde update" to upgrade.`,
|
|
153
|
+
);
|
|
154
|
+
},
|
|
131
155
|
});
|
|
132
156
|
|
|
133
|
-
console.log(
|
|
157
|
+
console.log(`Sonde Agent v${VERSION}`);
|
|
134
158
|
console.log(` Name: ${config.agentName}`);
|
|
135
159
|
console.log(` Hub: ${config.hubUrl}`);
|
|
136
160
|
console.log('');
|
|
137
161
|
|
|
138
162
|
connection.start();
|
|
163
|
+
process.stdin.unref();
|
|
139
164
|
|
|
140
|
-
|
|
165
|
+
const shutdown = () => {
|
|
141
166
|
console.log('\nShutting down...');
|
|
142
167
|
connection.stop();
|
|
143
168
|
process.exit(0);
|
|
144
|
-
}
|
|
169
|
+
};
|
|
170
|
+
process.on('SIGINT', shutdown);
|
|
171
|
+
process.on('SIGTERM', shutdown);
|
|
145
172
|
}
|
|
146
173
|
|
|
147
174
|
async function cmdManager(): Promise<void> {
|
|
@@ -176,6 +203,11 @@ async function cmdInstall(): Promise<void> {
|
|
|
176
203
|
await waitUntilExit();
|
|
177
204
|
}
|
|
178
205
|
|
|
206
|
+
if (command === '--version' || command === '-v' || hasFlag('--version')) {
|
|
207
|
+
console.log(VERSION);
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}
|
|
210
|
+
|
|
179
211
|
switch (command) {
|
|
180
212
|
case 'install':
|
|
181
213
|
cmdInstall().catch((err: Error) => {
|
|
@@ -184,8 +216,17 @@ switch (command) {
|
|
|
184
216
|
});
|
|
185
217
|
break;
|
|
186
218
|
case 'enroll':
|
|
187
|
-
cmdEnroll().catch((err: Error) => {
|
|
188
|
-
|
|
219
|
+
cmdEnroll().catch((err: Error & { code?: string }) => {
|
|
220
|
+
if (err.code === 'ECONNREFUSED') {
|
|
221
|
+
const hubUrl = getArg('--hub') ?? 'the hub';
|
|
222
|
+
console.error(`Could not connect to hub at ${hubUrl}. Verify the hub is running.`);
|
|
223
|
+
} else if (err.message?.includes('401') || err.message?.includes('Unauthorized')) {
|
|
224
|
+
console.error('Authentication failed. Check your API key or enrollment token.');
|
|
225
|
+
} else if (err.message?.includes('timed out')) {
|
|
226
|
+
console.error('Enrollment timed out. The hub may be unreachable.');
|
|
227
|
+
} else {
|
|
228
|
+
console.error(`Enrollment failed: ${err.message}`);
|
|
229
|
+
}
|
|
189
230
|
process.exit(1);
|
|
190
231
|
});
|
|
191
232
|
break;
|
|
@@ -193,8 +234,14 @@ switch (command) {
|
|
|
193
234
|
if (hasFlag('--headless')) {
|
|
194
235
|
cmdStart();
|
|
195
236
|
} else {
|
|
196
|
-
cmdManager().catch((err: Error) => {
|
|
197
|
-
|
|
237
|
+
cmdManager().catch((err: Error & { code?: string }) => {
|
|
238
|
+
if (err.code === 'ECONNREFUSED') {
|
|
239
|
+
console.error(
|
|
240
|
+
'Could not connect to hub. Verify the hub is running and the URL is correct.',
|
|
241
|
+
);
|
|
242
|
+
} else {
|
|
243
|
+
console.error(err.message);
|
|
244
|
+
}
|
|
198
245
|
process.exit(1);
|
|
199
246
|
});
|
|
200
247
|
}
|
|
@@ -205,10 +252,28 @@ switch (command) {
|
|
|
205
252
|
case 'packs':
|
|
206
253
|
handlePacksCommand(args.slice(1));
|
|
207
254
|
break;
|
|
255
|
+
case 'update':
|
|
256
|
+
cmdUpdate().catch((err: Error) => {
|
|
257
|
+
console.error(`Update failed: ${err.message}`);
|
|
258
|
+
process.exit(1);
|
|
259
|
+
});
|
|
260
|
+
break;
|
|
261
|
+
case 'mcp-bridge':
|
|
262
|
+
import('./cli/mcp-bridge.js').then(({ startMcpBridge }) =>
|
|
263
|
+
startMcpBridge().catch((err: Error) => {
|
|
264
|
+
process.stderr.write(`[sonde-bridge] Fatal: ${err.message}\n`);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
break;
|
|
208
269
|
default:
|
|
209
270
|
if (command) {
|
|
210
271
|
printUsage();
|
|
211
|
-
|
|
272
|
+
if (command.startsWith('--')) {
|
|
273
|
+
console.error(`\nUnknown flag: ${command}. Did you mean "sonde start ${command}"?`);
|
|
274
|
+
} else {
|
|
275
|
+
console.error(`\nUnknown command: ${command}`);
|
|
276
|
+
}
|
|
212
277
|
process.exit(1);
|
|
213
278
|
} else {
|
|
214
279
|
// No command: launch TUI if enrolled, otherwise show usage
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import WebSocket from 'ws';
|
|
12
12
|
import type { AgentConfig } from '../config.js';
|
|
13
13
|
import { saveCerts } from '../config.js';
|
|
14
|
+
import { VERSION } from '../version.js';
|
|
14
15
|
import { generateAttestation } from './attestation.js';
|
|
15
16
|
import { AgentAuditLog } from './audit.js';
|
|
16
17
|
import type { ProbeExecutor } from './executor.js';
|
|
@@ -21,6 +22,7 @@ export interface ConnectionEvents {
|
|
|
21
22
|
onError?: (error: Error) => void;
|
|
22
23
|
onRegistered?: (agentId: string) => void;
|
|
23
24
|
onProbeCompleted?: (probe: string, status: string, durationMs: number) => void;
|
|
25
|
+
onUpdateAvailable?: (latestVersion: string, currentVersion: string) => void;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
/** Minimum/maximum reconnect delays */
|
|
@@ -56,7 +58,7 @@ export function enrollWithHub(
|
|
|
56
58
|
const payload: Record<string, unknown> = {
|
|
57
59
|
name: config.agentName,
|
|
58
60
|
os: `${process.platform} ${process.arch}`,
|
|
59
|
-
agentVersion:
|
|
61
|
+
agentVersion: VERSION,
|
|
60
62
|
packs: executor.getLoadedPacks(),
|
|
61
63
|
attestation: generateAttestation(config, executor),
|
|
62
64
|
};
|
|
@@ -118,13 +120,27 @@ export function enrollWithHub(
|
|
|
118
120
|
}
|
|
119
121
|
});
|
|
120
122
|
|
|
121
|
-
ws.on('error', (err) => {
|
|
123
|
+
ws.on('error', (err: Error & { code?: string }) => {
|
|
122
124
|
clearTimeout(timeout);
|
|
123
|
-
reject(err);
|
|
125
|
+
reject(new Error(humanizeWsError(err, config.hubUrl)));
|
|
124
126
|
});
|
|
125
127
|
});
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
/** Map low-level WebSocket/network error codes to actionable messages. */
|
|
131
|
+
function humanizeWsError(err: Error & { code?: string }, hubUrl: string): string {
|
|
132
|
+
switch (err.code) {
|
|
133
|
+
case 'ECONNREFUSED':
|
|
134
|
+
return `Could not connect to hub at ${hubUrl}. Verify the hub is running.`;
|
|
135
|
+
case 'ENOTFOUND':
|
|
136
|
+
return `Hub hostname not found: ${hubUrl}. Check the URL.`;
|
|
137
|
+
case 'ETIMEDOUT':
|
|
138
|
+
return `Connection to hub at ${hubUrl} timed out. The hub may be unreachable.`;
|
|
139
|
+
default:
|
|
140
|
+
return err.message;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
128
144
|
/** Build WebSocket options, including TLS client cert if available. */
|
|
129
145
|
function buildWsOptions(config: AgentConfig): WebSocket.ClientOptions {
|
|
130
146
|
const options: WebSocket.ClientOptions = {
|
|
@@ -138,7 +154,7 @@ function buildWsOptions(config: AgentConfig): WebSocket.ClientOptions {
|
|
|
138
154
|
options.cert = fs.readFileSync(config.certPath, 'utf-8');
|
|
139
155
|
options.key = fs.readFileSync(config.keyPath, 'utf-8');
|
|
140
156
|
options.ca = [fs.readFileSync(config.caCertPath, 'utf-8')];
|
|
141
|
-
options.rejectUnauthorized =
|
|
157
|
+
options.rejectUnauthorized = true; // Verify hub cert against our CA
|
|
142
158
|
} catch {
|
|
143
159
|
// Cert files missing or unreadable — fall back to API key only
|
|
144
160
|
}
|
|
@@ -223,16 +239,21 @@ export class AgentConnection {
|
|
|
223
239
|
this.handleMessage(data.toString());
|
|
224
240
|
});
|
|
225
241
|
|
|
226
|
-
this.ws.on('close', () => {
|
|
242
|
+
this.ws.on('close', (code) => {
|
|
227
243
|
this.clearTimers();
|
|
244
|
+
if (code === 4001) {
|
|
245
|
+
this.events.onError?.(
|
|
246
|
+
new Error("Authentication rejected by hub. Run 'sonde enroll' to re-authenticate."),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
228
249
|
this.events.onDisconnected?.();
|
|
229
250
|
if (this.running) {
|
|
230
251
|
this.scheduleReconnect();
|
|
231
252
|
}
|
|
232
253
|
});
|
|
233
254
|
|
|
234
|
-
this.ws.on('error', (err) => {
|
|
235
|
-
this.events.onError?.(err);
|
|
255
|
+
this.ws.on('error', (err: Error & { code?: string }) => {
|
|
256
|
+
this.events.onError?.(new Error(humanizeWsError(err, this.config.hubUrl)));
|
|
236
257
|
});
|
|
237
258
|
}
|
|
238
259
|
|
|
@@ -260,6 +281,14 @@ export class AgentConnection {
|
|
|
260
281
|
case 'probe.request':
|
|
261
282
|
this.handleProbeRequest(envelope);
|
|
262
283
|
break;
|
|
284
|
+
case 'hub.update_available': {
|
|
285
|
+
const updatePayload = envelope.payload as {
|
|
286
|
+
latestVersion: string;
|
|
287
|
+
currentVersion: string;
|
|
288
|
+
};
|
|
289
|
+
this.events.onUpdateAvailable?.(updatePayload.latestVersion, updatePayload.currentVersion);
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
263
292
|
default:
|
|
264
293
|
break;
|
|
265
294
|
}
|
|
@@ -287,6 +316,11 @@ export class AgentConnection {
|
|
|
287
316
|
|
|
288
317
|
const response = await this.executor.execute(request);
|
|
289
318
|
|
|
319
|
+
// Echo back requestId for concurrent probe correlation
|
|
320
|
+
if (request.requestId) {
|
|
321
|
+
response.requestId = request.requestId;
|
|
322
|
+
}
|
|
323
|
+
|
|
290
324
|
this.auditLog.log(request.probe, response.status, response.durationMs);
|
|
291
325
|
this.events.onProbeCompleted?.(request.probe, response.status, response.durationMs);
|
|
292
326
|
|
|
@@ -314,7 +348,7 @@ export class AgentConnection {
|
|
|
314
348
|
payload: {
|
|
315
349
|
name: this.config.agentName,
|
|
316
350
|
os: `${process.platform} ${process.arch}`,
|
|
317
|
-
agentVersion:
|
|
351
|
+
agentVersion: VERSION,
|
|
318
352
|
packs: this.executor.getLoadedPacks(),
|
|
319
353
|
attestation: generateAttestation(this.config, this.executor),
|
|
320
354
|
},
|
|
@@ -349,7 +383,7 @@ export class AgentConnection {
|
|
|
349
383
|
data: { error: message },
|
|
350
384
|
durationMs: 0,
|
|
351
385
|
metadata: {
|
|
352
|
-
agentVersion:
|
|
386
|
+
agentVersion: VERSION,
|
|
353
387
|
packName: 'unknown',
|
|
354
388
|
packVersion: '0.0.0',
|
|
355
389
|
capabilityLevel: 'observe',
|