@openclaw-cloud/agent-controller 0.1.3 → 0.1.5
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/CLAUDE.md +59 -0
- package/__tests__/api.test.ts +123 -0
- package/__tests__/board-handler.test.ts +6 -3
- package/__tests__/chat.test.ts +191 -0
- package/__tests__/config.test.ts +100 -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.js +24 -0
- 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-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 +18 -3
- 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 +25 -0
- package/src/handlers/board-handler.ts +5 -2
- package/src/handlers/chat.ts +79 -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 +19 -3
- package/src/openclaw/gateway-adapter.ts +129 -0
- package/src/openclaw/gateway-client.ts +131 -0
- package/src/openclaw/index.ts +17 -0
- package/src/openclaw/types.ts +41 -0
- package/src/types.ts +5 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
npx tsc --noEmit
|
package/CLAUDE.md
CHANGED
|
@@ -1,5 +1,64 @@
|
|
|
1
1
|
# CLAUDE.md — Agent Controller Context
|
|
2
2
|
|
|
3
|
+
Review this plan thoroughly before making any code changes. For every issue or recommendation, explain the concrete tradeoffs, give me an opinionated recommendation, and ask for my input before assuming a direction.
|
|
4
|
+
|
|
5
|
+
My engineering preferences (use these to guide your recommendations):
|
|
6
|
+
|
|
7
|
+
• DRY is important—flag repetition aggressively.
|
|
8
|
+
• Well-tested code is non-negotiable; I'd rather have too many tests than too few.
|
|
9
|
+
• I want code that's "engineered enough" — not under-engineered (fragile, hacky) and not over-engineered (premature abstraction, unnecessary complexity).
|
|
10
|
+
• I err on the side of handling more edge cases, not fewer; thoughtfulness > speed.
|
|
11
|
+
• Bias toward explicit over clever.
|
|
12
|
+
|
|
13
|
+
1. Architecture review
|
|
14
|
+
Evaluate:
|
|
15
|
+
|
|
16
|
+
• Overall system design and component boundaries.
|
|
17
|
+
• Dependency graph and coupling concerns.
|
|
18
|
+
• Data flow patterns and potential bottlenecks.
|
|
19
|
+
• Scaling characteristics and single points of failure.
|
|
20
|
+
• Security architecture (auth, data access, API boundaries).
|
|
21
|
+
2. Code quality review
|
|
22
|
+
Evaluate:
|
|
23
|
+
|
|
24
|
+
• Code organization and module structure.
|
|
25
|
+
• DRY violations—be aggressive here.
|
|
26
|
+
• Error handling patterns and missing edge cases (call these out explicitly).
|
|
27
|
+
• Technical debt hotspots.
|
|
28
|
+
• Areas that are over-engineered or under-engineered relative to my preferences.
|
|
29
|
+
3. Test review
|
|
30
|
+
Evaluate:
|
|
31
|
+
|
|
32
|
+
• Test coverage gaps (unit, integration, e2e).
|
|
33
|
+
• Test quality and assertion strength.
|
|
34
|
+
• Missing edge case coverage—be thorough.
|
|
35
|
+
• Untested failure modes and error paths.
|
|
36
|
+
4. Performance review
|
|
37
|
+
Evaluate:
|
|
38
|
+
|
|
39
|
+
• N+1 queries and database access patterns.
|
|
40
|
+
• Memory-usage concerns.
|
|
41
|
+
• Caching opportunities.
|
|
42
|
+
• Slow or high-complexity code paths.
|
|
43
|
+
For each issue you find
|
|
44
|
+
For every specific issue (bug, smell, design concern, or risk):
|
|
45
|
+
|
|
46
|
+
• Describe the problem concretely, with file and line references.
|
|
47
|
+
• Present 2–3 options, including "do nothing" where that's reasonable.
|
|
48
|
+
• For each option, specify: implementation effort, risk, impact on other code, and maintenance burden.
|
|
49
|
+
• Give me your recommended option and why, mapped to my preferences above.
|
|
50
|
+
• Then explicitly ask whether I agree or want to choose a different direction before proceeding.
|
|
51
|
+
Workflow and interaction
|
|
52
|
+
|
|
53
|
+
• Do not assume my priorities on timeline or scale.
|
|
54
|
+
• After each section, pause and ask for my feedback before moving on.
|
|
55
|
+
BEFORE YOU START:
|
|
56
|
+
Ask if I want one of two options:
|
|
57
|
+
1/ BIG CHANGE: Work through this interactively, one section at a time (Architecture → Code Quality → Tests → Performance) with at most 4 top issues in each section.
|
|
58
|
+
2/ SMALL CHANGE: Work through interactively ONE question per review section
|
|
59
|
+
|
|
60
|
+
FOR EACH STAGE OF REVIEW: output the explanation and pros and cons of each stage's questions AND your opinionated recommendation and why, and then use AskUserQuestion. Also NUMBER issues and then give LETTERS for options and when using AskUserQuestion make sure each option clearly labels the issue NUMBER and option LETTER so the user doesn't get confused. Make the recommended option always the 1st option.
|
|
61
|
+
|
|
3
62
|
## Quick Reference
|
|
4
63
|
|
|
5
64
|
```bash
|
|
@@ -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
|
+
});
|
|
@@ -16,12 +16,14 @@ function mockApi(overrides: Partial<AgentApi> = {}): AgentApi {
|
|
|
16
16
|
|
|
17
17
|
function makeBoardInfo(opts: Partial<{
|
|
18
18
|
boardId: string;
|
|
19
|
+
workspaceId: string;
|
|
19
20
|
myColumnIds: string[];
|
|
20
21
|
columns: BoardInfo['board']['columns'];
|
|
21
22
|
}> = {}): BoardInfo {
|
|
22
23
|
return {
|
|
23
24
|
board: {
|
|
24
25
|
id: opts.boardId ?? 'board-1',
|
|
26
|
+
workspaceId: opts.workspaceId ?? 'ws-1',
|
|
25
27
|
myColumnIds: opts.myColumnIds ?? ['col-1'],
|
|
26
28
|
columns: opts.columns ?? [{
|
|
27
29
|
id: 'col-1',
|
|
@@ -70,13 +72,14 @@ describe('BoardHandler', () => {
|
|
|
70
72
|
});
|
|
71
73
|
|
|
72
74
|
describe('initialize', () => {
|
|
73
|
-
it('sets boardId and myColumnIds from API response', async () => {
|
|
74
|
-
const info = makeBoardInfo({ boardId: 'b-42', myColumnIds: ['c-1', 'c-2'] });
|
|
75
|
+
it('sets boardId, workspaceId and myColumnIds from API response', async () => {
|
|
76
|
+
const info = makeBoardInfo({ boardId: 'b-42', workspaceId: 'ws-99', myColumnIds: ['c-1', 'c-2'] });
|
|
75
77
|
(api.get as jest.Mock).mockResolvedValue(info);
|
|
76
78
|
|
|
77
79
|
const result = await handler.initialize();
|
|
78
80
|
|
|
79
|
-
|
|
81
|
+
// initialize() returns workspaceId for channel subscription
|
|
82
|
+
expect(result).toBe('ws-99');
|
|
80
83
|
expect(handler.getBoardId()).toBe('b-42');
|
|
81
84
|
expect(api.get).toHaveBeenCalledWith('/api/agent/board');
|
|
82
85
|
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { handleFileWrite } from '../src/handlers/file-write';
|
|
2
|
+
import type { AgentCommand } from '../src/types';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
jest.mock('node:fs/promises');
|
|
6
|
+
|
|
7
|
+
const mockMkdir = fs.mkdir as jest.Mock;
|
|
8
|
+
const mockWriteFile = fs.writeFile as jest.Mock;
|
|
9
|
+
const mockAccess = fs.access as jest.Mock;
|
|
10
|
+
|
|
11
|
+
const baseCmd: AgentCommand = { id: '1', type: 'file_write', payload: {} };
|
|
12
|
+
|
|
13
|
+
describe('handleFileWrite', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
jest.clearAllMocks();
|
|
16
|
+
mockMkdir.mockResolvedValue(undefined);
|
|
17
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('writes file and returns written:true', async () => {
|
|
21
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: 'config/agent.yml', content: 'key: val' } };
|
|
22
|
+
const res = await handleFileWrite(cmd);
|
|
23
|
+
expect(res.success).toBe(true);
|
|
24
|
+
expect(res.data?.written).toBe(true);
|
|
25
|
+
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
26
|
+
expect.stringContaining('agent.yml'),
|
|
27
|
+
'key: val',
|
|
28
|
+
'utf-8',
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('skips write when overwrite=false and file already exists', async () => {
|
|
33
|
+
mockAccess.mockResolvedValue(undefined); // file exists
|
|
34
|
+
const cmd: AgentCommand = {
|
|
35
|
+
...baseCmd,
|
|
36
|
+
payload: { path: 'config/agent.yml', content: 'key: val', overwrite: false },
|
|
37
|
+
};
|
|
38
|
+
const res = await handleFileWrite(cmd);
|
|
39
|
+
expect(res.success).toBe(true);
|
|
40
|
+
expect(res.data?.written).toBe(false);
|
|
41
|
+
expect(res.data?.skipped).toBe(true);
|
|
42
|
+
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('writes when overwrite=false and file does not exist', async () => {
|
|
46
|
+
mockAccess.mockRejectedValue(new Error('ENOENT'));
|
|
47
|
+
const cmd: AgentCommand = {
|
|
48
|
+
...baseCmd,
|
|
49
|
+
payload: { path: 'config/new.yml', content: 'data', overwrite: false },
|
|
50
|
+
};
|
|
51
|
+
const res = await handleFileWrite(cmd);
|
|
52
|
+
expect(res.success).toBe(true);
|
|
53
|
+
expect(res.data?.written).toBe(true);
|
|
54
|
+
expect(mockWriteFile).toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rejects path traversal with ".."', async () => {
|
|
58
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: '../etc/passwd', content: 'root' } };
|
|
59
|
+
const res = await handleFileWrite(cmd);
|
|
60
|
+
expect(res.success).toBe(false);
|
|
61
|
+
expect(res.error).toContain('Invalid path');
|
|
62
|
+
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('rejects nested path traversal (foo/../bar)', async () => {
|
|
66
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: 'foo/../bar/secret', content: 'x' } };
|
|
67
|
+
const res = await handleFileWrite(cmd);
|
|
68
|
+
expect(res.success).toBe(false);
|
|
69
|
+
expect(res.error).toContain('Invalid path');
|
|
70
|
+
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('rejects absolute path starting with "/"', async () => {
|
|
74
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: '/etc/passwd', content: 'root' } };
|
|
75
|
+
const res = await handleFileWrite(cmd);
|
|
76
|
+
expect(res.success).toBe(false);
|
|
77
|
+
expect(res.error).toContain('Invalid path');
|
|
78
|
+
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('creates parent directories before writing', async () => {
|
|
82
|
+
const cmd: AgentCommand = {
|
|
83
|
+
...baseCmd,
|
|
84
|
+
payload: { path: 'deep/nested/dir/file.txt', content: 'hello' },
|
|
85
|
+
};
|
|
86
|
+
const res = await handleFileWrite(cmd);
|
|
87
|
+
expect(res.success).toBe(true);
|
|
88
|
+
expect(mockMkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns error when path is missing', async () => {
|
|
92
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { content: 'data' } };
|
|
93
|
+
const res = await handleFileWrite(cmd);
|
|
94
|
+
expect(res.success).toBe(false);
|
|
95
|
+
expect(res.error).toContain('Missing');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns error when content is missing', async () => {
|
|
99
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: 'file.txt' } };
|
|
100
|
+
const res = await handleFileWrite(cmd);
|
|
101
|
+
expect(res.success).toBe(false);
|
|
102
|
+
expect(res.error).toContain('Missing');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('allows empty string content', async () => {
|
|
106
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: 'empty.txt', content: '' } };
|
|
107
|
+
const res = await handleFileWrite(cmd);
|
|
108
|
+
expect(res.success).toBe(true);
|
|
109
|
+
expect(mockWriteFile).toHaveBeenCalledWith(expect.any(String), '', 'utf-8');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns error when writeFile fails', async () => {
|
|
113
|
+
mockWriteFile.mockRejectedValue(new Error('EACCES: permission denied'));
|
|
114
|
+
const cmd: AgentCommand = { ...baseCmd, payload: { path: 'file.txt', content: 'data' } };
|
|
115
|
+
const res = await handleFileWrite(cmd);
|
|
116
|
+
expect(res.success).toBe(false);
|
|
117
|
+
expect(res.error).toContain('EACCES');
|
|
118
|
+
});
|
|
119
|
+
});
|