@openclaw-cloud/agent-controller 0.2.6 → 0.2.7

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.
Files changed (76) hide show
  1. package/dist/commands/install.js +3 -0
  2. package/dist/commands/install.js.map +1 -1
  3. package/dist/config-file.d.ts +9 -0
  4. package/dist/config-file.js +47 -0
  5. package/dist/config-file.js.map +1 -0
  6. package/dist/connection.d.ts +1 -0
  7. package/dist/connection.js +27 -13
  8. package/dist/connection.js.map +1 -1
  9. package/dist/handlers/backup.js +7 -2
  10. package/dist/handlers/backup.js.map +1 -1
  11. package/dist/handlers/knowledge-sync.d.ts +2 -0
  12. package/dist/handlers/knowledge-sync.js +51 -0
  13. package/dist/handlers/knowledge-sync.js.map +1 -0
  14. package/dist/heartbeat.d.ts +1 -0
  15. package/dist/heartbeat.js +30 -0
  16. package/dist/heartbeat.js.map +1 -1
  17. package/dist/index.js +7 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/types.d.ts +2 -1
  20. package/package.json +6 -1
  21. package/.claude/cc-notify.sh +0 -32
  22. package/.claude/settings.json +0 -31
  23. package/.husky/pre-commit +0 -1
  24. package/BIZPLAN.md +0 -530
  25. package/CLAUDE.md +0 -172
  26. package/Dockerfile +0 -9
  27. package/__tests__/api.test.ts +0 -183
  28. package/__tests__/backup.test.ts +0 -145
  29. package/__tests__/board-handler.test.ts +0 -323
  30. package/__tests__/chat.test.ts +0 -191
  31. package/__tests__/config.test.ts +0 -100
  32. package/__tests__/connection.test.ts +0 -289
  33. package/__tests__/file-delete.test.ts +0 -90
  34. package/__tests__/file-write.test.ts +0 -119
  35. package/__tests__/gateway-adapter.test.ts +0 -366
  36. package/__tests__/gateway-client.test.ts +0 -272
  37. package/__tests__/handlers.test.ts +0 -150
  38. package/__tests__/heartbeat.test.ts +0 -124
  39. package/__tests__/onboarding.test.ts +0 -55
  40. package/__tests__/package-install.test.ts +0 -109
  41. package/__tests__/pair.test.ts +0 -60
  42. package/__tests__/self-update.test.ts +0 -123
  43. package/__tests__/stop.test.ts +0 -38
  44. package/jest.config.ts +0 -16
  45. package/src/api.ts +0 -62
  46. package/src/commands/install.ts +0 -68
  47. package/src/commands/self-update.ts +0 -43
  48. package/src/commands/uninstall.ts +0 -19
  49. package/src/config-file.ts +0 -56
  50. package/src/connection.ts +0 -203
  51. package/src/debug.ts +0 -11
  52. package/src/handlers/backup.ts +0 -101
  53. package/src/handlers/board-handler.ts +0 -155
  54. package/src/handlers/chat.ts +0 -79
  55. package/src/handlers/config.ts +0 -48
  56. package/src/handlers/deploy.ts +0 -32
  57. package/src/handlers/exec.ts +0 -32
  58. package/src/handlers/file-delete.ts +0 -46
  59. package/src/handlers/file-write.ts +0 -65
  60. package/src/handlers/knowledge-sync.ts +0 -53
  61. package/src/handlers/onboarding.ts +0 -19
  62. package/src/handlers/package-install.ts +0 -69
  63. package/src/handlers/pair.ts +0 -26
  64. package/src/handlers/restart.ts +0 -19
  65. package/src/handlers/stop.ts +0 -17
  66. package/src/heartbeat.ts +0 -110
  67. package/src/index.ts +0 -97
  68. package/src/openclaw/gateway-adapter.ts +0 -129
  69. package/src/openclaw/gateway-client.ts +0 -131
  70. package/src/openclaw/index.ts +0 -17
  71. package/src/openclaw/types.ts +0 -41
  72. package/src/platform/linux.ts +0 -108
  73. package/src/platform/macos.ts +0 -122
  74. package/src/platform/windows.ts +0 -92
  75. package/src/types.ts +0 -94
  76. package/tsconfig.json +0 -18
