@openclaw-cloud/agent-controller 0.1.4 → 0.1.6
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.
- package/.husky/pre-commit +1 -0
- package/BIZPLAN.md +530 -0
- package/CLAUDE.md +64 -0
- package/__tests__/api.test.ts +123 -0
- package/__tests__/chat.test.ts +191 -0
- package/__tests__/config.test.ts +100 -0
- package/__tests__/connection.test.ts +171 -14
- package/__tests__/file-delete.test.ts +90 -0
- package/__tests__/file-write.test.ts +119 -0
- package/__tests__/gateway-adapter.test.ts +366 -0
- package/__tests__/gateway-client.test.ts +272 -0
- package/__tests__/onboarding.test.ts +55 -0
- package/__tests__/package-install.test.ts +109 -0
- package/__tests__/pair.test.ts +60 -0
- package/__tests__/self-update.test.ts +123 -0
- package/__tests__/stop.test.ts +38 -0
- package/bin/agent-controller.js +15 -2
- package/dist/commands/self-update.d.ts +1 -0
- package/dist/commands/self-update.js +41 -0
- package/dist/commands/self-update.js.map +1 -0
- package/dist/connection.d.ts +1 -1
- package/dist/connection.js +49 -29
- package/dist/connection.js.map +1 -1
- package/dist/handlers/board-handler.d.ts +1 -0
- package/dist/handlers/board-handler.js +5 -2
- package/dist/handlers/board-handler.js.map +1 -1
- package/dist/handlers/chat.d.ts +4 -0
- package/dist/handlers/chat.js +62 -0
- package/dist/handlers/chat.js.map +1 -0
- package/dist/handlers/file-delete.d.ts +2 -0
- package/dist/handlers/file-delete.js +41 -0
- package/dist/handlers/file-delete.js.map +1 -0
- package/dist/handlers/file-write.d.ts +2 -0
- package/dist/handlers/file-write.js +59 -0
- package/dist/handlers/file-write.js.map +1 -0
- package/dist/handlers/onboarding.d.ts +2 -0
- package/dist/handlers/onboarding.js +18 -0
- package/dist/handlers/onboarding.js.map +1 -0
- package/dist/handlers/package-install.d.ts +2 -0
- package/dist/handlers/package-install.js +59 -0
- package/dist/handlers/package-install.js.map +1 -0
- package/dist/index.js +20 -7
- package/dist/index.js.map +1 -1
- package/dist/openclaw/gateway-adapter.d.ts +22 -0
- package/dist/openclaw/gateway-adapter.js +108 -0
- package/dist/openclaw/gateway-adapter.js.map +1 -0
- package/dist/openclaw/gateway-client.d.ts +21 -0
- package/dist/openclaw/gateway-client.js +114 -0
- package/dist/openclaw/gateway-client.js.map +1 -0
- package/dist/openclaw/index.d.ts +8 -0
- package/dist/openclaw/index.js +12 -0
- package/dist/openclaw/index.js.map +1 -0
- package/dist/openclaw/types.d.ts +36 -0
- package/dist/openclaw/types.js +2 -0
- package/dist/openclaw/types.js.map +1 -0
- package/dist/types.d.ts +2 -1
- package/package.json +4 -2
- package/src/commands/self-update.ts +43 -0
- package/src/connection.ts +31 -28
- package/src/handlers/file-delete.ts +46 -0
- package/src/handlers/file-write.ts +65 -0
- package/src/handlers/onboarding.ts +19 -0
- package/src/handlers/package-install.ts +69 -0
- package/src/index.ts +3 -5
- package/src/types.ts +2 -1
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Mock ws before importing anything that uses it.
|
|
2
|
+
// The 'instances' array lives inside the factory closure and is exposed via the
|
|
3
|
+
// static property so tests can grab the last created socket.
|
|
4
|
+
jest.mock('ws', () => {
|
|
5
|
+
const { EventEmitter } = require('node:events');
|
|
6
|
+
const instances: any[] = [];
|
|
7
|
+
|
|
8
|
+
class MockWebSocket extends EventEmitter {
|
|
9
|
+
static OPEN = 1;
|
|
10
|
+
static instances = instances;
|
|
11
|
+
readyState = 1;
|
|
12
|
+
send = jest.fn();
|
|
13
|
+
close = jest.fn(() => (this as any).emit('close'));
|
|
14
|
+
|
|
15
|
+
constructor() {
|
|
16
|
+
super();
|
|
17
|
+
instances.push(this);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return MockWebSocket;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
import WebSocket from 'ws';
|
|
25
|
+
import { GatewayClient } from '../src/openclaw/gateway-client';
|
|
26
|
+
|
|
27
|
+
const WS = WebSocket as any;
|
|
28
|
+
|
|
29
|
+
function lastWs(): any {
|
|
30
|
+
return WS.instances[WS.instances.length - 1];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Complete the connect handshake (challenge → auth → resolve).
|
|
34
|
+
async function doConnect(client: GatewayClient): Promise<any> {
|
|
35
|
+
const connectPromise = client.connect();
|
|
36
|
+
const ws = lastWs();
|
|
37
|
+
|
|
38
|
+
ws.emit('message', JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} }));
|
|
39
|
+
await Promise.resolve(); // let the async handler send the auth request
|
|
40
|
+
|
|
41
|
+
const sent = JSON.parse(ws.send.mock.calls[0][0]);
|
|
42
|
+
ws.emit('message', JSON.stringify({ type: 'res', id: sent.id, ok: true, payload: {} }));
|
|
43
|
+
await connectPromise;
|
|
44
|
+
|
|
45
|
+
ws.send.mockClear();
|
|
46
|
+
return ws;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
WS.instances.length = 0;
|
|
51
|
+
jest.clearAllMocks();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('GatewayClient – initial state', () => {
|
|
55
|
+
it('is not connected before connect()', () => {
|
|
56
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
57
|
+
expect(client.isConnected()).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('setEventHandler does not throw', () => {
|
|
61
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
62
|
+
expect(() => client.setEventHandler(jest.fn())).not.toThrow();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('GatewayClient – request() before connect', () => {
|
|
67
|
+
it('rejects with "Not connected" when ws is null', async () => {
|
|
68
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
69
|
+
await expect(client.request('test', {})).rejects.toThrow('Not connected');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('GatewayClient – disconnect()', () => {
|
|
74
|
+
it('is safe to call when not connected', () => {
|
|
75
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
76
|
+
expect(() => client.disconnect()).not.toThrow();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('marks client as not connected after connect → disconnect', async () => {
|
|
80
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
81
|
+
await doConnect(client);
|
|
82
|
+
|
|
83
|
+
expect(client.isConnected()).toBe(true);
|
|
84
|
+
client.disconnect();
|
|
85
|
+
expect(client.isConnected()).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('rejects pending requests on disconnect', async () => {
|
|
89
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
90
|
+
const ws = await doConnect(client);
|
|
91
|
+
|
|
92
|
+
const pending = client.request('slow.method', {});
|
|
93
|
+
client.disconnect();
|
|
94
|
+
|
|
95
|
+
await expect(pending).rejects.toThrow('Connection closed');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('GatewayClient – connect()', () => {
|
|
100
|
+
it('creates WebSocket with the given url', async () => {
|
|
101
|
+
const client = new GatewayClient('ws://test-host:8000', 'tok');
|
|
102
|
+
await doConnect(client);
|
|
103
|
+
expect(WS.instances).toHaveLength(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('sends auth request containing the token after connect.challenge', async () => {
|
|
107
|
+
const client = new GatewayClient('ws://localhost', 'secret-token');
|
|
108
|
+
const connectPromise = client.connect();
|
|
109
|
+
const ws = lastWs();
|
|
110
|
+
|
|
111
|
+
ws.emit('message', JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} }));
|
|
112
|
+
await Promise.resolve();
|
|
113
|
+
|
|
114
|
+
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
115
|
+
const msg = JSON.parse(ws.send.mock.calls[0][0]);
|
|
116
|
+
expect(msg.method).toBe('connect');
|
|
117
|
+
expect(msg.params.auth.token).toBe('secret-token');
|
|
118
|
+
expect(msg.type).toBe('req');
|
|
119
|
+
|
|
120
|
+
ws.emit('message', JSON.stringify({ type: 'res', id: msg.id, ok: true, payload: {} }));
|
|
121
|
+
await connectPromise;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('resolves and sets isConnected to true after successful auth', async () => {
|
|
125
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
126
|
+
await doConnect(client);
|
|
127
|
+
expect(client.isConnected()).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('rejects when WebSocket emits error before connecting', async () => {
|
|
131
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
132
|
+
const connectPromise = client.connect();
|
|
133
|
+
lastWs().emit('error', new Error('ECONNREFUSED'));
|
|
134
|
+
await expect(connectPromise).rejects.toThrow('ECONNREFUSED');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('rejects when auth response is an error', async () => {
|
|
138
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
139
|
+
const connectPromise = client.connect();
|
|
140
|
+
const ws = lastWs();
|
|
141
|
+
|
|
142
|
+
ws.emit('message', JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} }));
|
|
143
|
+
await Promise.resolve();
|
|
144
|
+
|
|
145
|
+
const msg = JSON.parse(ws.send.mock.calls[0][0]);
|
|
146
|
+
ws.emit('message', JSON.stringify({ type: 'res', id: msg.id, ok: false, error: { message: 'Unauthorized' } }));
|
|
147
|
+
|
|
148
|
+
await expect(connectPromise).rejects.toThrow('Unauthorized');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('ignores events other than connect.challenge during handshake', async () => {
|
|
152
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
153
|
+
const connectPromise = client.connect();
|
|
154
|
+
const ws = lastWs();
|
|
155
|
+
|
|
156
|
+
ws.emit('message', JSON.stringify({ type: 'event', event: 'other.event', payload: {} }));
|
|
157
|
+
await Promise.resolve();
|
|
158
|
+
expect(ws.send).not.toHaveBeenCalled();
|
|
159
|
+
|
|
160
|
+
ws.emit('message', JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} }));
|
|
161
|
+
await Promise.resolve();
|
|
162
|
+
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
163
|
+
|
|
164
|
+
const msg = JSON.parse(ws.send.mock.calls[0][0]);
|
|
165
|
+
ws.emit('message', JSON.stringify({ type: 'res', id: msg.id, ok: true, payload: {} }));
|
|
166
|
+
await connectPromise;
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('restores original event handler after auth completes', async () => {
|
|
170
|
+
const originalHandler = jest.fn();
|
|
171
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
172
|
+
client.setEventHandler(originalHandler);
|
|
173
|
+
const ws = await doConnect(client);
|
|
174
|
+
|
|
175
|
+
// Post-connect event should reach the original handler
|
|
176
|
+
ws.emit('message', JSON.stringify({ type: 'event', event: 'some.event', payload: { x: 1 } }));
|
|
177
|
+
await Promise.resolve();
|
|
178
|
+
|
|
179
|
+
expect(originalHandler).toHaveBeenCalledWith(expect.objectContaining({ event: 'some.event' }));
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('GatewayClient – request() after connect', () => {
|
|
184
|
+
it('sends request with correct type, method, params, and id fields', async () => {
|
|
185
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
186
|
+
const ws = await doConnect(client);
|
|
187
|
+
|
|
188
|
+
const reqPromise = client.request('sessions.list', { limit: 10 });
|
|
189
|
+
expect(ws.send).toHaveBeenCalledTimes(1);
|
|
190
|
+
|
|
191
|
+
const sent = JSON.parse(ws.send.mock.calls[0][0]);
|
|
192
|
+
expect(sent.type).toBe('req');
|
|
193
|
+
expect(sent.method).toBe('sessions.list');
|
|
194
|
+
expect(sent.params).toEqual({ limit: 10 });
|
|
195
|
+
expect(typeof sent.id).toBe('string');
|
|
196
|
+
|
|
197
|
+
ws.emit('message', JSON.stringify({ type: 'res', id: sent.id, ok: true, payload: { sessions: [] } }));
|
|
198
|
+
const result = await reqPromise;
|
|
199
|
+
expect(result.sessions).toEqual([]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('rejects when response has ok:false', async () => {
|
|
203
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
204
|
+
const ws = await doConnect(client);
|
|
205
|
+
|
|
206
|
+
const reqPromise = client.request('sessions.list', {});
|
|
207
|
+
const sent = JSON.parse(ws.send.mock.calls[0][0]);
|
|
208
|
+
ws.emit('message', JSON.stringify({ type: 'res', id: sent.id, ok: false, error: { message: 'Not found' } }));
|
|
209
|
+
|
|
210
|
+
await expect(reqPromise).rejects.toThrow('Not found');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('rejects with generic message when error has no message field', async () => {
|
|
214
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
215
|
+
const ws = await doConnect(client);
|
|
216
|
+
|
|
217
|
+
const reqPromise = client.request('test', {});
|
|
218
|
+
const sent = JSON.parse(ws.send.mock.calls[0][0]);
|
|
219
|
+
ws.emit('message', JSON.stringify({ type: 'res', id: sent.id, ok: false }));
|
|
220
|
+
|
|
221
|
+
await expect(reqPromise).rejects.toThrow('Request failed');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('ignores response for an unknown request id', async () => {
|
|
225
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
226
|
+
const ws = await doConnect(client);
|
|
227
|
+
|
|
228
|
+
const reqPromise = client.request('test', {});
|
|
229
|
+
ws.emit('message', JSON.stringify({ type: 'res', id: 'stale-id', ok: true, payload: {} }));
|
|
230
|
+
await Promise.resolve();
|
|
231
|
+
|
|
232
|
+
let resolved = false;
|
|
233
|
+
reqPromise.then(() => { resolved = true; }).catch(() => {});
|
|
234
|
+
await Promise.resolve();
|
|
235
|
+
expect(resolved).toBe(false);
|
|
236
|
+
|
|
237
|
+
// Clean up to avoid open handles
|
|
238
|
+
client.disconnect();
|
|
239
|
+
await reqPromise.catch(() => {});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('ignores messages with unknown type', async () => {
|
|
243
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
244
|
+
const ws = await doConnect(client);
|
|
245
|
+
|
|
246
|
+
expect(() => {
|
|
247
|
+
ws.emit('message', JSON.stringify({ type: 'unknown_type', id: 'x' }));
|
|
248
|
+
}).not.toThrow();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('ignores malformed JSON messages', async () => {
|
|
252
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
253
|
+
const ws = await doConnect(client);
|
|
254
|
+
|
|
255
|
+
expect(() => {
|
|
256
|
+
ws.emit('message', '{ bad json');
|
|
257
|
+
}).not.toThrow();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('GatewayClient – connection close', () => {
|
|
262
|
+
it('marks as disconnected and rejects pending requests when connection closes', async () => {
|
|
263
|
+
const client = new GatewayClient('ws://localhost', 'token');
|
|
264
|
+
const ws = await doConnect(client);
|
|
265
|
+
|
|
266
|
+
const pendingReq = client.request('test', {});
|
|
267
|
+
ws.emit('close');
|
|
268
|
+
|
|
269
|
+
await expect(pendingReq).rejects.toThrow('Connection closed');
|
|
270
|
+
expect(client.isConnected()).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { handleOnboardingComplete } from '../src/handlers/onboarding';
|
|
2
|
+
import type { AgentCommand } from '../src/types';
|
|
3
|
+
import childProcess from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
jest.mock('node:child_process');
|
|
6
|
+
|
|
7
|
+
const mockExec = childProcess.exec as unknown as jest.Mock;
|
|
8
|
+
|
|
9
|
+
function fakeExec(error: Error | null, stdout = '', stderr = '') {
|
|
10
|
+
mockExec.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => {
|
|
11
|
+
cb(error, Buffer.from(stdout), Buffer.from(stderr));
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const baseCmd: AgentCommand = { id: '1', type: 'onboarding_complete', payload: { agentId: 'agent-123' } };
|
|
16
|
+
|
|
17
|
+
describe('handleOnboardingComplete', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
jest.clearAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('triggers "openclaw gateway restart" and returns success', async () => {
|
|
23
|
+
fakeExec(null, 'restarted', '');
|
|
24
|
+
const res = await handleOnboardingComplete(baseCmd);
|
|
25
|
+
expect(res.success).toBe(true);
|
|
26
|
+
expect(res.id).toBe('1');
|
|
27
|
+
expect(res.type).toBe('onboarding_complete');
|
|
28
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
29
|
+
'openclaw gateway restart',
|
|
30
|
+
expect.any(Object),
|
|
31
|
+
expect.any(Function),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns success:false with error message when restart fails', async () => {
|
|
36
|
+
fakeExec(new Error('restart failed'), '', 'stderr output');
|
|
37
|
+
const res = await handleOnboardingComplete({ ...baseCmd, id: '2' });
|
|
38
|
+
expect(res.success).toBe(false);
|
|
39
|
+
expect(res.error).toBe('restart failed');
|
|
40
|
+
expect(res.id).toBe('2');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('includes stdout and stderr in data', async () => {
|
|
44
|
+
fakeExec(null, 'gateway restarted ok', 'some warning');
|
|
45
|
+
const res = await handleOnboardingComplete(baseCmd);
|
|
46
|
+
expect(res.data?.stdout).toBe('gateway restarted ok');
|
|
47
|
+
expect(res.data?.stderr).toBe('some warning');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('does not set error field on success', async () => {
|
|
51
|
+
fakeExec(null);
|
|
52
|
+
const res = await handleOnboardingComplete(baseCmd);
|
|
53
|
+
expect(res.error).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { handlePackageInstall } from '../src/handlers/package-install';
|
|
2
|
+
import type { AgentCommand } from '../src/types';
|
|
3
|
+
import childProcess from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
jest.mock('node:child_process');
|
|
6
|
+
|
|
7
|
+
const mockExec = childProcess.exec as unknown as jest.Mock;
|
|
8
|
+
|
|
9
|
+
function fakeExec(error: Error | null) {
|
|
10
|
+
mockExec.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => {
|
|
11
|
+
cb(error, Buffer.from(''), Buffer.from(''));
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const baseCmd: AgentCommand = { id: '1', type: 'package_install', payload: {} };
|
|
16
|
+
|
|
17
|
+
describe('handlePackageInstall', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
jest.clearAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns success with empty installed when no packages provided', async () => {
|
|
23
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { packages: {} } };
|
|
24
|
+
const res = await handlePackageInstall(cmd);
|
|
25
|
+
expect(res.success).toBe(true);
|
|
26
|
+
expect(res.data?.installed).toEqual({ apt: [], npm: [], pip: [] });
|
|
27
|
+
expect(res.data?.errors).toEqual([]);
|
|
28
|
+
expect(mockExec).not.toHaveBeenCalled();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns success with empty installed when packages key is missing', async () => {
|
|
32
|
+
const cmd: AgentCommand = { ...baseCmd, payload: {} };
|
|
33
|
+
const res = await handlePackageInstall(cmd);
|
|
34
|
+
expect(res.success).toBe(true);
|
|
35
|
+
expect(res.data?.installed).toEqual({ apt: [], npm: [], pip: [] });
|
|
36
|
+
expect(mockExec).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('installs apt packages and runs apt-get install -y per package', async () => {
|
|
40
|
+
fakeExec(null);
|
|
41
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { packages: { apt: ['curl', 'git'] } } };
|
|
42
|
+
const res = await handlePackageInstall(cmd);
|
|
43
|
+
expect(res.success).toBe(true);
|
|
44
|
+
expect((res.data?.installed as any).apt).toEqual(['curl', 'git']);
|
|
45
|
+
expect(mockExec).toHaveBeenCalledWith('apt-get install -y curl', expect.any(Object), expect.any(Function));
|
|
46
|
+
expect(mockExec).toHaveBeenCalledWith('apt-get install -y git', expect.any(Object), expect.any(Function));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('installs npm packages with npm install -g', async () => {
|
|
50
|
+
fakeExec(null);
|
|
51
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { packages: { npm: ['typescript'] } } };
|
|
52
|
+
const res = await handlePackageInstall(cmd);
|
|
53
|
+
expect(res.success).toBe(true);
|
|
54
|
+
expect((res.data?.installed as any).npm).toEqual(['typescript']);
|
|
55
|
+
expect(mockExec).toHaveBeenCalledWith('npm install -g typescript', expect.any(Object), expect.any(Function));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('installs pip packages with pip install', async () => {
|
|
59
|
+
fakeExec(null);
|
|
60
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { packages: { pip: ['requests'] } } };
|
|
61
|
+
const res = await handlePackageInstall(cmd);
|
|
62
|
+
expect(res.success).toBe(true);
|
|
63
|
+
expect((res.data?.installed as any).pip).toEqual(['requests']);
|
|
64
|
+
expect(mockExec).toHaveBeenCalledWith('pip install requests', expect.any(Object), expect.any(Function));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('continues with other packages when one npm package fails (partial failure)', async () => {
|
|
68
|
+
let call = 0;
|
|
69
|
+
mockExec.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => {
|
|
70
|
+
call === 0
|
|
71
|
+
? cb(new Error('package not found'), Buffer.from(''), Buffer.from(''))
|
|
72
|
+
: cb(null, Buffer.from(''), Buffer.from(''));
|
|
73
|
+
call++;
|
|
74
|
+
});
|
|
75
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { packages: { npm: ['bad-pkg', 'typescript'] } } };
|
|
76
|
+
const res = await handlePackageInstall(cmd);
|
|
77
|
+
expect(res.success).toBe(false);
|
|
78
|
+
expect((res.data?.installed as any).npm).toEqual(['typescript']);
|
|
79
|
+
expect((res.data?.errors as any[]).length).toBe(1);
|
|
80
|
+
expect((res.data?.errors as any[])[0].name).toBe('bad-pkg');
|
|
81
|
+
expect((res.data?.errors as any[])[0].error).toContain('package not found');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('collects errors across all package types when all fail', async () => {
|
|
85
|
+
fakeExec(new Error('install failed'));
|
|
86
|
+
const cmd: AgentCommand = {
|
|
87
|
+
...baseCmd,
|
|
88
|
+
payload: { packages: { apt: ['pkg1'], npm: ['pkg2'], pip: ['pkg3'] } },
|
|
89
|
+
};
|
|
90
|
+
const res = await handlePackageInstall(cmd);
|
|
91
|
+
expect(res.success).toBe(false);
|
|
92
|
+
expect((res.data?.errors as any[]).length).toBe(3);
|
|
93
|
+
expect((res.data?.installed as any)).toEqual({ apt: [], npm: [], pip: [] });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('succeeds with success:true when all packages across all types install', async () => {
|
|
97
|
+
fakeExec(null);
|
|
98
|
+
const cmd: AgentCommand = {
|
|
99
|
+
...baseCmd,
|
|
100
|
+
payload: { packages: { apt: ['curl'], npm: ['ts-node'], pip: ['boto3'] } },
|
|
101
|
+
};
|
|
102
|
+
const res = await handlePackageInstall(cmd);
|
|
103
|
+
expect(res.success).toBe(true);
|
|
104
|
+
expect((res.data?.errors as any[]).length).toBe(0);
|
|
105
|
+
expect((res.data?.installed as any).apt).toEqual(['curl']);
|
|
106
|
+
expect((res.data?.installed as any).npm).toEqual(['ts-node']);
|
|
107
|
+
expect((res.data?.installed as any).pip).toEqual(['boto3']);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { handlePair } from '../src/handlers/pair';
|
|
2
|
+
import type { AgentCommand } from '../src/types';
|
|
3
|
+
|
|
4
|
+
describe('handlePair', () => {
|
|
5
|
+
const originalEnv = process.env;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
process.env = { ...originalEnv, AGENT_ID: 'agent-uuid-123' };
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
process.env = originalEnv;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('rejects when token is missing', async () => {
|
|
16
|
+
const cmd: AgentCommand = { id: '1', type: 'pair', payload: { targetId: 'target-1' } };
|
|
17
|
+
const res = await handlePair(cmd);
|
|
18
|
+
expect(res.success).toBe(false);
|
|
19
|
+
expect(res.error).toContain('Missing');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('rejects when targetId is missing', async () => {
|
|
23
|
+
const cmd: AgentCommand = { id: '2', type: 'pair', payload: { token: 'abc' } };
|
|
24
|
+
const res = await handlePair(cmd);
|
|
25
|
+
expect(res.success).toBe(false);
|
|
26
|
+
expect(res.error).toContain('Missing');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('rejects when both token and targetId are missing', async () => {
|
|
30
|
+
const cmd: AgentCommand = { id: '3', type: 'pair', payload: {} };
|
|
31
|
+
const res = await handlePair(cmd);
|
|
32
|
+
expect(res.success).toBe(false);
|
|
33
|
+
expect(res.error).toContain('Missing');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns paired:true with targetId and agentId from env', async () => {
|
|
37
|
+
const cmd: AgentCommand = {
|
|
38
|
+
id: '4',
|
|
39
|
+
type: 'pair',
|
|
40
|
+
payload: { token: 'my-token', targetId: 'target-42' },
|
|
41
|
+
};
|
|
42
|
+
const res = await handlePair(cmd);
|
|
43
|
+
expect(res.success).toBe(true);
|
|
44
|
+
expect(res.data?.paired).toBe(true);
|
|
45
|
+
expect(res.data?.targetId).toBe('target-42');
|
|
46
|
+
expect(res.data?.agentId).toBe('agent-uuid-123');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns agentId as undefined when AGENT_ID env var is not set', async () => {
|
|
50
|
+
delete process.env.AGENT_ID;
|
|
51
|
+
const cmd: AgentCommand = {
|
|
52
|
+
id: '5',
|
|
53
|
+
type: 'pair',
|
|
54
|
+
payload: { token: 'tok', targetId: 'target-1' },
|
|
55
|
+
};
|
|
56
|
+
const res = await handlePair(cmd);
|
|
57
|
+
expect(res.success).toBe(true);
|
|
58
|
+
expect(res.data?.agentId).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { selfUpdate } from '../src/commands/self-update';
|
|
2
|
+
import childProcess from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
jest.mock('node:child_process');
|
|
5
|
+
|
|
6
|
+
const mockExec = childProcess.exec as unknown as jest.Mock;
|
|
7
|
+
|
|
8
|
+
type ExecFakeResult = { error: Error | null; stdout: string };
|
|
9
|
+
|
|
10
|
+
function fakeExecSequence(results: ExecFakeResult[]) {
|
|
11
|
+
let call = 0;
|
|
12
|
+
mockExec.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => {
|
|
13
|
+
const result = results[call++] ?? { error: new Error('unexpected call'), stdout: '' };
|
|
14
|
+
cb(result.error, Buffer.from(result.stdout), Buffer.from(''));
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('selfUpdate', () => {
|
|
19
|
+
let exitSpy: jest.SpyInstance;
|
|
20
|
+
let logSpy: jest.SpyInstance;
|
|
21
|
+
let errorSpy: jest.SpyInstance;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
jest.clearAllMocks();
|
|
25
|
+
exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
|
26
|
+
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
27
|
+
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
jest.restoreAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('prints "Already up to date" and does not exit when version matches', async () => {
|
|
35
|
+
fakeExecSequence([{ error: null, stdout: '1.2.3' }]);
|
|
36
|
+
|
|
37
|
+
await selfUpdate('1.2.3');
|
|
38
|
+
|
|
39
|
+
expect(mockExec).toHaveBeenCalledTimes(1);
|
|
40
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
41
|
+
'npm view @openclaw-cloud/agent-controller version',
|
|
42
|
+
expect.any(Object),
|
|
43
|
+
expect.any(Function),
|
|
44
|
+
);
|
|
45
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Already up to date'));
|
|
46
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('installs latest and calls process.exit(0) when a new version is available', async () => {
|
|
50
|
+
fakeExecSequence([
|
|
51
|
+
{ error: null, stdout: '1.3.0' },
|
|
52
|
+
{ error: null, stdout: '' },
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
await selfUpdate('1.2.3');
|
|
56
|
+
|
|
57
|
+
expect(mockExec).toHaveBeenCalledTimes(2);
|
|
58
|
+
expect(mockExec).toHaveBeenNthCalledWith(
|
|
59
|
+
1,
|
|
60
|
+
'npm view @openclaw-cloud/agent-controller version',
|
|
61
|
+
expect.any(Object),
|
|
62
|
+
expect.any(Function),
|
|
63
|
+
);
|
|
64
|
+
expect(mockExec).toHaveBeenNthCalledWith(
|
|
65
|
+
2,
|
|
66
|
+
'npm install -g @openclaw-cloud/agent-controller@latest',
|
|
67
|
+
expect.any(Object),
|
|
68
|
+
expect.any(Function),
|
|
69
|
+
);
|
|
70
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Updating from 1.2.3 to 1.3.0'));
|
|
71
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Successfully updated to 1.3.0'));
|
|
72
|
+
expect(exitSpy).toHaveBeenCalledWith(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('calls process.exit(1) when npm view fails', async () => {
|
|
76
|
+
fakeExecSequence([{ error: new Error('network error'), stdout: '' }]);
|
|
77
|
+
|
|
78
|
+
await selfUpdate('1.2.3');
|
|
79
|
+
|
|
80
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
81
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
82
|
+
expect.stringContaining('Failed to fetch latest version'),
|
|
83
|
+
'network error',
|
|
84
|
+
);
|
|
85
|
+
expect(mockExec).toHaveBeenCalledTimes(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('calls process.exit(1) when npm install fails', async () => {
|
|
89
|
+
fakeExecSequence([
|
|
90
|
+
{ error: null, stdout: '1.3.0' },
|
|
91
|
+
{ error: new Error('npm install failed'), stdout: '' },
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
await selfUpdate('1.2.3');
|
|
95
|
+
|
|
96
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
97
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
98
|
+
expect.stringContaining('Update failed'),
|
|
99
|
+
'npm install failed',
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('logs current and latest versions before updating', async () => {
|
|
104
|
+
fakeExecSequence([
|
|
105
|
+
{ error: null, stdout: '2.0.0' },
|
|
106
|
+
{ error: null, stdout: '' },
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
await selfUpdate('1.0.0');
|
|
110
|
+
|
|
111
|
+
expect(logSpy).toHaveBeenCalledWith('Current version: 1.0.0');
|
|
112
|
+
expect(logSpy).toHaveBeenCalledWith('Latest version: 2.0.0');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('trims whitespace from npm view output before comparing', async () => {
|
|
116
|
+
fakeExecSequence([{ error: null, stdout: '1.2.3\n' }]);
|
|
117
|
+
|
|
118
|
+
await selfUpdate('1.2.3');
|
|
119
|
+
|
|
120
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Already up to date'));
|
|
121
|
+
expect(exitSpy).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { handleStop } from '../src/handlers/stop';
|
|
2
|
+
import type { AgentCommand } from '../src/types';
|
|
3
|
+
|
|
4
|
+
describe('handleStop', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
jest.useFakeTimers();
|
|
7
|
+
jest.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
jest.useRealTimers();
|
|
12
|
+
jest.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns success response with graceful shutdown message', async () => {
|
|
16
|
+
const cmd: AgentCommand = { id: '1', type: 'stop', payload: {} };
|
|
17
|
+
const res = await handleStop(cmd);
|
|
18
|
+
expect(res.success).toBe(true);
|
|
19
|
+
expect(res.data?.message).toContain('gracefully');
|
|
20
|
+
expect(res.id).toBe('1');
|
|
21
|
+
expect(res.type).toBe('stop');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('does not call process.exit before 500ms', async () => {
|
|
25
|
+
const cmd: AgentCommand = { id: '2', type: 'stop', payload: {} };
|
|
26
|
+
await handleStop(cmd);
|
|
27
|
+
jest.advanceTimersByTime(499);
|
|
28
|
+
expect(process.exit).not.toHaveBeenCalled();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('calls process.exit(0) after 500ms', async () => {
|
|
32
|
+
const cmd: AgentCommand = { id: '3', type: 'stop', payload: {} };
|
|
33
|
+
await handleStop(cmd);
|
|
34
|
+
expect(process.exit).not.toHaveBeenCalled();
|
|
35
|
+
jest.advanceTimersByTime(500);
|
|
36
|
+
expect(process.exit).toHaveBeenCalledWith(0);
|
|
37
|
+
});
|
|
38
|
+
});
|
package/bin/agent-controller.js
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const pkg = require(join(dirname(fileURLToPath(import.meta.url)), '../package.json'));
|
|
9
|
+
|
|
10
|
+
const [,, command] = process.argv;
|
|
11
|
+
|
|
12
|
+
if (command === 'self-update') {
|
|
13
|
+
const { selfUpdate } = await import('../dist/commands/self-update.js');
|
|
14
|
+
await selfUpdate(pkg.version);
|
|
15
|
+
} else {
|
|
16
|
+
const { main } = await import('../dist/index.js');
|
|
17
|
+
main();
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function selfUpdate(currentVersion: string): Promise<void>;
|