Mona commited on
Commit
dfd5fc7
Β·
1 Parent(s): 4360e71

feat: versioned WS envelope (v:1) + capabilities in hello

Browse files

Every control-channel message now carries a protocol version field so
the gateway can evolve the wire format without guessing. The hello
handshake announces device capabilities (tool list with descriptions,
shell allowlist/unsafe flag, platform) so the control plane can enforce
agent_permissions without probing the device.

- control.js: #send adds v:1; hello includes capabilities
- agent.js: passes tools.list() + shell security posture
- shell.js: exports security { allowlist, unsafe, platform }
- tests: hello envelope shape + capabilities

Files changed (4) hide show
  1. src/agent.js +5 -1
  2. src/control.js +6 -2
  3. src/tools/shell.js +8 -0
  4. test/control.test.mjs +33 -2
src/agent.js CHANGED
@@ -7,6 +7,7 @@ import { EventEmitter } from 'node:events';
7
  import { think } from './cloud.js';
8
  import { ControlChannel } from './control.js';
9
  import { tools } from './tools/index.js';
 
10
  import { log } from './log.js';
11
 
12
  export class AgentDaemon extends EventEmitter {
@@ -20,7 +21,10 @@ export class AgentDaemon extends EventEmitter {
20
  super();
21
  this.#creds = creds;
22
 
23
- this.#control = new ControlChannel(creds.apiKey, creds.agentId);
 
 
 
24
 
25
  // Forward control events
26
  this.#control.on('connected', () => this.emit('connected'));
 
7
  import { think } from './cloud.js';
8
  import { ControlChannel } from './control.js';
9
  import { tools } from './tools/index.js';
10
+ import { security as shellSecurity } from './tools/shell.js';
11
  import { log } from './log.js';
12
 
13
  export class AgentDaemon extends EventEmitter {
 
21
  super();
22
  this.#creds = creds;
23
 
24
+ this.#control = new ControlChannel(creds.apiKey, creds.agentId, {
25
+ tools: tools.list(),
26
+ shell: shellSecurity,
27
+ });
28
 
29
  // Forward control events
30
  this.#control.on('connected', () => this.emit('connected'));
src/control.js CHANGED
@@ -20,6 +20,7 @@ const TERMINAL_CLOSE_CODES = new Set([4001, 4003]);
20
  export class ControlChannel extends EventEmitter {
21
  #apiKey;
22
  #agentId;
 
23
  #ws = null;
24
  #queue = [];
25
  #metricsTimer = null;
@@ -28,10 +29,11 @@ export class ControlChannel extends EventEmitter {
28
  #closing = false;
29
  #stopped = false;
30
 
31
- constructor(apiKey, agentId) {
32
  super();
33
  this.#apiKey = apiKey;
34
  this.#agentId = agentId;
 
35
  }
36
 
37
  /** Connect (or reconnect) to the cloud. Returns this for chaining. */
@@ -61,6 +63,7 @@ export class ControlChannel extends EventEmitter {
61
  cpus: os.cpus().length,
62
  mem: os.totalmem(),
63
  version: DEFAULTS.version,
 
64
  });
65
  this.#flush();
66
  this.#startMetrics();
@@ -109,9 +112,10 @@ export class ControlChannel extends EventEmitter {
109
  return this;
110
  }
111
 
112
- /** Send a typed message upstream. */
113
  #send(type, data) {
114
  const msg = JSON.stringify({
 
115
  type,
116
  ts: Date.now(),
117
  agentId: this.#agentId,
 
20
  export class ControlChannel extends EventEmitter {
21
  #apiKey;
22
  #agentId;
23
+ #capabilities;
24
  #ws = null;
25
  #queue = [];
26
  #metricsTimer = null;
 
29
  #closing = false;
30
  #stopped = false;
31
 
32
+ constructor(apiKey, agentId, capabilities = null) {
33
  super();
34
  this.#apiKey = apiKey;
35
  this.#agentId = agentId;
36
+ this.#capabilities = capabilities;
37
  }
38
 
39
  /** Connect (or reconnect) to the cloud. Returns this for chaining. */
 
63
  cpus: os.cpus().length,
64
  mem: os.totalmem(),
65
  version: DEFAULTS.version,
66
+ capabilities: this.#capabilities,
67
  });
68
  this.#flush();
69
  this.#startMetrics();
 
112
  return this;
113
  }
114
 
115
+ /** Send a typed message upstream. Every envelope carries a protocol version. */
116
  #send(type, data) {
117
  const msg = JSON.stringify({
118
+ v: 1,
119
  type,
120
  ts: Date.now(),
121
  agentId: this.#agentId,
src/tools/shell.js CHANGED
@@ -42,6 +42,14 @@ const ALLOW = new Set(
42
  );
43
  const UNSAFE = process.env.MONA_SHELL_UNSAFE === '1';
44
 
 
 
 
 
 
 
 
 
45
  // ── Per-OS command mapping (translate common unix β†’ windows) ──────
46
  const CMD_MAP_WIN32 = {
47
  ls: 'dir',
 
42
  );
43
  const UNSAFE = process.env.MONA_SHELL_UNSAFE === '1';
44
 
45
+ /** Shell security posture β€” advertised to the cloud in `hello` so the
46
+ * control plane can enforce agent_permissions without probing. */
47
+ export const security = {
48
+ allowlist: [...ALLOW].sort(),
49
+ unsafe: UNSAFE,
50
+ platform: PLATFORM,
51
+ };
52
+
53
  // ── Per-OS command mapping (translate common unix β†’ windows) ──────
54
  const CMD_MAP_WIN32 = {
55
  ls: 'dir',
test/control.test.mjs CHANGED
@@ -15,11 +15,15 @@ const { ControlChannel } = await import('../src/control.js');
15
 
16
  let connections = 0;
17
  let closeCode = null;
 
18
  server.on('connection', (ws) => {
19
  connections++;
20
  ws.on('message', (raw) => {
21
  const msg = JSON.parse(raw.toString());
22
- if (msg.type === 'hello') ws.send(JSON.stringify({ type: 'pong', data: {} }));
 
 
 
23
  });
24
  if (closeCode !== null) {
25
  // 1006 is reserved and can never be sent as a close frame β€” terminate()
@@ -29,7 +33,7 @@ server.on('connection', (ws) => {
29
  }
30
  });
31
 
32
- const reset = () => { connections = 0; };
33
 
34
  // terminate() produces an ECONNRESET on the client β€” expected in the 1006 test.
35
  const quiet = (ch) => ch.on('error', () => {});
@@ -120,4 +124,31 @@ describe('control channel', () => {
120
  assert.equal(flushed, true);
121
  ch.close();
122
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  });
 
15
 
16
  let connections = 0;
17
  let closeCode = null;
18
+ let lastHello = null;
19
  server.on('connection', (ws) => {
20
  connections++;
21
  ws.on('message', (raw) => {
22
  const msg = JSON.parse(raw.toString());
23
+ if (msg.type === 'hello') {
24
+ lastHello = msg;
25
+ ws.send(JSON.stringify({ type: 'pong', data: {} }));
26
+ }
27
  });
28
  if (closeCode !== null) {
29
  // 1006 is reserved and can never be sent as a close frame β€” terminate()
 
33
  }
34
  });
35
 
36
+ const reset = () => { connections = 0; lastHello = null; };
37
 
38
  // terminate() produces an ECONNRESET on the client β€” expected in the 1006 test.
39
  const quiet = (ch) => ch.on('error', () => {});
 
124
  assert.equal(flushed, true);
125
  ch.close();
126
  });
127
+
128
+ it('sends v:1 envelope and announces capabilities in hello', async () => {
129
+ reset();
130
+ closeCode = null;
131
+ const caps = {
132
+ tools: [{ name: 'sysinfo', description: 'System information' }],
133
+ shell: { allowlist: ['df', 'uptime'], unsafe: false, platform: 'darwin' },
134
+ };
135
+ const ch = new ControlChannel('test-key', 'agent-1', caps);
136
+ quiet(ch);
137
+ ch.connect();
138
+
139
+ await new Promise((resolve) => {
140
+ const timer = setInterval(() => {
141
+ if (lastHello) { clearInterval(timer); resolve(); }
142
+ }, 25);
143
+ });
144
+
145
+ assert.equal(lastHello.v, 1);
146
+ assert.equal(lastHello.agentId, 'agent-1');
147
+ assert.ok(Array.isArray(lastHello.data.capabilities.tools));
148
+ assert.equal(lastHello.data.capabilities.tools[0].name, 'sysinfo');
149
+ assert.deepEqual(lastHello.data.capabilities.shell.allowlist, ['df', 'uptime']);
150
+ assert.ok(lastHello.data.host);
151
+ assert.ok(lastHello.data.version);
152
+ ch.close();
153
+ });
154
  });