File size: 5,954 Bytes
4360e71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dfd5fc7
4360e71
 
 
 
dfd5fc7
 
 
 
4360e71
 
 
 
 
 
 
 
 
dfd5fc7
4360e71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fce80d1
4360e71
 
 
 
 
 
 
 
 
dfd5fc7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7323d61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4360e71
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
// Control-channel tests: reconnect backoff, queue flush, terminal close codes.
// Run: node --test test/control.test.mjs

import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { WebSocketServer } from 'ws';

const server = new WebSocketServer({ port: 0 });
const port = server.address().port;
const url = `ws://127.0.0.1:${port}/ws?role=device`;

// ControlChannel reads CLOUD.wsUrl at import time — import after env is set.
process.env.MONA_CLOUD_WS = url;
const { ControlChannel } = await import('../src/control.js');

let connections = 0;
let closeCode = null;
let lastHello = null;
server.on('connection', (ws) => {
  connections++;
  ws.on('message', (raw) => {
    const msg = JSON.parse(raw.toString());
    if (msg.type === 'hello') {
      lastHello = msg;
      ws.send(JSON.stringify({ type: 'pong', data: {} }));
    }
  });
  if (closeCode !== null) {
    // 1006 is reserved and can never be sent as a close frame — terminate()
    // is what produces a real 1006 (abnormal closure) on the client side.
    if (closeCode === 1006) ws.terminate();
    else ws.close(closeCode);
  }
});

const reset = () => { connections = 0; lastHello = null; };

// terminate() produces an ECONNRESET on the client — expected in the 1006 test.
const quiet = (ch) => ch.on('error', () => {});

describe('control channel', () => {
  before(() => { server.on('connection', () => {}); });
  after(() => server.close());

  it('emits auth-failed and stops on terminal close code 4001', async () => {
    reset();
    closeCode = 4001;
    const ch = new ControlChannel('test-key', 'agent-1');
    const events = [];
    quiet(ch);
    ch.on('auth-failed', (code) => events.push(['auth-failed', code]));

    await new Promise((resolve) => {
      ch.on('disconnected', () => setTimeout(resolve, 100));
      ch.connect();
    });

    assert.equal(events.length, 1);
    assert.equal(events[0][1], 4001);
    assert.equal(ch.stopped, true);

    // No reconnect loop: allow one backoff cycle, assert no new connection.
    await new Promise((r) => setTimeout(r, 1500));
    assert.equal(connections, 1);
    ch.close();
  });

  it('emits auth-failed and stops on terminal close code 4003 (revoked)', async () => {
    reset();
    closeCode = 4003;
    const ch = new ControlChannel('test-key', 'agent-1');
    let failed = false;
    quiet(ch);
    ch.on('auth-failed', () => { failed = true; });

    await new Promise((resolve) => {
      ch.on('disconnected', () => setTimeout(resolve, 100));
      ch.connect();
    });

    assert.equal(failed, true);
    assert.equal(ch.stopped, true);
    await new Promise((r) => setTimeout(r, 1500));
    assert.equal(connections, 1);
    ch.close();
  });

  it('reconnects with backoff after a transient close (1006)', async () => {
    reset();
    closeCode = 1006;
    const ch = new ControlChannel('test-key', 'agent-1');
    quiet(ch);

    // A second disconnect can only happen after a reconnect — wait for it.
    let disconnects = 0;
    const reconnected = new Promise((resolve, reject) => {
      ch.on('disconnected', () => {
        if (++disconnects >= 2) resolve(true);
      });
      ch.connect();
      setTimeout(() => reject(new Error('no reconnect within 5s')), 5000);
    });

    assert.equal(await reconnected, true);
    assert.ok(connections >= 2, `expected a reconnect, saw ${connections} connection(s)`);
    closeCode = null;
    ch.close();
  });

  it('queues messages until open, then flushes', async () => {
    reset();
    closeCode = null;
    const ch = new ControlChannel('test-key', 'agent-1');
    let flushed = false;
    quiet(ch);
    ch.connect();
    ch.step('test', { n: 1 }); // sent before open  queued

    await new Promise((resolve) => {
      const timer = setInterval(() => {
        if (connections === 1) { flushed = true; clearInterval(timer); resolve(); }
      }, 50);
    });
    assert.equal(flushed, true);
    ch.close();
  });

  it('sends v:1 envelope and announces capabilities in hello', async () => {
    reset();
    closeCode = null;
    const caps = {
      tools: [{ name: 'sysinfo', description: 'System information' }],
      shell: { allowlist: ['df', 'uptime'], unsafe: false, platform: 'darwin' },
    };
    const ch = new ControlChannel('test-key', 'agent-1', caps);
    quiet(ch);
    ch.connect();

    await new Promise((resolve) => {
      const timer = setInterval(() => {
        if (lastHello) { clearInterval(timer); resolve(); }
      }, 25);
    });

    assert.equal(lastHello.v, 1);
    assert.equal(lastHello.agentId, 'agent-1');
    assert.ok(Array.isArray(lastHello.data.capabilities.tools));
    assert.equal(lastHello.data.capabilities.tools[0].name, 'sysinfo');
    assert.deepEqual(lastHello.data.capabilities.shell.allowlist, ['df', 'uptime']);
    assert.ok(lastHello.data.host);
    assert.ok(lastHello.data.version);
    ch.close();
  });

  it('streams extended metrics (cpu/mem/disk percent)', async () => {
    reset();
    closeCode = null;
    const ch = new ControlChannel('test-key', 'agent-1', null, { metricsIntervalMs: 120 });
    quiet(ch);
    const got = new Promise((resolve) => ch.on('metrics', resolve));
    ch.connect();

    const m = await Promise.race([
      got,
      new Promise((_, reject) => setTimeout(() => reject(new Error('no metrics within 3s')), 3000)),
    ]);

    assert.equal(typeof m.cpuPercent, 'number');
    assert.ok(m.cpuPercent >= 0 && m.cpuPercent <= 100);
    assert.ok(m.mem.percent >= 0 && m.mem.percent <= 100);
    assert.equal(typeof m.mem.used, 'number');
    assert.equal(typeof m.diskPercent, 'number');
    assert.equal(typeof m.uptimeSeconds, 'number');
    assert.ok(m.cpuModel);
    // Backward-compatible fields the TUI depends on:
    assert.equal(typeof m.mem.total, 'number');
    assert.equal(typeof m.mem.free, 'number');
    assert.ok(Array.isArray(m.cpuLoad));
    ch.close();
  });
});