onbuzz 3.6.1 → 3.6.2

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 (83) hide show
  1. package/package.json +1 -1
  2. package/src/__test-utils__/fixtures/malformedJson.js +31 -0
  3. package/src/__test-utils__/globalSetup.js +9 -0
  4. package/src/__test-utils__/globalTeardown.js +12 -0
  5. package/src/__test-utils__/mockFactories.js +101 -0
  6. package/src/analyzers/__tests__/CSSAnalyzer.test.js +41 -0
  7. package/src/analyzers/__tests__/ConfigValidator.test.js +362 -0
  8. package/src/analyzers/__tests__/ESLintAnalyzer.test.js +271 -0
  9. package/src/analyzers/__tests__/JavaScriptAnalyzer.test.js +40 -0
  10. package/src/analyzers/__tests__/PrettierFormatter.test.js +197 -0
  11. package/src/analyzers/__tests__/PythonAnalyzer.test.js +208 -0
  12. package/src/analyzers/__tests__/SecurityAnalyzer.test.js +303 -0
  13. package/src/analyzers/__tests__/SparrowAnalyzer.test.js +270 -0
  14. package/src/analyzers/__tests__/TypeScriptAnalyzer.test.js +187 -0
  15. package/src/core/__tests__/agentPool.test.js +601 -0
  16. package/src/core/__tests__/agentScheduler.test.js +576 -0
  17. package/src/core/__tests__/contextManager.test.js +252 -0
  18. package/src/core/__tests__/flowExecutor.test.js +262 -0
  19. package/src/core/__tests__/messageProcessor.test.js +627 -0
  20. package/src/core/__tests__/orchestrator.test.js +257 -0
  21. package/src/core/__tests__/stateManager.test.js +375 -0
  22. package/src/core/agentPool.js +11 -1
  23. package/src/index.js +25 -9
  24. package/src/interfaces/terminal/__tests__/smoke/imports.test.js +3 -5
  25. package/src/services/__tests__/agentActivityService.test.js +319 -0
  26. package/src/services/__tests__/apiKeyManager.test.js +206 -0
  27. package/src/services/__tests__/benchmarkService.test.js +184 -0
  28. package/src/services/__tests__/budgetService.test.js +211 -0
  29. package/src/services/__tests__/contextInjectionService.test.js +205 -0
  30. package/src/services/__tests__/conversationCompactionService.test.js +280 -0
  31. package/src/services/__tests__/credentialVault.test.js +469 -0
  32. package/src/services/__tests__/errorHandler.test.js +314 -0
  33. package/src/services/__tests__/fileAttachmentService.test.js +278 -0
  34. package/src/services/__tests__/flowContextService.test.js +199 -0
  35. package/src/services/__tests__/memoryService.test.js +450 -0
  36. package/src/services/__tests__/modelRouterService.test.js +388 -0
  37. package/src/services/__tests__/modelsService.test.js +261 -0
  38. package/src/services/__tests__/portRegistry.test.js +123 -0
  39. package/src/services/__tests__/projectDetector.test.js +34 -0
  40. package/src/services/__tests__/promptService.test.js +242 -0
  41. package/src/services/__tests__/qualityInspector.test.js +97 -0
  42. package/src/services/__tests__/scheduleService.test.js +308 -0
  43. package/src/services/__tests__/serviceRegistry.test.js +74 -0
  44. package/src/services/__tests__/skillsService.test.js +402 -0
  45. package/src/services/__tests__/tokenCountingService.test.js +48 -0
  46. package/src/tools/__tests__/agentCommunicationTool.test.js +500 -0
  47. package/src/tools/__tests__/agentDelayTool.test.js +342 -0
  48. package/src/tools/__tests__/asyncToolManager.test.js +344 -0
  49. package/src/tools/__tests__/baseTool.test.js +420 -0
  50. package/src/tools/__tests__/codeMapTool.test.js +348 -0
  51. package/src/tools/__tests__/fileContentReplaceTool.test.js +309 -0
  52. package/src/tools/__tests__/fileTreeTool.test.js +274 -0
  53. package/src/tools/__tests__/filesystemTool.test.js +717 -0
  54. package/src/tools/__tests__/helpTool.test.js +204 -0
  55. package/src/tools/__tests__/jobDoneTool.test.js +296 -0
  56. package/src/tools/__tests__/memoryTool.test.js +297 -0
  57. package/src/tools/__tests__/seekTool.test.js +282 -0
  58. package/src/tools/__tests__/skillsTool.test.js +226 -0
  59. package/src/tools/__tests__/staticAnalysisTool.test.js +509 -0
  60. package/src/tools/__tests__/taskManagerTool.test.js +725 -0
  61. package/src/tools/__tests__/terminalTool.test.js +384 -0
  62. package/src/tools/__tests__/userPromptTool.test.js +297 -0
  63. package/src/tools/__tests__/webTool.e2e.test.js +25 -11
  64. package/src/tools/webTool.js +6 -12
  65. package/src/types/__tests__/agent.test.js +499 -0
  66. package/src/types/__tests__/contextReference.test.js +606 -0
  67. package/src/types/__tests__/conversation.test.js +555 -0
  68. package/src/types/__tests__/toolCommand.test.js +584 -0
  69. package/src/types/contextReference.js +1 -1
  70. package/src/utilities/__tests__/attachmentValidator.test.js +80 -0
  71. package/src/utilities/__tests__/configManager.test.js +397 -0
  72. package/src/utilities/__tests__/constants.test.js +49 -0
  73. package/src/utilities/__tests__/directoryAccessManager.test.js +388 -0
  74. package/src/utilities/__tests__/fileProcessor.test.js +104 -0
  75. package/src/utilities/__tests__/jsonRepair.test.js +104 -0
  76. package/src/utilities/__tests__/logger.test.js +129 -0
  77. package/src/utilities/__tests__/platformUtils.test.js +87 -0
  78. package/src/utilities/__tests__/structuredFileValidator.test.js +263 -0
  79. package/src/utilities/__tests__/tagParser.test.js +887 -0
  80. package/src/utilities/__tests__/toolConstants.test.js +94 -0
  81. package/src/utilities/tagParser.js +2 -2
  82. package/src/tools/browserTool.js +0 -897
  83. package/src/utilities/platformUtils.test.js +0 -98
