@sonde/agent 0.2.3 → 0.2.5

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 (45) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +14 -12
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/CHANGELOG.md +20 -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 +59 -5
  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 +30 -5
  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 +2 -2
  35. package/dist/runtime/executor.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/cli/mcp-bridge.test.ts +58 -0
  38. package/src/cli/mcp-bridge.ts +217 -0
  39. package/src/cli/update.test.ts +69 -0
  40. package/src/cli/update.ts +78 -0
  41. package/src/config.ts +16 -2
  42. package/src/index.ts +66 -7
  43. package/src/runtime/connection.ts +39 -6
  44. package/src/runtime/executor.ts +14 -1
  45. package/tsconfig.tsbuildinfo +1 -1
@@ -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,6 +2,7 @@
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';
@@ -26,6 +27,8 @@ function printUsage(): void {
26
27
  console.log(' start Start the agent (TUI by default, --headless for daemon)');
27
28
  console.log(' status Show agent status');
28
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)');
29
32
  console.log('');
30
33
  console.log('Enroll options:');
31
34
  console.log(' --hub <url> Hub URL (e.g. http://localhost:3000)');
@@ -64,6 +67,21 @@ function createRuntime(events: ConnectionEvents): Runtime {
64
67
  return { config, executor, connection };
65
68
  }
66
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
+
67
85
  async function cmdEnroll(): Promise<void> {
68
86
  const hubUrl = getArg('--hub');
69
87
  const apiKey = getArg('--key');
@@ -129,6 +147,11 @@ function cmdStart(): void {
129
147
  config.agentId = agentId;
130
148
  saveConfig(config);
131
149
  },
150
+ onUpdateAvailable: (latestVersion, currentVersion) => {
151
+ console.log(
152
+ `Update available: v${currentVersion} → v${latestVersion}. Run "sonde update" to upgrade.`,
153
+ );
154
+ },
132
155
  });
133
156
 
134
157
  console.log(`Sonde Agent v${VERSION}`);
@@ -137,12 +160,15 @@ function cmdStart(): void {
137
160
  console.log('');
138
161
 
139
162
  connection.start();
163
+ process.stdin.unref();
140
164
 
141
- process.on('SIGINT', () => {
165
+ const shutdown = () => {
142
166
  console.log('\nShutting down...');
143
167
  connection.stop();
144
168
  process.exit(0);
145
- });
169
+ };
170
+ process.on('SIGINT', shutdown);
171
+ process.on('SIGTERM', shutdown);
146
172
  }
147
173
 
148
174
  async function cmdManager(): Promise<void> {
@@ -190,8 +216,17 @@ switch (command) {
190
216
  });
191
217
  break;
192
218
  case 'enroll':
193
- cmdEnroll().catch((err: Error) => {
194
- 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
+ }
195
230
  process.exit(1);
196
231
  });
197
232
  break;
@@ -199,8 +234,14 @@ switch (command) {
199
234
  if (hasFlag('--headless')) {
200
235
  cmdStart();
201
236
  } else {
202
- cmdManager().catch((err: Error) => {
203
- 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
+ }
204
245
  process.exit(1);
205
246
  });
206
247
  }
@@ -211,10 +252,28 @@ switch (command) {
211
252
  case 'packs':
212
253
  handlePacksCommand(args.slice(1));
213
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;
214
269
  default:
215
270
  if (command) {
216
271
  printUsage();
217
- 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
+ }
218
277
  process.exit(1);
219
278
  } else {
220
279
  // No command: launch TUI if enrolled, otherwise show usage
@@ -22,6 +22,7 @@ export interface ConnectionEvents {
22
22
  onError?: (error: Error) => void;
23
23
  onRegistered?: (agentId: string) => void;
24
24
  onProbeCompleted?: (probe: string, status: string, durationMs: number) => void;
25
+ onUpdateAvailable?: (latestVersion: string, currentVersion: string) => void;
25
26
  }
26
27
 
27
28
  /** Minimum/maximum reconnect delays */
@@ -119,13 +120,27 @@ export function enrollWithHub(
119
120
  }
120
121
  });
