@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,123 @@
|
|
|
1
|
+
import { createAgentApi } from '../src/api';
|
|
2
|
+
|
|
3
|
+
const mockFetch = jest.fn();
|
|
4
|
+
(global as any).fetch = mockFetch;
|
|
5
|
+
|
|
6
|
+
function makeOkResponse(data: unknown, status = 200): Response {
|
|
7
|
+
return {
|
|
8
|
+
ok: true,
|
|
9
|
+
status,
|
|
10
|
+
json: jest.fn().mockResolvedValue(data),
|
|
11
|
+
text: jest.fn().mockResolvedValue(''),
|
|
12
|
+
} as unknown as Response;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function makeErrorResponse(status: number, body = ''): Response {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
status,
|
|
19
|
+
json: jest.fn(),
|
|
20
|
+
text: jest.fn().mockResolvedValue(body),
|
|
21
|
+
} as unknown as Response;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
jest.clearAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('createAgentApi – get()', () => {
|
|
29
|
+
it('sends GET request with correct URL, method, and Authorization header', async () => {
|
|
30
|
+
const api = createAgentApi('http://backend', 'my-token');
|
|
31
|
+
mockFetch.mockResolvedValue(makeOkResponse({ ok: true }));
|
|
32
|
+
|
|
33
|
+
await api.get('/api/test');
|
|
34
|
+
|
|
35
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
36
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
37
|
+
expect(url).toBe('http://backend/api/test');
|
|
38
|
+
expect(init.method).toBe('GET');
|
|
39
|
+
expect(init.headers['Authorization']).toBe('Bearer my-token');
|
|
40
|
+
expect(init.headers['Content-Type']).toBe('application/json');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns parsed JSON body on success', async () => {
|
|
44
|
+
const api = createAgentApi('http://backend', 'tok');
|
|
45
|
+
const data = { id: 1, name: 'test' };
|
|
46
|
+
mockFetch.mockResolvedValue(makeOkResponse(data));
|
|
47
|
+
|
|
48
|
+
const result = await api.get('/resource');
|
|
49
|
+
expect(result).toEqual(data);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('throws containing HTTP status when response is not ok', async () => {
|
|
53
|
+
const api = createAgentApi('http://backend', 'tok');
|
|
54
|
+
mockFetch.mockResolvedValue(makeErrorResponse(404));
|
|
55
|
+
|
|
56
|
+
await expect(api.get('/missing')).rejects.toThrow('HTTP 404');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('throws containing the path in the error message', async () => {
|
|
60
|
+
const api = createAgentApi('http://backend', 'tok');
|
|
61
|
+
mockFetch.mockResolvedValue(makeErrorResponse(500));
|
|
62
|
+
|
|
63
|
+
await expect(api.get('/crash')).rejects.toThrow('/crash');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('propagates network errors from fetch', async () => {
|
|
67
|
+
const api = createAgentApi('http://backend', 'tok');
|
|
68
|
+
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
69
|
+
|
|
70
|
+
await expect(api.get('/path')).rejects.toThrow('ECONNREFUSED');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('createAgentApi – post()', () => {
|
|
75
|
+
it('sends POST request with correct URL, method, and Authorization header', async () => {
|
|
76
|
+
const api = createAgentApi('http://backend', 'my-token');
|
|
77
|
+
mockFetch.mockResolvedValue(makeOkResponse(null));
|
|
78
|
+
|
|
79
|
+
await api.post('/api/action');
|
|
80
|
+
|
|
81
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
82
|
+
expect(url).toBe('http://backend/api/action');
|
|
83
|
+
expect(init.method).toBe('POST');
|
|
84
|
+
expect(init.headers['Authorization']).toBe('Bearer my-token');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('serializes body as JSON when provided', async () => {
|
|
88
|
+
const api = createAgentApi('http://backend', 'tok');
|
|
89
|
+
mockFetch.mockResolvedValue(makeOkResponse(null));
|
|
90
|
+
|
|
91
|
+
await api.post('/endpoint', { key: 'value', num: 42 });
|
|
92
|
+
|
|
93
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
94
|
+
expect(init.body).toBe('{"key":"value","num":42}');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('omits body field when no body argument given', async () => {
|
|
98
|
+
const api = createAgentApi('http://backend', 'tok');
|
|
99
|
+
mockFetch.mockResolvedValue(makeOkResponse(null));
|
|
100
|
+
|
|
101
|
+
await api.post('/endpoint');
|
|
102
|
+
|
|
103
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
104
|
+
expect(init.body).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns the raw Response object without throwing on non-ok', async () => {
|
|
108
|
+
const api = createAgentApi('http://backend', 'tok');
|
|
109
|
+
const fakeRes = makeErrorResponse(400);
|
|
110
|
+
mockFetch.mockResolvedValue(fakeRes);
|
|
111
|
+
|
|
112
|
+
const result = await api.post('/endpoint', {});
|
|
113
|
+
expect(result).toBe(fakeRes);
|
|
114
|
+
expect(result.status).toBe(400);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('propagates network errors from fetch', async () => {
|
|
118
|
+
const api = createAgentApi('http://backend', 'tok');
|
|
119
|
+
mockFetch.mockRejectedValue(new Error('timeout'));
|
|
120
|
+
|
|
121
|
+
await expect(api.post('/endpoint')).rejects.toThrow('timeout');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { handleChatListSessions, handleChatHistory, handleChatSend } from '../src/handlers/chat';
|
|
2
|
+
import type { AgentCommand } from '../src/types';
|
|
3
|
+
|
|
4
|
+
jest.mock('../src/openclaw/index');
|
|
5
|
+
|
|
6
|
+
import { getChatProvider } from '../src/openclaw/index';
|
|
7
|
+
|
|
8
|
+
const mockGetChatProvider = getChatProvider as jest.Mock;
|
|
9
|
+
|
|
10
|
+
function makeCommand(
|
|
11
|
+
type: AgentCommand['type'],
|
|
12
|
+
payload: Record<string, unknown> = {},
|
|
13
|
+
id = 'cmd-1',
|
|
14
|
+
): AgentCommand {
|
|
15
|
+
return { id, type, payload };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('handleChatListSessions', () => {
|
|
19
|
+
let publish: jest.Mock;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
jest.clearAllMocks();
|
|
23
|
+
publish = jest.fn().mockResolvedValue(undefined);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('publishes error when provider is null', async () => {
|
|
27
|
+
mockGetChatProvider.mockReturnValue(null);
|
|
28
|
+
const cmd = makeCommand('chat_list_sessions');
|
|
29
|
+
await handleChatListSessions(cmd, publish);
|
|
30
|
+
expect(publish).toHaveBeenCalledWith(
|
|
31
|
+
expect.objectContaining({
|
|
32
|
+
type: 'chat_sessions_response',
|
|
33
|
+
correlationId: 'cmd-1',
|
|
34
|
+
sessions: [],
|
|
35
|
+
error: expect.stringContaining('not initialized'),
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('publishes sessions on success', async () => {
|
|
41
|
+
const sessions = [{ key: 'sess-1', sessionId: 'sid-1', kind: 'direct', updatedAt: 0 }];
|
|
42
|
+
mockGetChatProvider.mockReturnValue({
|
|
43
|
+
listSessions: jest.fn().mockResolvedValue(sessions),
|
|
44
|
+
});
|
|
45
|
+
const cmd = makeCommand('chat_list_sessions');
|
|
46
|
+
await handleChatListSessions(cmd, publish);
|
|
47
|
+
expect(publish).toHaveBeenCalledWith({
|
|
48
|
+
type: 'chat_sessions_response',
|
|
49
|
+
correlationId: 'cmd-1',
|
|
50
|
+
sessions,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('publishes error when provider.listSessions throws', async () => {
|
|
55
|
+
mockGetChatProvider.mockReturnValue({
|
|
56
|
+
listSessions: jest.fn().mockRejectedValue(new Error('db error')),
|
|
57
|
+
});
|
|
58
|
+
const cmd = makeCommand('chat_list_sessions');
|
|
59
|
+
await handleChatListSessions(cmd, publish);
|
|
60
|
+
expect(publish).toHaveBeenCalledWith(
|
|
61
|
+
expect.objectContaining({
|
|
62
|
+
type: 'chat_sessions_response',
|
|
63
|
+
sessions: [],
|
|
64
|
+
error: 'db error',
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('handleChatHistory', () => {
|
|
71
|
+
let publish: jest.Mock;
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
jest.clearAllMocks();
|
|
75
|
+
publish = jest.fn().mockResolvedValue(undefined);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('publishes messages on success', async () => {
|
|
79
|
+
const messages = [{ role: 'user', content: 'hello', timestamp: '2026-01-01T00:00:00Z' }];
|
|
80
|
+
mockGetChatProvider.mockReturnValue({
|
|
81
|
+
getHistory: jest.fn().mockResolvedValue(messages),
|
|
82
|
+
});
|
|
83
|
+
const cmd = makeCommand('chat_history', { sessionKey: 'sess-1', limit: 50 });
|
|
84
|
+
await handleChatHistory(cmd, publish);
|
|
85
|
+
expect(publish).toHaveBeenCalledWith({
|
|
86
|
+
type: 'chat_history_response',
|
|
87
|
+
correlationId: 'cmd-1',
|
|
88
|
+
messages,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('publishes error when provider is null', async () => {
|
|
93
|
+
mockGetChatProvider.mockReturnValue(null);
|
|
94
|
+
const cmd = makeCommand('chat_history', { sessionKey: 'sess-1' });
|
|
95
|
+
await handleChatHistory(cmd, publish);
|
|
96
|
+
expect(publish).toHaveBeenCalledWith(
|
|
97
|
+
expect.objectContaining({
|
|
98
|
+
type: 'chat_history_response',
|
|
99
|
+
messages: [],
|
|
100
|
+
error: expect.stringContaining('not initialized'),
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('publishes error when provider.getHistory throws', async () => {
|
|
106
|
+
mockGetChatProvider.mockReturnValue({
|
|
107
|
+
getHistory: jest.fn().mockRejectedValue(new Error('history error')),
|
|
108
|
+
});
|
|
109
|
+
const cmd = makeCommand('chat_history', { sessionKey: 'sess-1' });
|
|
110
|
+
await handleChatHistory(cmd, publish);
|
|
111
|
+
expect(publish).toHaveBeenCalledWith(
|
|
112
|
+
expect.objectContaining({
|
|
113
|
+
type: 'chat_history_response',
|
|
114
|
+
messages: [],
|
|
115
|
+
error: 'history error',
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('handleChatSend', () => {
|
|
122
|
+
let publish: jest.Mock;
|
|
123
|
+
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
jest.clearAllMocks();
|
|
126
|
+
publish = jest.fn().mockResolvedValue(undefined);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('always sends typing indicator before checking provider', async () => {
|
|
130
|
+
mockGetChatProvider.mockReturnValue(null);
|
|
131
|
+
const cmd = makeCommand('chat_send', { sessionKey: 'sess-1', text: 'hi' });
|
|
132
|
+
await handleChatSend(cmd, publish, 'agent-1');
|
|
133
|
+
expect(publish).toHaveBeenCalledWith({ type: 'chat_typing', agentId: 'agent-1', state: true });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('publishes error response when provider is null', async () => {
|
|
137
|
+
mockGetChatProvider.mockReturnValue(null);
|
|
138
|
+
const cmd = makeCommand('chat_send', { sessionKey: 'sess-1', text: 'hi' });
|
|
139
|
+
await handleChatSend(cmd, publish, 'agent-1');
|
|
140
|
+
const published = (publish as jest.Mock).mock.calls.map((c) => c[0]);
|
|
141
|
+
expect(published).toContainEqual(
|
|
142
|
+
expect.objectContaining({
|
|
143
|
+
type: 'chat_response',
|
|
144
|
+
correlationId: 'cmd-1',
|
|
145
|
+
error: expect.stringContaining('not initialized'),
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('calls onDelta and onDone via sendMessage callbacks', async () => {
|
|
151
|
+
const sendMessage = jest.fn().mockImplementation(async (params: any) => {
|
|
152
|
+
await params.onDelta('partial');
|
|
153
|
+
await params.onDone('full response');
|
|
154
|
+
});
|
|
155
|
+
mockGetChatProvider.mockReturnValue({ sendMessage });
|
|
156
|
+
const cmd = makeCommand('chat_send', { sessionKey: 'sess-1', text: 'hi' });
|
|
157
|
+
await handleChatSend(cmd, publish, 'agent-1');
|
|
158
|
+
const published = (publish as jest.Mock).mock.calls.map((c) => c[0]);
|
|
159
|
+
expect(published).toContainEqual(
|
|
160
|
+
expect.objectContaining({ type: 'chat_delta', correlationId: 'cmd-1', text: 'partial' }),
|
|
161
|
+
);
|
|
162
|
+
expect(published).toContainEqual(
|
|
163
|
+
expect.objectContaining({ type: 'chat_response', correlationId: 'cmd-1', text: 'full response' }),
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('publishes error via onError callback', async () => {
|
|
168
|
+
const sendMessage = jest.fn().mockImplementation(async (params: any) => {
|
|
169
|
+
await params.onError('stream error');
|
|
170
|
+
});
|
|
171
|
+
mockGetChatProvider.mockReturnValue({ sendMessage });
|
|
172
|
+
const cmd = makeCommand('chat_send', { sessionKey: 'sess-1', text: 'hi' });
|
|
173
|
+
await handleChatSend(cmd, publish, 'agent-1');
|
|
174
|
+
const published = (publish as jest.Mock).mock.calls.map((c) => c[0]);
|
|
175
|
+
expect(published).toContainEqual(
|
|
176
|
+
expect.objectContaining({ type: 'chat_response', error: 'stream error' }),
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('publishes error when provider.sendMessage throws', async () => {
|
|
181
|
+
mockGetChatProvider.mockReturnValue({
|
|
182
|
+
sendMessage: jest.fn().mockRejectedValue(new Error('send failed')),
|
|
183
|
+
});
|
|
184
|
+
const cmd = makeCommand('chat_send', { sessionKey: 'sess-1', text: 'hi' });
|
|
185
|
+
await handleChatSend(cmd, publish, 'agent-1');
|
|
186
|
+
const published = (publish as jest.Mock).mock.calls.map((c) => c[0]);
|
|
187
|
+
expect(published).toContainEqual(
|
|
188
|
+
expect.objectContaining({ type: 'chat_response', error: 'send failed' }),
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { handleConfig } from '../src/handlers/config';
|
|
2
|
+
import type { AgentCommand } from '../src/types';
|
|
3
|
+
import childProcess from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
|
|
6
|
+
jest.mock('node:child_process');
|
|
7
|
+
jest.mock('node:fs/promises');
|
|
8
|
+
|
|
9
|
+
const mockExec = childProcess.exec as unknown as jest.Mock;
|
|
10
|
+
const mockMkdir = fs.mkdir as jest.Mock;
|
|
11
|
+
const mockWriteFile = fs.writeFile as jest.Mock;
|
|
12
|
+
|
|
13
|
+
function fakeExec(error: Error | null, stdout = '', stderr = '') {
|
|
14
|
+
mockExec.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => {
|
|
15
|
+
cb(error, Buffer.from(stdout), Buffer.from(stderr));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('handleConfig', () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
jest.clearAllMocks();
|
|
22
|
+
mockMkdir.mockResolvedValue(undefined);
|
|
23
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('rejects missing filename', async () => {
|
|
27
|
+
const cmd: AgentCommand = { id: '1', type: 'config', payload: { content: 'some: config' } };
|
|
28
|
+
const res = await handleConfig(cmd);
|
|
29
|
+
expect(res.success).toBe(false);
|
|
30
|
+
expect(res.error).toContain('Missing');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('rejects missing content', async () => {
|
|
34
|
+
const cmd: AgentCommand = { id: '2', type: 'config', payload: { filename: 'test.yml' } };
|
|
35
|
+
const res = await handleConfig(cmd);
|
|
36
|
+
expect(res.success).toBe(false);
|
|
37
|
+
expect(res.error).toContain('Missing');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('creates directory, writes file, and runs restart on success', async () => {
|
|
41
|
+
fakeExec(null, 'restarted', '');
|
|
42
|
+
const cmd: AgentCommand = {
|
|
43
|
+
id: '3',
|
|
44
|
+
type: 'config',
|
|
45
|
+
payload: { filename: 'agent.yml', content: 'key: val' },
|
|
46
|
+
};
|
|
47
|
+
const res = await handleConfig(cmd);
|
|
48
|
+
|
|
49
|
+
expect(fs.mkdir).toHaveBeenCalledWith('/etc/openclaw', { recursive: true });
|
|
50
|
+
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
51
|
+
expect.stringContaining('agent.yml'),
|
|
52
|
+
'key: val',
|
|
53
|
+
'utf-8',
|
|
54
|
+
);
|
|
55
|
+
expect(mockExec).toHaveBeenCalledWith(
|
|
56
|
+
'openclaw gateway restart',
|
|
57
|
+
expect.any(Object),
|
|
58
|
+
expect.any(Function),
|
|
59
|
+
);
|
|
60
|
+
expect(res.success).toBe(true);
|
|
61
|
+
expect(res.data?.path).toContain('agent.yml');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns error when writeFile fails', async () => {
|
|
65
|
+
mockWriteFile.mockRejectedValue(new Error('EACCES: permission denied'));
|
|
66
|
+
const cmd: AgentCommand = {
|
|
67
|
+
id: '4',
|
|
68
|
+
type: 'config',
|
|
69
|
+
payload: { filename: 'agent.yml', content: 'key: val' },
|
|
70
|
+
};
|
|
71
|
+
const res = await handleConfig(cmd);
|
|
72
|
+
expect(res.success).toBe(false);
|
|
73
|
+
expect(res.error).toContain('EACCES');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns success:false with error message when restart fails', async () => {
|
|
77
|
+
fakeExec(new Error('restart failed'), '', 'stderr output');
|
|
78
|
+
const cmd: AgentCommand = {
|
|
79
|
+
id: '5',
|
|
80
|
+
type: 'config',
|
|
81
|
+
payload: { filename: 'agent.yml', content: 'key: val' },
|
|
82
|
+
};
|
|
83
|
+
const res = await handleConfig(cmd);
|
|
84
|
+
expect(res.success).toBe(false);
|
|
85
|
+
expect(res.error).toBe('restart failed');
|
|
86
|
+
expect(res.data?.path).toContain('agent.yml');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('strips directory traversal from filename', async () => {
|
|
90
|
+
fakeExec(null, '', '');
|
|
91
|
+
const cmd: AgentCommand = {
|
|
92
|
+
id: '6',
|
|
93
|
+
type: 'config',
|
|
94
|
+
payload: { filename: '../../../etc/passwd', content: 'root:x:0:0' },
|
|
95
|
+
};
|
|
96
|
+
const res = await handleConfig(cmd);
|
|
97
|
+
// path.basename strips the directory part
|
|
98
|
+
expect(res.data?.path).toBe('/etc/openclaw/passwd');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -5,6 +5,11 @@ jest.mock('ws', () => jest.fn());
|
|
|
5
5
|
|
|
6
6
|
const MockCentrifuge = Centrifuge as jest.MockedClass<typeof Centrifuge>;
|
|
7
7
|
|
|
8
|
+
const BACKEND_URL = 'http://backend:3000';
|
|
9
|
+
const AGENT_TOKEN = 'agent-token';
|
|
10
|
+
const AGENT_ID = 'test-1';
|
|
11
|
+
const JWT = 'centrifugo.jwt.token';
|
|
12
|
+
|
|
8
13
|
// Mock subscription instance
|
|
9
14
|
function createMockSubscription(channel: string) {
|
|
10
15
|
const handlers: Record<string, Function> = {};
|
|
@@ -29,37 +34,179 @@ function createMockClient(mockSubs: ReturnType<typeof createMockSubscription>[])
|
|
|
29
34
|
};
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
function mockFetchOk(token = JWT) {
|
|
38
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
39
|
+
ok: true,
|
|
40
|
+
json: () => Promise.resolve({ token }),
|
|
41
|
+
} as unknown as Response);
|
|
42
|
+
}
|
|
43
|
+
|
|
32
44
|
beforeEach(() => {
|
|
33
45
|
jest.clearAllMocks();
|
|
46
|
+
mockFetchOk();
|
|
34
47
|
});
|
|
35
48
|
|
|
36
49
|
describe('createConnection', () => {
|
|
37
|
-
it('
|
|
38
|
-
const commandSub = createMockSubscription(
|
|
50
|
+
it('passes getToken (not raw token) to Centrifuge when backendUrl is provided', async () => {
|
|
51
|
+
const commandSub = createMockSubscription(`agent:${AGENT_ID}`);
|
|
39
52
|
const mockClient = createMockClient([commandSub]);
|
|
40
53
|
MockCentrifuge.mockImplementation(() => mockClient as any);
|
|
41
54
|
|
|
42
55
|
const { createConnection } = await import('../src/connection');
|
|
43
56
|
|
|
44
|
-
|
|
57
|
+
createConnection({
|
|
45
58
|
url: 'ws://localhost:8000/connection/websocket',
|
|
46
|
-
token:
|
|
47
|
-
agentId:
|
|
59
|
+
token: AGENT_TOKEN,
|
|
60
|
+
agentId: AGENT_ID,
|
|
61
|
+
backendUrl: BACKEND_URL,
|
|
48
62
|
});
|
|
49
63
|
|
|
50
64
|
expect(MockCentrifuge).toHaveBeenCalledWith(
|
|
51
65
|
'ws://localhost:8000/connection/websocket',
|
|
52
|
-
expect.objectContaining({
|
|
66
|
+
expect.objectContaining({ getToken: expect.any(Function) })
|
|
53
67
|
);
|
|
54
|
-
|
|
68
|
+
// Must NOT pass raw token as a field
|
|
69
|
+
const [[, opts]] = MockCentrifuge.mock.calls;
|
|
70
|
+
expect(opts).not.toHaveProperty('token');
|
|
71
|
+
|
|
72
|
+
expect(mockClient.newSubscription).toHaveBeenCalledWith(`agent:${AGENT_ID}`);
|
|
55
73
|
expect(mockClient.newSubscription).toHaveBeenCalledTimes(1);
|
|
56
74
|
expect(commandSub.subscribe).toHaveBeenCalled();
|
|
57
75
|
expect(mockClient.connect).toHaveBeenCalled();
|
|
58
76
|
expect(commandSub.on).toHaveBeenCalledWith('publication', expect.any(Function));
|
|
59
77
|
});
|
|
60
78
|
|
|
79
|
+
it('getToken fetches JWT from backend with correct headers', async () => {
|
|
80
|
+
const commandSub = createMockSubscription(`agent:${AGENT_ID}`);
|
|
81
|
+
const mockClient = createMockClient([commandSub]);
|
|
82
|
+
let capturedGetToken: (() => Promise<string>) | undefined;
|
|
83
|
+
MockCentrifuge.mockImplementation((_url, opts: any) => {
|
|
84
|
+
capturedGetToken = opts.getToken;
|
|
85
|
+
return mockClient as any;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const { createConnection } = await import('../src/connection');
|
|
89
|
+
createConnection({
|
|
90
|
+
url: 'ws://localhost:8000/connection/websocket',
|
|
91
|
+
token: AGENT_TOKEN,
|
|
92
|
+
agentId: AGENT_ID,
|
|
93
|
+
backendUrl: BACKEND_URL,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(capturedGetToken).toBeDefined();
|
|
97
|
+
const result = await capturedGetToken!();
|
|
98
|
+
|
|
99
|
+
expect(result).toBe(JWT);
|
|
100
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
101
|
+
`${BACKEND_URL}/api/internal/centrifugo-token`,
|
|
102
|
+
expect.objectContaining({
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: expect.objectContaining({
|
|
105
|
+
'Authorization': `Bearer ${AGENT_TOKEN}`,
|
|
106
|
+
'Content-Type': 'application/json',
|
|
107
|
+
}),
|
|
108
|
+
body: JSON.stringify({ agentId: AGENT_ID }),
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('getToken retries once and succeeds on second attempt', async () => {
|
|
114
|
+
const commandSub = createMockSubscription(`agent:${AGENT_ID}`);
|
|
115
|
+
const mockClient = createMockClient([commandSub]);
|
|
116
|
+
let capturedGetToken: (() => Promise<string>) | undefined;
|
|
117
|
+
MockCentrifuge.mockImplementation((_url, opts: any) => {
|
|
118
|
+
capturedGetToken = opts.getToken;
|
|
119
|
+
return mockClient as any;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// First call fails, second succeeds
|
|
123
|
+
global.fetch = jest.fn()
|
|
124
|
+
.mockRejectedValueOnce(new Error('network error'))
|
|
125
|
+
.mockResolvedValueOnce({
|
|
126
|
+
ok: true,
|
|
127
|
+
json: () => Promise.resolve({ token: JWT }),
|
|
128
|
+
} as unknown as Response);
|
|
129
|
+
|
|
130
|
+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
131
|
+
|
|
132
|
+
const { createConnection } = await import('../src/connection');
|
|
133
|
+
createConnection({
|
|
134
|
+
url: 'ws://localhost:8000/connection/websocket',
|
|
135
|
+
token: AGENT_TOKEN,
|
|
136
|
+
agentId: AGENT_ID,
|
|
137
|
+
backendUrl: BACKEND_URL,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const result = await capturedGetToken!();
|
|
141
|
+
expect(result).toBe(JWT);
|
|
142
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
143
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
144
|
+
expect.stringContaining('Failed to fetch Centrifugo JWT'),
|
|
145
|
+
expect.anything()
|
|
146
|
+
);
|
|
147
|
+
consoleSpy.mockRestore();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('getToken throws after both attempts fail', async () => {
|
|
151
|
+
const commandSub = createMockSubscription(`agent:${AGENT_ID}`);
|
|
152
|
+
const mockClient = createMockClient([commandSub]);
|
|
153
|
+
let capturedGetToken: (() => Promise<string>) | undefined;
|
|
154
|
+
MockCentrifuge.mockImplementation((_url, opts: any) => {
|
|
155
|
+
capturedGetToken = opts.getToken;
|
|
156
|
+
return mockClient as any;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const fetchError = new Error('network down');
|
|
160
|
+
global.fetch = jest.fn().mockRejectedValue(fetchError);
|
|
161
|
+
|
|
162
|
+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
163
|
+
|
|
164
|
+
const { createConnection } = await import('../src/connection');
|
|
165
|
+
createConnection({
|
|
166
|
+
url: 'ws://localhost:8000/connection/websocket',
|
|
167
|
+
token: AGENT_TOKEN,
|
|
168
|
+
agentId: AGENT_ID,
|
|
169
|
+
backendUrl: BACKEND_URL,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await expect(capturedGetToken!()).rejects.toThrow('network down');
|
|
173
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
174
|
+
consoleSpy.mockRestore();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('getToken throws when backend returns non-ok HTTP status', async () => {
|
|
178
|
+
const commandSub = createMockSubscription(`agent:${AGENT_ID}`);
|
|
179
|
+
const mockClient = createMockClient([commandSub]);
|
|
180
|
+
let capturedGetToken: (() => Promise<string>) | undefined;
|
|
181
|
+
MockCentrifuge.mockImplementation((_url, opts: any) => {
|
|
182
|
+
capturedGetToken = opts.getToken;
|
|
183
|
+
return mockClient as any;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
187
|
+
ok: false,
|
|
188
|
+
status: 401,
|
|
189
|
+
text: () => Promise.resolve('Unauthorized'),
|
|
190
|
+
} as unknown as Response);
|
|
191
|
+
|
|
192
|
+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
193
|
+
|
|
194
|
+
const { createConnection } = await import('../src/connection');
|
|
195
|
+
createConnection({
|
|
196
|
+
url: 'ws://localhost:8000/connection/websocket',
|
|
197
|
+
token: AGENT_TOKEN,
|
|
198
|
+
agentId: AGENT_ID,
|
|
199
|
+
backendUrl: BACKEND_URL,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await expect(capturedGetToken!()).rejects.toThrow('HTTP 401');
|
|
203
|
+
// Both attempts return 401 → called twice
|
|
204
|
+
expect(global.fetch).toHaveBeenCalledTimes(2);
|
|
205
|
+
consoleSpy.mockRestore();
|
|
206
|
+
});
|
|
207
|
+
|
|
61
208
|
it('routes exec command to handler', async () => {
|
|
62
|
-
const commandSub = createMockSubscription(
|
|
209
|
+
const commandSub = createMockSubscription(`agent:${AGENT_ID}`);
|
|
63
210
|
const mockClient = createMockClient([commandSub]);
|
|
64
211
|
MockCentrifuge.mockImplementation(() => mockClient as any);
|
|
65
212
|
|
|
@@ -70,7 +217,12 @@ describe('createConnection', () => {
|
|
|
70
217
|
}));
|
|
71
218
|
|
|
72
219
|
const { createConnection } = await import('../src/connection');
|
|
73
|
-
createConnection({
|
|
220
|
+
createConnection({
|
|
221
|
+
url: 'ws://localhost:8000/connection/websocket',
|
|
222
|
+
token: AGENT_TOKEN,
|
|
223
|
+
agentId: AGENT_ID,
|
|
224
|
+
backendUrl: BACKEND_URL,
|
|
225
|
+
});
|
|
74
226
|
|
|
75
227
|
// Simulate a publication on the command channel
|
|
76
228
|
const publicationHandler = commandSub._handlers['publication'];
|
|
@@ -78,30 +230,35 @@ describe('createConnection', () => {
|
|
|
78
230
|
|
|
79
231
|
await publicationHandler({
|
|
80
232
|
data: { id: 'cmd-1', type: 'exec', payload: { command: 'echo hi' } },
|
|
81
|
-
channel:
|
|
233
|
+
channel: `agent:${AGENT_ID}`,
|
|
82
234
|
});
|
|
83
235
|
|
|
84
236
|
// Should have published a response to the agent channel
|
|
85
237
|
expect(mockClient.publish).toHaveBeenCalledWith(
|
|
86
|
-
|
|
238
|
+
`agent:${AGENT_ID}`,
|
|
87
239
|
expect.objectContaining({ id: 'cmd-1', type: 'exec' })
|
|
88
240
|
);
|
|
89
241
|
});
|
|
90
242
|
|
|
91
243
|
it('handles unknown command types gracefully', async () => {
|
|
92
|
-
const commandSub = createMockSubscription(
|
|
244
|
+
const commandSub = createMockSubscription(`agent:${AGENT_ID}`);
|
|
93
245
|
const mockClient = createMockClient([commandSub]);
|
|
94
246
|
MockCentrifuge.mockImplementation(() => mockClient as any);
|
|
95
247
|
|
|
96
248
|
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
97
249
|
|
|
98
250
|
const { createConnection } = await import('../src/connection');
|
|
99
|
-
createConnection({
|
|
251
|
+
createConnection({
|
|
252
|
+
url: 'ws://localhost:8000/connection/websocket',
|
|
253
|
+
token: AGENT_TOKEN,
|
|
254
|
+
agentId: AGENT_ID,
|
|
255
|
+
backendUrl: BACKEND_URL,
|
|
256
|
+
});
|
|
100
257
|
|
|
101
258
|
const publicationHandler = commandSub._handlers['publication'];
|
|
102
259
|
await publicationHandler({
|
|
103
260
|
data: { id: 'cmd-2', type: 'unknown_cmd', payload: {} },
|
|
104
|
-
channel:
|
|
261
|
+
channel: `agent:${AGENT_ID}`,
|
|
105
262
|
});
|
|
106
263
|
|
|
107
264
|
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Unknown command type'));
|