package/src/index.js CHANGED
@@ -32,8 +32,6 @@ import { ToolsRegistry } from './tools/baseTool.js';
32
32
  import AgentDelayTool from './tools/agentDelayTool.js';
33
33
  import TerminalTool from './tools/terminalTool.js';
34
34
  import FileSystemTool from './tools/fileSystemTool.js';
35
- // BrowserTool is DEPRECATED - use WebTool instead
36
- // import BrowserTool from './tools/browserTool.js';
37
35
  import JobDoneTool from './tools/jobDoneTool.js';
38
36
  import AgentCommunicationTool from './tools/agentCommunicationTool.js';
39
37
  import TaskManagerTool from './tools/taskManagerTool.js';
@@ -486,11 +484,6 @@ class LoxiaApplication {
486
484
  this.logger.info('ToolsRegistry set for Help Tool');
487
485
  }
488
486
 
489
- // NOTE: BrowserTool is DEPRECATED as of December 2024
490
- // Use WebTool (toolId: "web") for all browser automation tasks
491
- // The Browser tool registration has been removed - WebTool provides
492
- // equivalent functionality with better architecture (singleton browser instance)
493
-
494
487
  // Set AgentPool dependency for AgentDelayTool
495
488
  const agentDelayTool = this.toolsRegistry.getTool('agentdelay');