121
122
 
122
- ws.on('error', (err) => {
123
+ ws.on('error', (err: Error & { code?: string }) => {
123
124
  clearTimeout(timeout);
124
- reject(err);
125
+ reject(new Error(humanizeWsError(err, config.hubUrl)));
125
126
  });
126
127
  });
127
128
  }
128
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
+
129
144
  /** Build WebSocket options, including TLS client cert if available. */
130
145
  function buildWsOptions(config: AgentConfig): WebSocket.ClientOptions {
131
146
  const options: WebSocket.ClientOptions = {
@@ -139,7 +154,7 @@ function buildWsOptions(config: AgentConfig): WebSocket.ClientOptions {
139
154
  options.cert = fs.readFileSync(config.certPath, 'utf-8');
140
155
  options.key = fs.readFileSync(config.keyPath, 'utf-8');
141
156
  options.ca = [fs.readFileSync(config.caCertPath, 'utf-8')];
142
- options.rejectUnauthorized = false; // Hub uses self-signed CA cert
157
+ options.rejectUnauthorized = true; // Verify hub cert against our CA
143
158
  } catch {
144
159
  // Cert files missing or unreadable — fall back to API key only
145
160
  }
@@ -224,16 +239,21 @@ export class AgentConnection {
224
239
  this.handleMessage(data.toString());
225
240
  });
226
241
 
227
- this.ws.on('close', () => {
242
+ this.ws.on('close', (code) => {
228
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
+ }
229
249
  this.events.onDisconnected?.();
230
250
  if (this.running) {
231
251
  this.scheduleReconnect();
232
252
  }
233
253
  });
234
254
 
235
- this.ws.on('error', (err) => {
236
- this.events.onError?.(err);
255
+ this.ws.on('error', (err: Error & { code?: string }) => {
256
+ this.events.onError?.(new Error(humanizeWsError(err, this.config.hubUrl)));
237
257
  });
238
258
  }
239
259
 
@@ -261,6 +281,14 @@ export class AgentConnection {
261
281
  case 'probe.request':
262
282
  this.handleProbeRequest(envelope);
263
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
+ }
264
292
  default:
265
293
  break;
266
294
  }
@@ -288,6 +316,11 @@ export class AgentConnection {
288
316
 
289
317
  const response = await this.executor.execute(request);
290
318
 
319
+ // Echo back requestId for concurrent probe correlation
320
+ if (request.requestId) {
321
+ response.requestId = request.requestId;
322
+ }
323
+
291
324
  this.auditLog.log(request.probe, response.status, response.durationMs);
292
325
  this.events.onProbeCompleted?.(request.probe, response.status, response.durationMs);
293
326
 
@@ -1,6 +1,12 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
- import { type ExecFn, type Pack, packRegistry } from '@sonde/packs';
3
+ import {
4
+ type ExecFn,
5
+ type Pack,
6
+ type PackRegistryOptions,
7
+ createPackRegistry,
8
+ packRegistry,
9
+ } from '@sonde/packs';
4
10
  import type { ProbeRequest, ProbeResponse } from '@sonde/shared';
5
11
  import { VERSION } from '../version.js';
6
12
  import { type ScrubPattern, buildPatterns, scrubData } from './scrubber.js';
@@ -16,6 +22,13 @@ async function defaultExec(command: string, args: string[]): Promise<string> {
16
22
  return stdout;
17
23
  }
18
24
 
25
+ export interface ProbeExecutorOptions {
26
+ packs?: ReadonlyMap<string, Pack>;
27
+ exec?: ExecFn;
28
+ scrubPatterns?: ScrubPattern[];
29
+ allowUnsignedPacks?: boolean;
30
+ }
31
+
19
32
  export class ProbeExecutor {
20
33
  private packs: ReadonlyMap<string, Pack>;
21
34
  private exec: ExecFn;