@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.
Files changed (50) hide show
  1. package/.turbo/turbo-build.log +6 -4
  2. package/.turbo/turbo-test.log +66 -21
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/CHANGELOG.md +16 -0
  5. package/dist/cli/mcp-bridge.d.ts +2 -0
  6. package/dist/cli/mcp-bridge.d.ts.map +1 -0
  7. package/dist/cli/mcp-bridge.js +193 -0
  8. package/dist/cli/mcp-bridge.js.map +1 -0
  9. package/dist/cli/mcp-bridge.test.d.ts +2 -0
  10. package/dist/cli/mcp-bridge.test.d.ts.map +1 -0
  11. package/dist/cli/mcp-bridge.test.js +54 -0
  12. package/dist/cli/mcp-bridge.test.js.map +1 -0
  13. package/dist/cli/update.d.ts +18 -0
  14. package/dist/cli/update.d.ts.map +1 -0
  15. package/dist/cli/update.js +71 -0
  16. package/dist/cli/update.js.map +1 -0
  17. package/dist/cli/update.test.d.ts +2 -0
  18. package/dist/cli/update.test.d.ts.map +1 -0
  19. package/dist/cli/update.test.js +55 -0
  20. package/dist/cli/update.test.js.map +1 -0
  21. package/dist/config.d.ts +1 -0
  22. package/dist/config.d.ts.map +1 -1
  23. package/dist/config.js +16 -2
  24. package/dist/config.js.map +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +65 -6
  27. package/dist/index.js.map +1 -1
  28. package/dist/runtime/connection.d.ts +1 -0
  29. package/dist/runtime/connection.d.ts.map +1 -1
  30. package/dist/runtime/connection.js +33 -7
  31. package/dist/runtime/connection.js.map +1 -1
  32. package/dist/runtime/executor.d.ts +6 -0
  33. package/dist/runtime/executor.d.ts.map +1 -1
  34. package/dist/runtime/executor.js +4 -4
  35. package/dist/runtime/executor.js.map +1 -1
  36. package/dist/version.d.ts +2 -0
  37. package/dist/version.d.ts.map +1 -0
  38. package/dist/version.js +7 -0
  39. package/dist/version.js.map +1 -0
  40. package/package.json +1 -1
  41. package/src/cli/mcp-bridge.test.ts +58 -0
  42. package/src/cli/mcp-bridge.ts +217 -0
  43. package/src/cli/update.test.ts +69 -0
  44. package/src/cli/update.ts +78 -0
  45. package/src/config.ts +16 -2
  46. package/src/index.ts +73 -8
  47. package/src/runtime/connection.ts +43 -9
  48. package/src/runtime/executor.ts +17 -5
  49. package/src/version.ts +10 -0
  50. 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
- return undefined;
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('Sonde Agent v0.1.0');
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
- process.on('SIGINT', () => {
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
- console.error(`Enrollment failed: ${err.message}`);
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
- console.error(err.message);
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
- console.error(`\nUnknown command: ${command}`);
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: '0.1.0',
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 = false; // Hub uses self-signed CA cert
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: '0.1.0',
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: '0.1.0',
386
+ agentVersion: VERSION,
353
387
  packName: 'unknown',
354
388
  packVersion: '0.0.0',
355
389
  capabilityLevel: 'observe',