496
489
  if (agentDelayTool && typeof agentDelayTool.setAgentPool === 'function') {
@@ -848,9 +841,9 @@ class LoxiaApplication {
848
841
  this.logger?.info('Models service retries cancelled');
849
842
  }
850
843
 
851
- // Close Puppeteer browsers (webTool, browserTool) — they hold DevTools ports
844
+ // Close Puppeteer browser (webTool) — it holds DevTools ports
852
845
  if (this.toolsRegistry) {
853
- for (const toolId of ['web', 'browser']) {
846
+ for (const toolId of ['web']) {
854
847
  try {
855
848
  const tool = this.toolsRegistry.getTool(toolId);
856
849
  if (tool?.cleanup) {
@@ -863,6 +856,29 @@ class LoxiaApplication {
863
856
  }
864
857
  }
865
858
 
859
+ // Kill ALL running terminal processes across all agents
860
+ if (this.toolsRegistry) {
861
+ try {
862
+ const terminalTool = this.toolsRegistry.getTool('terminal');
863
+ if (terminalTool?.commandTracker) {
864
+ let killed = 0;
865
+ for (const [cmdId, cmdInfo] of terminalTool.commandTracker) {
866
+ if (cmdInfo.process && cmdInfo.state === 'RUNNING') {
867
+ try {
868
+ cmdInfo.process.kill('SIGTERM');
869
+ killed++;
870
+ } catch { /* already dead */ }
871
+ }
872
+ }
873
+ if (killed > 0) {
874
+ this.logger?.info(`Killed ${killed} running terminal process(es) on shutdown`);
875
+ }
876
+ }
877
+ } catch (error) {
878
+ this.logger?.warn('Failed to cleanup terminal processes', { error: error.message });
879
+ }
880
+ }
881
+
866
882
  // Shutdown interfaces (web server, visual editor, WS connections)
867
883
  for (const [type, interface_] of this.interfaces) {
868
884
  try {
@@ -92,12 +92,10 @@ describe('Terminal UI Infrastructure - Imports', () => {
92
92
  });
93
93
 
94
94
  test('useConnection hook can be imported', async () => {
95
- const { useConnection, useConnectionStatus } = await import('../../state/useConnection.js');
95
+ const mod = await import('../../state/useConnection.js');
96
96
 
97
- expect(useConnection).toBeDefined();
98
- expect(typeof useConnection).toBe('function');
99
- expect(useConnectionStatus).toBeDefined();
100
- expect(typeof useConnectionStatus).toBe('function');
97
+ expect(mod.useConnection).toBeDefined();
98
+ expect(typeof mod.useConnection).toBe('function');
101
99
  });
102
100
  });
103
101
 
@@ -0,0 +1,319 @@
1
+ import { jest, describe, test, expect, beforeEach } from '@jest/globals';
2
+ import { createMockLogger } from '../../__test-utils__/mockFactories.js';
3
+ import {
4
+ shouldAgentBeActive,
5
+ getActiveAgents,
6
+ getAllAgentActivityStatus,
7
+ shouldSkipIteration,
8
+ hasPendingTasks,
9
+ getMessageQueueStatus,
10
+ isAgentDelayed,
11
+ isAgentPaused,
12
+ isExecutingTools
13
+ } from '../agentActivityService.js';
14
+
15
+ // Helper to create a base active agent
16
+ function makeAgent(overrides = {}) {
17
+ return {
18
+ id: 'agent-1',
19
+ name: 'Test Agent',
20
+ sessionId: 'session-1',
21
+ status: 'active',
22
+ mode: 'agent',
23
+ taskList: { tasks: [] },
24
+ messageQueues: {},
25
+ delayEndTime: null,
26
+ pausedUntil: null,
27
+ awaitingUserInput: null,
28
+ stopRequested: false,
29
+ toolExecutionInProgress: false,
30
+ ttl: null,
31
+ ...overrides
32
+ };
33
+ }
34
+
35
+ describe('agentActivityService', () => {
36
+ describe('hasPendingTasks', () => {
37
+ test('returns false when taskList is null or missing', () => {
38
+ expect(hasPendingTasks({})).toBe(false);
39
+ expect(hasPendingTasks({ taskList: null })).toBe(false);
40
+ expect(hasPendingTasks({ taskList: {} })).toBe(false);
41
+ expect(hasPendingTasks({ taskList: { tasks: 'not-array' } })).toBe(false);
42
+ });
43
+
44
+ test('returns true when there are pending tasks', () => {
45
+ const agent = makeAgent({
46
+ taskList: { tasks: [{ status: 'pending' }] }
47
+ });
48
+ expect(hasPendingTasks(agent)).toBe(true);
49
+ });
50
+
51
+ test('returns true when there are in_progress tasks', () => {
52
+ const agent = makeAgent({
53
+ taskList: { tasks: [{ status: 'completed' }, { status: 'in_progress' }] }
54
+ });
55
+ expect(hasPendingTasks(agent)).toBe(true);
56
+ });
57
+
58
+ test('returns false when all tasks are completed', () => {
59
+ const agent = makeAgent({
60
+ taskList: { tasks: [{ status: 'completed' }, { status: 'failed' }] }
61
+ });
62
+ expect(hasPendingTasks(agent)).toBe(false);
63
+ });
64
+ });
65
+
66
+ describe('getMessageQueueStatus', () => {
67
+ test('returns zero counts for empty queues', () => {
68
+ const result = getMessageQueueStatus({});
69
+ expect(result.hasMessages).toBe(false);
70
+ expect(result.counts.total).toBe(0);
71
+ });
72
+
73
+ test('counts messages across all queues', () => {
74
+ const agent = makeAgent({
75
+ messageQueues: {
76
+ toolResults: [{ id: 1 }],
77
+ interAgentMessages: [{ id: 2 }, { id: 3 }],
78
+ userMessages: [{ id: 4 }]
79
+ }
80
+ });
81
+ const result = getMessageQueueStatus(agent);
82
+ expect(result.hasMessages).toBe(true);
83
+ expect(result.hasUserMessages).toBe(true);
84
+ expect(result.hasInterAgentMessages).toBe(true);
85
+ expect(result.hasToolResults).toBe(true);
86
+ expect(result.counts.total).toBe(4);
87
+ });
88
+
89
+ test('handles non-array queue values', () => {
90
+ const agent = makeAgent({
91
+ messageQueues: { toolResults: 'not-array', userMessages: null }
92
+ });
93
+ const result = getMessageQueueStatus(agent);
94
+ expect(result.counts.toolResults).toBe(0);
95
+ expect(result.counts.userMessages).toBe(0);
96
+ });
97
+ });
98
+
99
+ describe('isAgentDelayed', () => {
100
+ test('returns false when no delayEndTime', () => {
101
+ expect(isAgentDelayed(makeAgent())).toBe(false);
102
+ });
103
+
104
+ test('returns true when delay is in the future', () => {
105
+ const future = new Date(Date.now() + 60000).toISOString();
106
+ expect(isAgentDelayed(makeAgent({ delayEndTime: future }))).toBe(true);
107
+ });
108
+
109
+ test('returns false when delay is in the past', () => {
110
+ const past = new Date(Date.now() - 60000).toISOString();
111
+ expect(isAgentDelayed(makeAgent({ delayEndTime: past }))).toBe(false);
112
+ });
113
+ });
114
+
115
+ describe('isAgentPaused', () => {
116
+ test('returns false for active, non-paused agent', () => {
117
+ expect(isAgentPaused(makeAgent())).toBe(false);
118
+ });
119
+
120
+ test('returns true for paused status without expiry', () => {
121
+ expect(isAgentPaused(makeAgent({ status: 'paused' }))).toBe(true);
122
+ });
123
+
124
+ test('returns true for paused status with future expiry', () => {
125
+ const future = new Date(Date.now() + 60000).toISOString();
126
+ expect(isAgentPaused(makeAgent({ status: 'paused', pausedUntil: future }))).toBe(true);
127
+ });
128
+
129
+ test('returns false for paused status with past expiry', () => {
130
+ const past = new Date(Date.now() - 60000).toISOString();
131
+ expect(isAgentPaused(makeAgent({ status: 'paused', pausedUntil: past }))).toBe(false);
132
+ });
133
+
134
+ test('returns true when pausedUntil is in future even without paused status', () => {
135
+ const future = new Date(Date.now() + 60000).toISOString();
136
+ expect(isAgentPaused(makeAgent({ pausedUntil: future }))).toBe(true);
137
+ });
138
+ });
139
+
140
+ describe('isExecutingTools', () => {
141
+ test('returns true when toolExecutionInProgress is true', () => {
142
+ expect(isExecutingTools({ toolExecutionInProgress: true })).toBe(true);
143
+ });
144
+
145
+ test('returns false otherwise', () => {
146
+ expect(isExecutingTools({ toolExecutionInProgress: false })).toBe(false);
147
+ expect(isExecutingTools({})).toBe(false);
148
+ });
149
+ });
150
+
151
+ describe('shouldAgentBeActive', () => {
152
+ test('returns inactive for null agent', () => {
153
+ const result = shouldAgentBeActive(null);
154
+ expect(result.active).toBe(false);
155
+ expect(result.reason).toBe('agent-not-found');
156
+ });
157
+
158
+ test('returns inactive for non-active status', () => {
159
+ const result = shouldAgentBeActive(makeAgent({ status: 'idle' }));
160
+ expect(result.active).toBe(false);
161
+ expect(result.reason).toBe('agent-inactive-status');
162
+ });
163
+
164
+ test('returns inactive when delayed', () => {
165
+ const future = new Date(Date.now() + 60000).toISOString();
166
+ const result = shouldAgentBeActive(makeAgent({ delayEndTime: future }));
167
+ expect(result.active).toBe(false);
168
+ expect(result.reason).toBe('agent-delayed');
169
+ });
170
+
171
+ test('returns inactive when paused', () => {
172
+ const result = shouldAgentBeActive(makeAgent({ status: 'active', pausedUntil: new Date(Date.now() + 60000).toISOString() }));
173
+ expect(result.active).toBe(false);
174
+ expect(result.reason).toBe('agent-paused');
175
+ });
176
+
177
+ test('returns inactive when awaiting user input', () => {
178
+ const result = shouldAgentBeActive(makeAgent({ awaitingUserInput: { type: 'credentials' } }));
179
+ expect(result.active).toBe(false);
180
+ expect(result.reason).toBe('awaiting-user-input');
181
+ });
182
+
183
+ test('returns inactive when stop requested', () => {
184
+ const result = shouldAgentBeActive(makeAgent({ stopRequested: true }));
185
+ expect(result.active).toBe(false);
186
+ expect(result.reason).toBe('stop-requested');
187
+ });
188
+
189
+ test('returns active when TTL remaining', () => {
190
+ const result = shouldAgentBeActive(makeAgent({ ttl: 3 }));
191
+ expect(result.active).toBe(true);
192
+ expect(result.reason).toBe('has-ttl-remaining');
193
+ });
194
+
195
+ test('AGENT mode: active when has pending tasks', () => {
196
+ const result = shouldAgentBeActive(makeAgent({
197
+ taskList: { tasks: [{ status: 'pending' }] }
198
+ }));
199
+ expect(result.active).toBe(true);
200
+ expect(result.reason).toBe('has-pending-tasks');
201
+ });
202
+
203
+ test('AGENT mode: inactive when no pending tasks', () => {
204
+ const result = shouldAgentBeActive(makeAgent({
205
+ taskList: { tasks: [{ status: 'completed' }] }
206
+ }));
207
+ expect(result.active).toBe(false);
208
+ expect(result.reason).toBe('no-pending-work');
209
+ });
210
+
211
+ test('CHAT mode: active when has user messages', () => {
212
+ const result = shouldAgentBeActive(makeAgent({
213
+ mode: 'chat',
214
+ messageQueues: { userMessages: [{ id: 1 }] }
215
+ }));
216
+ expect(result.active).toBe(true);
217
+ expect(result.reason).toBe('has-user-messages');
218
+ });
219
+
220
+ test('CHAT mode: active when has inter-agent messages', () => {
221
+ const result = shouldAgentBeActive(makeAgent({
222
+ mode: 'chat',
223
+ messageQueues: { interAgentMessages: [{ id: 1 }] }
224
+ }));
225
+ expect(result.active).toBe(true);
226
+ expect(result.reason).toBe('has-inter-agent-messages');
227
+ });
228
+
229
+ test('CHAT mode: inactive with only tool results', () => {
230
+ const result = shouldAgentBeActive(makeAgent({
231
+ mode: 'chat',
232
+ messageQueues: { toolResults: [{ id: 1 }] }
233
+ }));
234
+ expect(result.active).toBe(false);
235
+ expect(result.reason).toBe('chat-mode-no-messages');
236
+ expect(result.details).toContain('tool results');
237
+ });
238
+
239
+ test('CHAT mode: inactive with no messages', () => {
240
+ const result = shouldAgentBeActive(makeAgent({ mode: 'chat' }));
241
+ expect(result.active).toBe(false);
242
+ expect(result.reason).toBe('chat-mode-no-messages');
243
+ });
244
+
245
+ test('returns unknown mode for unrecognized mode', () => {
246
+ const result = shouldAgentBeActive(makeAgent({ mode: 'weird' }));
247
+ expect(result.active).toBe(false);
248
+ expect(result.reason).toBe('unknown-mode');
249
+ });
250
+ });
251
+
252
+ describe('getActiveAgents', () => {
253
+ test('filters active agents from an array', () => {
254
+ const agents = [
255
+ makeAgent({ id: 'a1', taskList: { tasks: [{ status: 'pending' }] } }),
256
+ makeAgent({ id: 'a2', status: 'idle' }),
257
+ makeAgent({ id: 'a3', taskList: { tasks: [{ status: 'pending' }] } })
258
+ ];
259
+ const active = getActiveAgents(agents);
260
+ expect(active).toHaveLength(2);
261
+ expect(active.map(a => a.agentId)).toEqual(['a1', 'a3']);
262
+ });
263
+
264
+ test('works with a Map input', () => {
265
+ const agents = new Map();
266
+ agents.set('a1', makeAgent({ id: 'a1', taskList: { tasks: [{ status: 'pending' }] } }));
267
+ agents.set('a2', makeAgent({ id: 'a2', status: 'idle' }));
268
+ const active = getActiveAgents(agents);
269
+ expect(active).toHaveLength(1);
270
+ });
271
+ });
272
+
273
+ describe('getAllAgentActivityStatus', () => {
274
+ test('returns detailed status for all agents', () => {
275
+ const agents = [
276
+ makeAgent({ id: 'a1', name: 'Agent 1', taskList: { tasks: [{ status: 'pending' }] } })
277
+ ];
278
+ const statuses = getAllAgentActivityStatus(agents);
279
+ expect(statuses).toHaveLength(1);
280
+ expect(statuses[0].agentId).toBe('a1');
281
+ expect(statuses[0].active).toBe(true);
282
+ expect(statuses[0]).toHaveProperty('queueCounts');
283
+ expect(statuses[0]).toHaveProperty('hasPendingTasks');
284
+ expect(statuses[0]).toHaveProperty('isExecutingTools');
285
+ });
286
+
287
+ test('works with Map input', () => {
288
+ const map = new Map();
289
+ map.set('a1', makeAgent({ id: 'a1' }));
290
+ const statuses = getAllAgentActivityStatus(map);
291
+ expect(statuses).toHaveLength(1);
292
+ });
293
+ });
294
+
295
+ describe('shouldSkipIteration', () => {
296
+ test('returns skip for null agent', () => {
297
+ const result = shouldSkipIteration(null);
298
+ expect(result.skip).toBe(true);
299
+ });
300
+
301
+ test('returns skip when delayed', () => {
302
+ const future = new Date(Date.now() + 60000).toISOString();
303
+ const result = shouldSkipIteration(makeAgent({ delayEndTime: future }));
304
+ expect(result.skip).toBe(true);
305
+ });
306
+
307
+ test('returns skip when paused with future expiry', () => {
308
+ const future = new Date(Date.now() + 60000).toISOString();
309
+ const result = shouldSkipIteration(makeAgent({ pausedUntil: future }));
310
+ expect(result.skip).toBe(true);
311
+ });
312
+
313
+ test('returns no skip for normal agent', () => {
314
+ const result = shouldSkipIteration(makeAgent());
315
+ expect(result.skip).toBe(false);
316
+ expect(result.reason).toBeNull();
317
+ });
318
+ });
319
+ });
@@ -0,0 +1,206 @@
1
+ import { jest, describe, test, expect, beforeEach } from '@jest/globals';
2
+ import { createMockLogger } from '../../__test-utils__/mockFactories.js';
3
+
4
+ // Mock fs/promises, crypto, os, and userDataDir before importing
5
+ const mockFs = {
6
+ readFile: jest.fn(),
7
+ writeFile: jest.fn()
8
+ };
9
+ const mockCrypto = {
10
+ randomBytes: jest.fn(() => Buffer.alloc(32, 'a')),
11
+ pbkdf2Sync: jest.fn(() => Buffer.alloc(32, 'k')),
12
+ createCipheriv: jest.fn(),
13
+ createDecipheriv: jest.fn()
14
+ };
15
+ const mockOs = {
16
+ hostname: jest.fn(() => 'test-host'),
17
+ homedir: jest.fn(() => '/home/test'),
18
+ userInfo: jest.fn(() => ({ username: 'testuser' }))
19
+ };
20
+
21
+ jest.unstable_mockModule('fs', () => ({
22
+ promises: mockFs
23
+ }));
24
+
25
+ jest.unstable_mockModule('crypto', () => ({
26
+ default: mockCrypto,
27
+ ...mockCrypto
28
+ }));
29
+
30
+ jest.unstable_mockModule('os', () => ({
31
+ default: mockOs,
32
+ ...mockOs
33
+ }));
34
+
35
+ jest.unstable_mockModule('../../utilities/userDataDir.js', () => ({
36
+ getUserDataPaths: jest.fn(() => ({ settings: '/fake/settings', attachments: '/fake/attachments' })),
37
+ ensureUserDataDirs: jest.fn(async () => {})
38
+ }));
39
+
40
+ const { default: ApiKeyManager } = await import('../apiKeyManager.js');
41
+
42
+ describe('ApiKeyManager', () => {
43
+ let manager;
44
+ let logger;
45
+
46
+ beforeEach(() => {
47
+ jest.clearAllMocks();
48
+ logger = createMockLogger();
49
+ manager = new ApiKeyManager(logger);
50
+ });
51
+
52
+ test('constructor initializes with empty keys', () => {
53
+ expect(manager.keys.loxiaApiKey).toBeNull();
54
+ expect(manager.keys.vendorKeys).toEqual({});
55
+ expect(manager.initialized).toBe(false);
56
+ });
57
+
58
+ test('initialize sets up persistence and loads keys', async () => {
59
+ // Mock salt file read failure (new salt generation)
60
+ mockFs.readFile.mockRejectedValueOnce(new Error('ENOENT'));
61
+ // Mock writeFile for salt
62
+ mockFs.writeFile.mockResolvedValue(undefined);
63
+ // Mock readFile for loadFromDisk - ENOENT (no existing keys)
64
+ const enoent = new Error('No file');
65
+ enoent.code = 'ENOENT';
66
+ mockFs.readFile.mockRejectedValueOnce(enoent);
67
+
68
+ await manager.initialize();
69
+ expect(manager.initialized).toBe(true);
70
+ expect(manager.persistenceFile).toContain('api-keys.enc');
71
+ });
72
+
73
+ test('initialize only runs once', async () => {
74
+ manager.initialized = true;
75
+ await manager.initialize();
76
+ // Should not call ensureUserDataDirs if already initialized
77
+ expect(logger.info).not.toHaveBeenCalled();
78
+ });
79
+
80
+ test('initialize handles errors gracefully', async () => {
81
+ const { ensureUserDataDirs } = await import('../../utilities/userDataDir.js');
82
+ ensureUserDataDirs.mockRejectedValueOnce(new Error('disk full'));
83
+
84
+ await manager.initialize();
85
+ expect(logger.warn).toHaveBeenCalled();
86
+ });
87
+
88
+ test('_getMachineIdentifier returns colon-separated string', () => {
89
+ const id = manager._getMachineIdentifier();
90
+ expect(id).toContain('test-host');
91
+ expect(id).toContain('testuser');
92
+ expect(id).toContain('loxia-api-key-encryption-v1');
93
+ });
94
+
95
+ test('_encrypt throws when no encryption key', () => {
96
+ expect(() => manager._encrypt('test')).toThrow('Encryption key not initialized');
97
+ });
98
+
99
+ test('_decrypt throws when no encryption key', () => {
100
+ expect(() => manager._decrypt('test')).toThrow('Encryption key not initialized');
101
+ });
102
+
103
+ test('setSessionKeys stores loxia key and vendor keys', async () => {
104
+ manager.persistenceFile = null; // skip persist
105
+ await manager.setSessionKeys('sess-1', {
106
+ loxiaApiKey: 'loxia-key-123',
107
+ vendorKeys: { anthropic: 'anth-key' }
108
+ });
109
+ expect(manager.keys.loxiaApiKey).toBe('loxia-key-123');
110
+ expect(manager.keys.vendorKeys.anthropic).toBe('anth-key');
111
+ });
112
+
113
+ test('setSessionKeys merges vendor keys', async () => {
114
+ manager.persistenceFile = null;
115
+ manager.keys.vendorKeys = { openai: 'oai-key' };
116
+ await manager.setSessionKeys('sess-1', {
117
+ vendorKeys: { anthropic: 'anth-key' }
118
+ });
119
+ expect(manager.keys.vendorKeys.openai).toBe('oai-key');
120
+ expect(manager.keys.vendorKeys.anthropic).toBe('anth-key');
121
+ });
122
+
123
+ test('getSessionKeys returns keys regardless of sessionId', () => {
124
+ manager.keys.loxiaApiKey = 'test-key';
125
+ const keys = manager.getSessionKeys('any-session');
126
+ expect(keys.loxiaApiKey).toBe('test-key');
127
+ });
128
+
129
+ test('getKeysForRequest returns loxia key for platform-provided', () => {
130
+ manager.keys.loxiaApiKey = 'loxia-123';
131
+ const result = manager.getKeysForRequest('sess', { platformProvided: true, vendor: 'anthropic' });
132
+ expect(result.loxiaApiKey).toBe('loxia-123');
133
+ expect(result.vendorApiKey).toBeNull();
134
+ });
135
+
136
+ test('getKeysForRequest returns vendor key for direct access', () => {
137
+ manager.keys.loxiaApiKey = 'loxia-123';
138
+ manager.keys.vendorKeys = { anthropic: 'anth-key' };
139
+ const result = manager.getKeysForRequest('sess', { platformProvided: false, vendor: 'anthropic' });
140
+ expect(result.loxiaApiKey).toBe('loxia-123');
141
+ expect(result.vendorApiKey).toBe('anth-key');
142
+ });
143
+
144
+ test('getKeysForRequest returns null vendor key when no vendor specified', () => {
145
+ const result = manager.getKeysForRequest('sess', {});
146
+ expect(result.vendorApiKey).toBeNull();
147
+ });
148
+
149
+ test('removeSessionKeys clears all keys and returns true if had keys', async () => {
150
+ manager.persistenceFile = null;
151
+ manager.keys.loxiaApiKey = 'some-key';
152
+ const hadKeys = await manager.removeSessionKeys('sess');
153
+ expect(hadKeys).toBe(true);
154
+ expect(manager.keys.loxiaApiKey).toBeNull();
155
+ expect(manager.keys.vendorKeys).toEqual({});
156
+ });
157
+
158
+ test('removeSessionKeys returns false if no keys existed', async () => {
159
+ const hadKeys = await manager.removeSessionKeys('sess');
160
+ expect(hadKeys).toBe(false);
161
+ });
162
+
163
+ test('setGlobalKeys delegates to setSessionKeys', async () => {
164
+ manager.persistenceFile = null;
165
+ await manager.setGlobalKeys({ loxiaApiKey: 'global-key' });
166
+ expect(manager.keys.loxiaApiKey).toBe('global-key');
167
+ });
168
+
169
+ test('getActiveSessions returns empty array', () => {
170
+ expect(manager.getActiveSessions()).toEqual([]);
171
+ });
172
+
173
+ test('cleanupExpiredSessions returns 0', () => {
174
+ expect(manager.cleanupExpiredSessions()).toBe(0);
175
+ });
176
+
177
+ test('persist does nothing when not initialized', async () => {
178
+ manager.persistenceFile = null;
179
+ await manager.persist();
180
+ expect(mockFs.writeFile).not.toHaveBeenCalled();
181
+ });
182
+
183
+ test('loadFromDisk does nothing when not initialized', async () => {
184
+ manager.persistenceFile = null;
185
+ await manager.loadFromDisk();
186
+ // No errors expected
187
+ });
188
+
189
+ test('loadFromDisk handles ENOENT gracefully', async () => {
190
+ manager.persistenceFile = '/fake/file';
191
+ manager.encryptionKey = Buffer.alloc(32);
192
+ const err = new Error('not found');
193
+ err.code = 'ENOENT';
194
+ mockFs.readFile.mockRejectedValueOnce(err);
195
+ await manager.loadFromDisk();
196
+ expect(logger.debug).toHaveBeenCalled();
197
+ });
198
+
199
+ test('loadFromDisk handles decrypt errors gracefully', async () => {
200
+ manager.persistenceFile = '/fake/file';
201
+ manager.encryptionKey = Buffer.alloc(32);
202
+ mockFs.readFile.mockRejectedValueOnce(new Error('bad data'));
203
+ await manager.loadFromDisk();
204
+ expect(logger.warn).toHaveBeenCalled();
205
+ });
206
+ });