Mona commited on
Commit Β·
dfd5fc7
1
Parent(s): 4360e71
feat: versioned WS envelope (v:1) + capabilities in hello
Browse filesEvery 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
- src/agent.js +5 -1
- src/control.js +6 -2
- src/tools/shell.js +8 -0
- 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')
|
|
|
|
|
|
|
|
|
|
| 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 |
});
|