package/CLAUDE.md DELETED
@@ -1,172 +0,0 @@
1
- # CLAUDE.md — Agent Controller Context
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
-
62
- ## Codebase Navigation
63
-
64
- ### Project Structure
65
- ```
66
- src/
67
- ├── index.ts # Entry point — parse args, start connection
68
- ├── connection.ts # Centrifugo WS client, command dispatcher
69
- ├── heartbeat.ts # Periodic heartbeat publisher
70
- ├── api.ts # HTTP client for backend API calls
71
- ├── types.ts # AgentCommand, AgentResponse, CommandType
72
- ├── handlers/ # Command handlers (backend → agent)
73
- │ ├── file-write.ts # Write file to workspace
74
- │ ├── file-delete.ts # Delete file from workspace
75
- │ ├── package-install.ts # Install apt/npm/pip packages
76
- │ ├── restart.ts # Restart openclaw gateway
77
- │ ├── stop.ts # Stop agent
78
- │ ├── backup.ts # Create workspace backup
79
- │ ├── config.ts # Update config files
80
- │ ├── deploy.ts # Deploy/redeploy
81
- │ ├── exec.ts # Execute shell commands
82
- │ ├── chat.ts # Chat relay
83
- │ ├── onboarding.ts # Onboarding completion
84
- │ ├── pair.ts # Node pairing
85
- │ ├── board-handler.ts # Kanban board integration
86
- │ └── self-update.ts # Self-update via npm
87
- ├── commands/ # CLI subcommands (agent-controller <cmd>)
88
- │ └── self-update.ts # `agent-controller self-update`
89
- └── openclaw/ # OpenClaw gateway process management
90
- ```
91
-
92
- ### Command dispatch flow
93
- ```
94
- Centrifugo WS message → connection.ts (handler map) → handlers/<type>.ts → AgentResponse → POST /api/internal/agent-response
95
- ```
96
-
97
- Handler map in `connection.ts`:
98
- ```ts
99
- const handlers: Record<CommandType, Handler> = {
100
- package_install: handlePackageInstall,
101
- file_write: handleFileWrite,
102
- file_delete: handleFileDelete,
103
- restart: handleRestart,
104
- // ...
105
- };
106
- ```
107
-
108
- ### Adding a new command handler
109
- 1. Create `src/handlers/my-handler.ts` — export `async function handleMyHandler(cmd: AgentCommand): Promise<AgentResponse>`
110
- 2. Add `'my_handler'` to `CommandType` union in `src/types.ts`
111
- 3. Register in handler map in `src/connection.ts`
112
- 4. Backend sends via `container.centrifugo.publishCommand(agentId, { type: 'my_handler', id, payload })`
113
-
114
- ### Known Issues & Pitfalls
115
- - **WORKSPACE_DIR**: Defaults to `/etc/openclaw/workspace`. All file operations are sandboxed here (no `..`, no absolute paths).
116
- - **Command names must match backend exactly**: e.g. `package_install` not `install_packages`. Mismatches silently drop.
117
- - **npm publish required**: After changes, bump version in `package.json`, `npm publish`, then update agent Dockerfile + user-data.ts to pull new version.
118
- - **PID 1 kill**: If agent-controller crashes, it kills PID 1 to force K8s pod restart. Don't swallow fatal errors.
119
-
120
- ## Quick Reference
121
-
122
- ```bash
123
- npx jest --forceExit # Run tests
124
- npx tsc --noEmit # Type check
125
- npm run build # Build
126
- ```
127
-
128
- ## Architecture
129
-
130
- Agent-controller is a Node.js process that runs inside every agent container (K8s pod) or VM. It's the single communication interface between the agent and the backend.
131
-
132
- ### Communication Flow
133
- ```
134
- Backend → Centrifugo WS (port 8000) → agent-controller → local agent process
135
- agent-controller → Backend HTTP API → /api/internal/agent-response (command responses)
136
- agent-controller → Backend HTTP API → /api/internal/agent-heartbeat (heartbeat)
137
- ```
138
-
139
- Centrifugo WS is **read-only** from agent-controller's perspective — all outbound traffic goes through the backend HTTP API, not directly to Centrifugo.
140
-
141
- ### Key Components
142
- - **WebSocket client** — Connects to Centrifugo, subscribes to agent channel (read-only)
143
- - **Heartbeat publisher** — Sends periodic heartbeats via `POST /api/internal/agent-heartbeat`
144
- - **Command handlers** — Processes commands from backend, sends responses via `POST /api/internal/agent-response`
145
- - **Board handler** — Kanban board integration (claim cards, move cards, report status)
146
- - **JWT auth** — Fetches JWT from backend's `/api/internal/centrifugo-token` endpoint, auto-refreshes before expiry (24h TTL)
147
-
148
- ### Environment Variables
149
- - `AGENT_ID` — UUID of this agent
150
- - `AGENT_TOKEN` — Internal auth token
151
- - `CENTRIFUGO_URL` — WebSocket URL (port 8000, converted from http→ws)
152
- - `API_BASE_URL` — Backend API URL (JWT fetch, agent responses, heartbeat)
153
-
154
- ### Deployment
155
-
156
- Agent-controller is delivered via **npm** (not Docker image copy):
157
-
158
- - **K8s pods**: `npm install -g @openclaw-cloud/agent-controller` in agent Dockerfile (`backend/charts/openclaw-kube/Dockerfile`)
159
- - **VMs**: `npm install -g @openclaw-cloud/agent-controller` in EC2 user-data script (`backend/src/modules/vm-provisioner/user-data.ts`)
160
- - Runs in same container/VM as OpenClaw agent (not sidecar)
161
- - Started via bash wrapper in K8s StatefulSet command
162
- - PID monitoring: if agent-controller dies, `kill 1` terminates container → K8s restarts pod
163
-
164
- ### Docker
165
-
166
- The `Dockerfile` in this repo is for **local development and testing only**. It is NOT used in production. Production installs agent-controller from npm registry.
167
-
168
-
169
- ## Business Context
170
-
171
- For full understanding of all platform components, architecture, deployment models, roadmap, and unit-economics — see `BIZPLAN.md` in this repo.
172
-
package/Dockerfile DELETED
@@ -1,9 +0,0 @@
1
- # Agent Controller — standalone build image
2
- FROM node:22-alpine AS build
3
-
4
- WORKDIR /app
5
- COPY package.json package-lock.json ./
6
- RUN npm ci --omit=dev
7
- COPY dist/ dist/
8
- COPY bin/ bin/
9
- RUN chmod +x bin/agent-controller.js
@@ -1,183 +0,0 @@
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
- });
124
-
125
- describe('createAgentApi – publishResponse()', () => {
126
- it('POSTs to /api/internal/agent-response with agentId and data', async () => {
127
- const api = createAgentApi('http://backend', 'tok');
128
- mockFetch.mockResolvedValue(makeOkResponse(null));
129
-
130
- await api.publishResponse('agent-1', { id: 'cmd-1', type: 'exec', success: true });
131
-
132
- const [url, init] = mockFetch.mock.calls[0];
133
- expect(url).toBe('http://backend/api/internal/agent-response');
134
- expect(init.method).toBe('POST');
135
- expect(JSON.parse(init.body)).toEqual({
136
- agentId: 'agent-1',
137
- data: { id: 'cmd-1', type: 'exec', success: true },
138
- });
139
- });
140
-
141
- it('throws when response is not ok', async () => {
142
- const api = createAgentApi('http://backend', 'tok');
143
- mockFetch.mockResolvedValue(makeErrorResponse(503));
144
-
145
- await expect(api.publishResponse('agent-1', {})).rejects.toThrow('HTTP 503');
146
- });
147
-
148
- it('propagates network errors', async () => {
149
- const api = createAgentApi('http://backend', 'tok');
150
- mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
151
-
152
- await expect(api.publishResponse('agent-1', {})).rejects.toThrow('ECONNREFUSED');
153
- });
154
- });
155
-
156
- describe('createAgentApi – publishHeartbeat()', () => {
157
- it('POSTs to /api/internal/agent-heartbeat with agentId and payload', async () => {
158
- const api = createAgentApi('http://backend', 'tok');
159
- mockFetch.mockResolvedValue(makeOkResponse(null));
160
-
161
- const payload = { type: 'heartbeat', agentId: 'agent-1', ts: 123, metrics: {} };
162
- await api.publishHeartbeat('agent-1', payload);
163
-
164
- const [url, init] = mockFetch.mock.calls[0];
165
- expect(url).toBe('http://backend/api/internal/agent-heartbeat');
166
- expect(init.method).toBe('POST');
167
- expect(JSON.parse(init.body)).toEqual({ agentId: 'agent-1', payload });
168
- });
169
-
170
- it('throws when response is not ok', async () => {
171
- const api = createAgentApi('http://backend', 'tok');
172
- mockFetch.mockResolvedValue(makeErrorResponse(500));
173
-
174
- await expect(api.publishHeartbeat('agent-1', {})).rejects.toThrow('HTTP 500');
175
- });
176
-
177
- it('propagates network errors', async () => {
178
- const api = createAgentApi('http://backend', 'tok');
179
- mockFetch.mockRejectedValue(new Error('timeout'));
180
-
181
- await expect(api.publishHeartbeat('agent-1', {})).rejects.toThrow('timeout');
182
- });
183
- });
@@ -1,145 +0,0 @@
1
- import { handleBackup } from '../src/handlers/backup';
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 mockStat = fs.stat as jest.Mock;
11
- const mockReadFile = fs.readFile as jest.Mock;
12
- const mockUnlink = fs.unlink as jest.Mock;
13
-
14
- function fakeExecSuccess() {
15
- mockExec.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => {
16
- cb(null, Buffer.from(''), Buffer.from(''));
17
- });
18
- }
19
-
20
- function fakeExecError(message: string, stderr = '') {
21
- mockExec.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => {
22
- cb(new Error(message), Buffer.from(''), Buffer.from(stderr));
23
- });
24
- }
25
-
26
- const mockFetch = jest.fn() as jest.Mock;
27
- (globalThis as any).fetch = mockFetch;
28
-
29
- describe('handleBackup', () => {
30
- beforeEach(() => {
31
- jest.clearAllMocks();
32
- mockUnlink.mockResolvedValue(undefined);
33
- });
34
-
35
- it('rejects missing uploadUrl', async () => {
36
- const cmd: AgentCommand = { id: '1', type: 'backup', payload: {} };
37
- const res = await handleBackup(cmd);
38
- expect(res.success).toBe(false);
39
- expect(res.error).toContain('Missing "uploadUrl"');
40
- });
41
-
42
- it('creates tar, uploads, cleans up on success', async () => {
43
- fakeExecSuccess();
44
- mockStat.mockResolvedValue({ size: 1024 });
45
- mockReadFile.mockResolvedValue(Buffer.from('fake-archive'));
46
- mockFetch.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' });
47
-
48
- const cmd: AgentCommand = {
49
- id: '2',
50
- type: 'backup',
51
- payload: { uploadUrl: 'https://backend.test/upload' },
52
- };
53
- const res = await handleBackup(cmd);
54
-
55
- expect(res.success).toBe(true);
56
- expect(res.data?.size).toBe(1024);
57
- expect(res.data?.filename).toMatch(/^backup-\d+\.tar\.gz$/);
58
-
59
- // verify tar command
60
- expect(mockExec).toHaveBeenCalledWith(
61
- expect.stringContaining('tar -czf /tmp/backup-'),
62
- expect.any(Object),
63
- expect.any(Function),
64
- );
65
-
66
- // verify upload
67
- expect(mockFetch).toHaveBeenCalledWith(
68
- 'https://backend.test/upload',
69
- expect.objectContaining({ method: 'POST' }),
70
- );
71
-
72
- // verify cleanup
73
- expect(mockUnlink).toHaveBeenCalledWith(expect.stringContaining('/tmp/backup-'));
74
- });
75
-
76
- it('returns error when tar fails', async () => {
77
- fakeExecError('tar failed', 'No such file or directory');
78
-
79
- const cmd: AgentCommand = {
80
- id: '3',
81
- type: 'backup',
82
- payload: { uploadUrl: 'https://backend.test/upload' },
83
- };
84
- const res = await handleBackup(cmd);
85
-
86
- expect(res.success).toBe(false);
87
- expect(res.error).toContain('tar failed');
88
- expect(mockUnlink).toHaveBeenCalled();
89
- });
90
-
91
- it('returns error when upload fails', async () => {
92
- fakeExecSuccess();
93
- mockStat.mockResolvedValue({ size: 512 });
94
- mockReadFile.mockResolvedValue(Buffer.from('fake'));
95
- mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error', text: () => Promise.resolve('') });
96
-
97
- const cmd: AgentCommand = {
98
- id: '4',
99
- type: 'backup',
100
- payload: { uploadUrl: 'https://backend.test/upload' },
101
- };
102
- const res = await handleBackup(cmd);
103
-
104
- expect(res.success).toBe(false);
105
- expect(res.error).toContain('Upload failed');
106
- expect(res.error).toContain('500');
107
- expect(mockUnlink).toHaveBeenCalled();
108
- });
109
-
110
- it('returns error when fetch throws (network error)', async () => {
111
- fakeExecSuccess();
112
- mockStat.mockResolvedValue({ size: 256 });
113
- mockReadFile.mockResolvedValue(Buffer.from('fake'));
114
- mockFetch.mockRejectedValue(new Error('network error'));
115
-
116
- const cmd: AgentCommand = {
117
- id: '5',
118
- type: 'backup',
119
- payload: { uploadUrl: 'https://backend.test/upload' },
120
- };
121
- const res = await handleBackup(cmd);
122
-
123
- expect(res.success).toBe(false);
124
- expect(res.error).toContain('network error');
125
- expect(mockUnlink).toHaveBeenCalled();
126
- });
127
-
128
- it('cleans up even if unlink fails', async () => {
129
- fakeExecSuccess();
130
- mockStat.mockResolvedValue({ size: 100 });
131
- mockReadFile.mockResolvedValue(Buffer.from('fake'));
132
- mockFetch.mockResolvedValue({ ok: true, status: 200, statusText: 'OK' });
133
- mockUnlink.mockRejectedValue(new Error('ENOENT'));
134
-
135
- const cmd: AgentCommand = {
136
- id: '6',
137
- type: 'backup',
138
- payload: { uploadUrl: 'https://backend.test/upload' },
139
- };
140
- const res = await handleBackup(cmd);
141
-
142
- // should still succeed despite cleanup error
143
- expect(res.success).toBe(true);
144
- });
145
- });