forkoff 1.0.11 → 1.0.13

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 (57) hide show
  1. package/README.md +7 -4
  2. package/dist/__tests__/cli-commands.test.d.ts +6 -0
  3. package/dist/__tests__/cli-commands.test.d.ts.map +1 -0
  4. package/dist/__tests__/cli-commands.test.js +213 -0
  5. package/dist/__tests__/cli-commands.test.js.map +1 -0
  6. package/dist/__tests__/startup.test.d.ts +11 -0
  7. package/dist/__tests__/startup.test.d.ts.map +1 -0
  8. package/dist/__tests__/startup.test.js +234 -0
  9. package/dist/__tests__/startup.test.js.map +1 -0
  10. package/dist/__tests__/tools/claude-process.test.js +221 -15
  11. package/dist/__tests__/tools/claude-process.test.js.map +1 -1
  12. package/dist/__tests__/tools/permission-hook.test.d.ts +17 -0
  13. package/dist/__tests__/tools/permission-hook.test.d.ts.map +1 -0
  14. package/dist/__tests__/tools/permission-hook.test.js +616 -0
  15. package/dist/__tests__/tools/permission-hook.test.js.map +1 -0
  16. package/dist/__tests__/tools/permission-ipc.test.d.ts +11 -0
  17. package/dist/__tests__/tools/permission-ipc.test.d.ts.map +1 -0
  18. package/dist/__tests__/tools/permission-ipc.test.js +612 -0
  19. package/dist/__tests__/tools/permission-ipc.test.js.map +1 -0
  20. package/dist/config.js +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1010 -898
  24. package/dist/index.js.map +1 -1
  25. package/dist/startup.d.ts.map +1 -1
  26. package/dist/startup.js +45 -15
  27. package/dist/startup.js.map +1 -1
  28. package/dist/tools/__tests__/claude-sessions.test.d.ts +2 -0
  29. package/dist/tools/__tests__/claude-sessions.test.d.ts.map +1 -0
  30. package/dist/tools/__tests__/claude-sessions.test.js +306 -0
  31. package/dist/tools/__tests__/claude-sessions.test.js.map +1 -0
  32. package/dist/tools/claude-process.d.ts +81 -4
  33. package/dist/tools/claude-process.d.ts.map +1 -1
  34. package/dist/tools/claude-process.js +332 -20
  35. package/dist/tools/claude-process.js.map +1 -1
  36. package/dist/tools/claude-sessions.d.ts +5 -0
  37. package/dist/tools/claude-sessions.d.ts.map +1 -1
  38. package/dist/tools/claude-sessions.js +16 -2
  39. package/dist/tools/claude-sessions.js.map +1 -1
  40. package/dist/tools/index.d.ts +1 -0
  41. package/dist/tools/index.d.ts.map +1 -1
  42. package/dist/tools/index.js +3 -1
  43. package/dist/tools/index.js.map +1 -1
  44. package/dist/tools/permission-hook.d.ts +41 -0
  45. package/dist/tools/permission-hook.d.ts.map +1 -0
  46. package/dist/tools/permission-hook.js +312 -0
  47. package/dist/tools/permission-hook.js.map +1 -0
  48. package/dist/tools/permission-ipc.d.ts +109 -0
  49. package/dist/tools/permission-ipc.d.ts.map +1 -0
  50. package/dist/tools/permission-ipc.js +295 -0
  51. package/dist/tools/permission-ipc.js.map +1 -0
  52. package/dist/websocket.d.ts +14 -0
  53. package/dist/websocket.d.ts.map +1 -1
  54. package/dist/websocket.js +34 -4
  55. package/dist/websocket.js.map +1 -1
  56. package/jest.config.js +3 -0
  57. package/package.json +1 -1
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Tests for PermissionIpcManager (permission-ipc.ts)
3
+ *
4
+ * Uses REAL filesystem operations in a unique temp directory per test run.
5
+ * The PermissionIpcManager is imported directly and tested end-to-end:
6
+ * start() -> detect request files -> emit events -> handleResponse() -> write response files
7
+ * cleanup() -> remove temp files
8
+ * auto-timeout -> auto-deny after TIMEOUT_MS
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=permission-ipc.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permission-ipc.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/tools/permission-ipc.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG"}
@@ -0,0 +1,612 @@
1
+ "use strict";
2
+ /**
3
+ * Tests for PermissionIpcManager (permission-ipc.ts)
4
+ *
5
+ * Uses REAL filesystem operations in a unique temp directory per test run.
6
+ * The PermissionIpcManager is imported directly and tested end-to-end:
7
+ * start() -> detect request files -> emit events -> handleResponse() -> write response files
8
+ * cleanup() -> remove temp files
9
+ * auto-timeout -> auto-deny after TIMEOUT_MS
10
+ */
11
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
12
+ if (k2 === undefined) k2 = k;
13
+ var desc = Object.getOwnPropertyDescriptor(m, k);
14
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
15
+ desc = { enumerable: true, get: function() { return m[k]; } };
16
+ }
17
+ Object.defineProperty(o, k2, desc);
18
+ }) : (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ o[k2] = m[k];
21
+ }));
22
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
23
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
24
+ }) : function(o, v) {
25
+ o["default"] = v;
26
+ });
27
+ var __importStar = (this && this.__importStar) || (function () {
28
+ var ownKeys = function(o) {
29
+ ownKeys = Object.getOwnPropertyNames || function (o) {
30
+ var ar = [];
31
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
32
+ return ar;
33
+ };
34
+ return ownKeys(o);
35
+ };
36
+ return function (mod) {
37
+ if (mod && mod.__esModule) return mod;
38
+ var result = {};
39
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
40
+ __setModuleDefault(result, mod);
41
+ return result;
42
+ };
43
+ })();
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ const fs = __importStar(require("fs"));
46
+ const os = __importStar(require("os"));
47
+ const path = __importStar(require("path"));
48
+ const permission_ipc_1 = require("../../tools/permission-ipc");
49
+ // ---------------------------------------------------------------------------
50
+ // Test helpers
51
+ // ---------------------------------------------------------------------------
52
+ /** The real TEMP_DIR the manager uses: os.tmpdir()/forkoff-permissions */
53
+ const REAL_TEMP_DIR = path.join(os.tmpdir(), 'forkoff-permissions');
54
+ /**
55
+ * Write a .request.json file into the IPC temp directory so the manager
56
+ * picks it up during its next poll cycle.
57
+ */
58
+ function writeRequestFile(promptId, overrides = {}) {
59
+ const filePath = path.join(REAL_TEMP_DIR, `${promptId}.request.json`);
60
+ const data = {
61
+ promptId,
62
+ toolName: 'Bash',
63
+ toolInput: { command: 'npm test' },
64
+ toolUseId: 'toolu_test_01',
65
+ timestamp: Date.now(),
66
+ ...overrides,
67
+ };
68
+ fs.writeFileSync(filePath, JSON.stringify(data), 'utf-8');
69
+ }
70
+ /**
71
+ * Wait for a condition to become true (up to `timeoutMs`).
72
+ * Useful for waiting for async polling to detect files.
73
+ */
74
+ function waitFor(conditionFn, timeoutMs = 3000, intervalMs = 50) {
75
+ return new Promise((resolve, reject) => {
76
+ const start = Date.now();
77
+ const check = () => {
78
+ if (conditionFn()) {
79
+ resolve();
80
+ return;
81
+ }
82
+ if (Date.now() - start > timeoutMs) {
83
+ reject(new Error(`waitFor timed out after ${timeoutMs}ms`));
84
+ return;
85
+ }
86
+ setTimeout(check, intervalMs);
87
+ };
88
+ check();
89
+ });
90
+ }
91
+ /**
92
+ * Remove all files from the forkoff-permissions temp directory.
93
+ */
94
+ function cleanTempDir() {
95
+ try {
96
+ if (fs.existsSync(REAL_TEMP_DIR)) {
97
+ const files = fs.readdirSync(REAL_TEMP_DIR);
98
+ for (const file of files) {
99
+ try {
100
+ fs.unlinkSync(path.join(REAL_TEMP_DIR, file));
101
+ }
102
+ catch {
103
+ // ignore
104
+ }
105
+ }
106
+ }
107
+ }
108
+ catch {
109
+ // ignore
110
+ }
111
+ }
112
+ // ===========================================================================
113
+ // TEST SUITES
114
+ // ===========================================================================
115
+ describe('PermissionIpcManager', () => {
116
+ let manager;
117
+ beforeEach(() => {
118
+ cleanTempDir();
119
+ manager = new permission_ipc_1.PermissionIpcManager();
120
+ });
121
+ afterEach(() => {
122
+ manager.cleanup();
123
+ cleanTempDir();
124
+ });
125
+ // =========================================================================
126
+ // 1. start() creates temp dir and begins polling
127
+ // =========================================================================
128
+ describe('start()', () => {
129
+ it('creates the forkoff-permissions temp directory', () => {
130
+ // Remove the directory first if it exists
131
+ try {
132
+ if (fs.existsSync(REAL_TEMP_DIR)) {
133
+ fs.rmSync(REAL_TEMP_DIR, { recursive: true, force: true });
134
+ }
135
+ }
136
+ catch {
137
+ // ignore
138
+ }
139
+ manager.start('terminal-session-1');
140
+ expect(fs.existsSync(REAL_TEMP_DIR)).toBe(true);
141
+ });
142
+ it('stores the terminalSessionId and sessionKey', async () => {
143
+ const events = [];
144
+ manager.on('permission_prompt', (evt) => events.push(evt));
145
+ manager.start('ts-123', 'sk-abc');
146
+ // Write a request file so we can observe the emitted event's metadata
147
+ writeRequestFile('prompt-ids-test');
148
+ await waitFor(() => events.length > 0);
149
+ expect(events[0].terminalSessionId).toBe('ts-123');
150
+ expect(events[0].sessionKey).toBe('sk-abc');
151
+ });
152
+ it('does not throw if temp dir already exists', () => {
153
+ fs.mkdirSync(REAL_TEMP_DIR, { recursive: true });
154
+ expect(() => manager.start('ts-1')).not.toThrow();
155
+ });
156
+ it('clears any previous polling interval when called again', () => {
157
+ // Call start twice - should not leak intervals
158
+ manager.start('ts-1');
159
+ manager.start('ts-2');
160
+ // If it leaked, cleanup would still be clean (no double-fire observed)
161
+ // We just verify it does not throw
162
+ manager.stop();
163
+ });
164
+ });
165
+ // =========================================================================
166
+ // 2. Detecting .request.json files -> emitting 'permission_prompt'
167
+ // =========================================================================
168
+ describe('request file detection and event emission', () => {
169
+ it('emits permission_prompt when a .request.json file appears', async () => {
170
+ const events = [];
171
+ manager.on('permission_prompt', (evt) => events.push(evt));
172
+ manager.start('ts-detect');
173
+ writeRequestFile('prompt-detect-1', {
174
+ toolName: 'Edit',
175
+ toolInput: { file_path: '/src/app.ts', old_string: 'a', new_string: 'b' },
176
+ toolUseId: 'toolu_edit_01',
177
+ });
178
+ await waitFor(() => events.length > 0);
179
+ expect(events).toHaveLength(1);
180
+ expect(events[0].promptId).toBe('prompt-detect-1');
181
+ expect(events[0].toolName).toBe('Edit');
182
+ expect(events[0].toolInput).toEqual({
183
+ file_path: '/src/app.ts',
184
+ old_string: 'a',
185
+ new_string: 'b',
186
+ });
187
+ expect(events[0].toolUseId).toBe('toolu_edit_01');
188
+ expect(events[0].terminalSessionId).toBe('ts-detect');
189
+ });
190
+ it('emits events for multiple request files', async () => {
191
+ const events = [];
192
+ manager.on('permission_prompt', (evt) => events.push(evt));
193
+ manager.start('ts-multi');
194
+ writeRequestFile('prompt-multi-1', { toolName: 'Bash' });
195
+ writeRequestFile('prompt-multi-2', { toolName: 'Write' });
196
+ writeRequestFile('prompt-multi-3', { toolName: 'Edit' });
197
+ await waitFor(() => {
198
+ const ids = events.map((e) => e.promptId);
199
+ return ids.includes('prompt-multi-1')
200
+ && ids.includes('prompt-multi-2')
201
+ && ids.includes('prompt-multi-3');
202
+ });
203
+ const ourEvents = events.filter((e) => ['prompt-multi-1', 'prompt-multi-2', 'prompt-multi-3'].includes(e.promptId));
204
+ expect(ourEvents).toHaveLength(3);
205
+ const promptIds = ourEvents.map((e) => e.promptId).sort();
206
+ expect(promptIds).toEqual(['prompt-multi-1', 'prompt-multi-2', 'prompt-multi-3']);
207
+ });
208
+ it('uses the file name (sans .request.json) as promptId if not in content', async () => {
209
+ const events = [];
210
+ manager.on('permission_prompt', (evt) => events.push(evt));
211
+ manager.start('ts-fallback');
212
+ // Write a request file where promptId is missing from the JSON content
213
+ const filePath = path.join(REAL_TEMP_DIR, 'fallback-id.request.json');
214
+ fs.writeFileSync(filePath, JSON.stringify({ toolName: 'Bash', toolInput: {}, toolUseId: 'x' }), 'utf-8');
215
+ await waitFor(() => events.some((e) => e.promptId === 'fallback-id'));
216
+ // The manager should fall back to extracting the id from the file name
217
+ const fallbackEvent = events.find((e) => e.promptId === 'fallback-id');
218
+ expect(fallbackEvent).toBeDefined();
219
+ expect(fallbackEvent.toolName).toBe('Bash');
220
+ });
221
+ });
222
+ // =========================================================================
223
+ // 3. handleResponse() writes a .response.json file
224
+ // =========================================================================
225
+ describe('handleResponse()', () => {
226
+ it('writes a .response.json file with allow decision', async () => {
227
+ const events = [];
228
+ manager.on('permission_prompt', (evt) => events.push(evt));
229
+ manager.start('ts-respond');
230
+ writeRequestFile('prompt-allow');
231
+ await waitFor(() => events.length > 0);
232
+ manager.handleResponse('prompt-allow', 'allow', 'User approved');
233
+ const responseFile = path.join(REAL_TEMP_DIR, 'prompt-allow.response.json');
234
+ expect(fs.existsSync(responseFile)).toBe(true);
235
+ const content = JSON.parse(fs.readFileSync(responseFile, 'utf-8'));
236
+ expect(content.decision).toBe('allow');
237
+ expect(content.reason).toBe('User approved');
238
+ });
239
+ it('writes a .response.json file with deny decision', async () => {
240
+ const events = [];
241
+ manager.on('permission_prompt', (evt) => events.push(evt));
242
+ manager.start('ts-deny');
243
+ writeRequestFile('prompt-deny');
244
+ await waitFor(() => events.length > 0);
245
+ manager.handleResponse('prompt-deny', 'deny', 'Too risky');
246
+ const responseFile = path.join(REAL_TEMP_DIR, 'prompt-deny.response.json');
247
+ const content = JSON.parse(fs.readFileSync(responseFile, 'utf-8'));
248
+ expect(content.decision).toBe('deny');
249
+ expect(content.reason).toBe('Too risky');
250
+ });
251
+ it('omits reason field when no reason is provided', async () => {
252
+ const events = [];
253
+ manager.on('permission_prompt', (evt) => events.push(evt));
254
+ manager.start('ts-no-reason');
255
+ writeRequestFile('prompt-no-reason');
256
+ await waitFor(() => events.length > 0);
257
+ manager.handleResponse('prompt-no-reason', 'allow');
258
+ const responseFile = path.join(REAL_TEMP_DIR, 'prompt-no-reason.response.json');
259
+ const content = JSON.parse(fs.readFileSync(responseFile, 'utf-8'));
260
+ expect(content.decision).toBe('allow');
261
+ expect(content).not.toHaveProperty('reason');
262
+ });
263
+ it('does nothing for an unknown promptId (not pending)', () => {
264
+ manager.start('ts-unknown');
265
+ // No request file was ever written, so 'nonexistent' is not pending
266
+ manager.handleResponse('nonexistent', 'allow');
267
+ const responseFile = path.join(REAL_TEMP_DIR, 'nonexistent.response.json');
268
+ expect(fs.existsSync(responseFile)).toBe(false);
269
+ });
270
+ });
271
+ // =========================================================================
272
+ // 4. stop() clears the polling interval
273
+ // =========================================================================
274
+ describe('stop()', () => {
275
+ it('stops polling so new request files are not detected', async () => {
276
+ const events = [];
277
+ manager.on('permission_prompt', (evt) => events.push(evt));
278
+ manager.start('ts-stop');
279
+ // Stop immediately
280
+ manager.stop();
281
+ // Write a request file after stopping
282
+ writeRequestFile('prompt-after-stop');
283
+ // Give it some time - the file should NOT be picked up
284
+ await new Promise((resolve) => setTimeout(resolve, 600));
285
+ expect(events).toHaveLength(0);
286
+ });
287
+ it('clears all pending prompt timeouts', async () => {
288
+ const events = [];
289
+ manager.on('permission_prompt', (evt) => events.push(evt));
290
+ manager.start('ts-clear-timeouts');
291
+ writeRequestFile('prompt-pending');
292
+ await waitFor(() => events.length > 0);
293
+ // Now stop - should clear the pending timeout without writing a response
294
+ manager.stop();
295
+ // The response file should NOT exist (the timeout was cleared, not triggered)
296
+ const responseFile = path.join(REAL_TEMP_DIR, 'prompt-pending.response.json');
297
+ expect(fs.existsSync(responseFile)).toBe(false);
298
+ });
299
+ it('clears the processed files set', async () => {
300
+ const events = [];
301
+ manager.on('permission_prompt', (evt) => events.push(evt));
302
+ manager.start('ts-processed-clear');
303
+ writeRequestFile('prompt-processed');
304
+ await waitFor(() => events.length > 0);
305
+ manager.stop();
306
+ // Re-start the manager - the same file should be picked up again
307
+ // because the processed set was cleared
308
+ const events2 = [];
309
+ manager.on('permission_prompt', (evt) => events2.push(evt));
310
+ manager.start('ts-processed-clear-2');
311
+ await waitFor(() => events2.length > 0);
312
+ expect(events2[0].promptId).toBe('prompt-processed');
313
+ });
314
+ });
315
+ // =========================================================================
316
+ // 5. cleanup() removes temp files
317
+ // =========================================================================
318
+ describe('cleanup()', () => {
319
+ it('removes all files from the temp directory', async () => {
320
+ manager.start('ts-cleanup');
321
+ writeRequestFile('cleanup-1');
322
+ writeRequestFile('cleanup-2');
323
+ // Give the manager time to detect them
324
+ await new Promise((resolve) => setTimeout(resolve, 500));
325
+ manager.cleanup();
326
+ // Check that the temp directory files are cleaned up
327
+ if (fs.existsSync(REAL_TEMP_DIR)) {
328
+ const remaining = fs.readdirSync(REAL_TEMP_DIR);
329
+ expect(remaining).toHaveLength(0);
330
+ }
331
+ });
332
+ it('calls stop() internally', async () => {
333
+ const events = [];
334
+ manager.on('permission_prompt', (evt) => events.push(evt));
335
+ manager.start('ts-cleanup-stops');
336
+ manager.cleanup();
337
+ // Write a file after cleanup - should not be detected
338
+ writeRequestFile('prompt-after-cleanup');
339
+ await new Promise((resolve) => setTimeout(resolve, 600));
340
+ expect(events).toHaveLength(0);
341
+ });
342
+ it('does not throw if temp directory does not exist', () => {
343
+ // Remove the directory manually
344
+ try {
345
+ fs.rmSync(REAL_TEMP_DIR, { recursive: true, force: true });
346
+ }
347
+ catch {
348
+ // ignore
349
+ }
350
+ expect(() => manager.cleanup()).not.toThrow();
351
+ });
352
+ });
353
+ // =========================================================================
354
+ // 6. Auto-timeout denies after TIMEOUT_MS (using fake timers)
355
+ // =========================================================================
356
+ describe('auto-timeout (5 minutes)', () => {
357
+ beforeEach(() => {
358
+ jest.useFakeTimers();
359
+ });
360
+ afterEach(() => {
361
+ jest.useRealTimers();
362
+ });
363
+ it('auto-denies a pending prompt after TIMEOUT_MS', () => {
364
+ manager.start('ts-timeout');
365
+ // Ensure temp dir exists for our write
366
+ fs.mkdirSync(REAL_TEMP_DIR, { recursive: true });
367
+ // Write request file directly
368
+ writeRequestFile('prompt-timeout');
369
+ // Trigger a poll cycle (200ms is the POLL_INTERVAL_MS)
370
+ jest.advanceTimersByTime(200);
371
+ // At this point the manager should have found the request
372
+ // and set up a timeout. Advance to just past 5 minutes.
373
+ jest.advanceTimersByTime(5 * 60 * 1000);
374
+ // The auto-deny should have written a response file
375
+ const responseFile = path.join(REAL_TEMP_DIR, 'prompt-timeout.response.json');
376
+ expect(fs.existsSync(responseFile)).toBe(true);
377
+ const content = JSON.parse(fs.readFileSync(responseFile, 'utf-8'));
378
+ expect(content.decision).toBe('deny');
379
+ expect(content.reason).toContain('Timed out');
380
+ });
381
+ it('does NOT auto-deny if response was already provided', () => {
382
+ const events = [];
383
+ manager.on('permission_prompt', (evt) => events.push(evt));
384
+ manager.start('ts-no-double-deny');
385
+ fs.mkdirSync(REAL_TEMP_DIR, { recursive: true });
386
+ writeRequestFile('prompt-early');
387
+ // Trigger poll to detect the request
388
+ jest.advanceTimersByTime(200);
389
+ // User responds quickly
390
+ manager.handleResponse('prompt-early', 'allow', 'Approved fast');
391
+ // Read the response that was written
392
+ const responseFile = path.join(REAL_TEMP_DIR, 'prompt-early.response.json');
393
+ const earlyContent = JSON.parse(fs.readFileSync(responseFile, 'utf-8'));
394
+ expect(earlyContent.decision).toBe('allow');
395
+ // Now advance past the timeout
396
+ jest.advanceTimersByTime(5 * 60 * 1000);
397
+ // The response file should still have the 'allow' decision (not overwritten to deny)
398
+ const finalContent = JSON.parse(fs.readFileSync(responseFile, 'utf-8'));
399
+ expect(finalContent.decision).toBe('allow');
400
+ });
401
+ it('each pending prompt has its own independent timeout', () => {
402
+ manager.start('ts-independent');
403
+ fs.mkdirSync(REAL_TEMP_DIR, { recursive: true });
404
+ // Write first request
405
+ writeRequestFile('prompt-ind-1');
406
+ jest.advanceTimersByTime(200);
407
+ // 1 minute later, write second request
408
+ jest.advanceTimersByTime(60 * 1000);
409
+ writeRequestFile('prompt-ind-2');
410
+ jest.advanceTimersByTime(200);
411
+ // Advance 4 minutes from second request (so 5m 1s from first)
412
+ jest.advanceTimersByTime(4 * 60 * 1000);
413
+ // First should have timed out
414
+ const resp1 = path.join(REAL_TEMP_DIR, 'prompt-ind-1.response.json');
415
+ expect(fs.existsSync(resp1)).toBe(true);
416
+ const content1 = JSON.parse(fs.readFileSync(resp1, 'utf-8'));
417
+ expect(content1.decision).toBe('deny');
418
+ // Second should NOT have timed out yet (only ~4 minutes have passed for it)
419
+ const resp2 = path.join(REAL_TEMP_DIR, 'prompt-ind-2.response.json');
420
+ expect(fs.existsSync(resp2)).toBe(false);
421
+ // Advance the remaining minute for the second prompt
422
+ jest.advanceTimersByTime(60 * 1000);
423
+ expect(fs.existsSync(resp2)).toBe(true);
424
+ });
425
+ });
426
+ // =========================================================================
427
+ // 7. Malformed request files are skipped (logged but marked processed)
428
+ // =========================================================================
429
+ describe('malformed request files', () => {
430
+ it('skips malformed JSON and does not emit an event', async () => {
431
+ const events = [];
432
+ manager.on('permission_prompt', (evt) => events.push(evt));
433
+ manager.start('ts-malformed');
434
+ // Write a file with invalid JSON
435
+ const filePath = path.join(REAL_TEMP_DIR, 'bad-json.request.json');
436
+ fs.writeFileSync(filePath, 'this is not valid JSON{{{', 'utf-8');
437
+ // Give time for a few poll cycles
438
+ await new Promise((resolve) => setTimeout(resolve, 600));
439
+ expect(events).toHaveLength(0);
440
+ });
441
+ it('marks malformed files as processed so they are not retried', async () => {
442
+ const events = [];
443
+ manager.on('permission_prompt', (evt) => events.push(evt));
444
+ manager.start('ts-malformed-processed');
445
+ // Write malformed file
446
+ const filePath = path.join(REAL_TEMP_DIR, 'malformed.request.json');
447
+ fs.writeFileSync(filePath, '{{invalid}}', 'utf-8');
448
+ // Let multiple poll cycles pass
449
+ await new Promise((resolve) => setTimeout(resolve, 800));
450
+ // Even after many polls, no event should be emitted (file was processed once)
451
+ expect(events).toHaveLength(0);
452
+ // Now write a valid request alongside the malformed one
453
+ writeRequestFile('prompt-after-malformed');
454
+ await waitFor(() => events.length > 0);
455
+ // The valid one is picked up, but the malformed one is not retried
456
+ expect(events).toHaveLength(1);
457
+ expect(events[0].promptId).toBe('prompt-after-malformed');
458
+ });
459
+ });
460
+ // =========================================================================
461
+ // 8. Already-processed files are not re-emitted
462
+ // =========================================================================
463
+ describe('already-processed files', () => {
464
+ it('does not re-emit events for files it already processed', async () => {
465
+ const events = [];
466
+ manager.on('permission_prompt', (evt) => events.push(evt));
467
+ manager.start('ts-no-reemit');
468
+ writeRequestFile('prompt-once-only');
469
+ await waitFor(() => events.length > 0);
470
+ expect(events).toHaveLength(1);
471
+ // The file is still on disk. Wait several more poll cycles.
472
+ await new Promise((resolve) => setTimeout(resolve, 800));
473
+ // Should still be exactly 1 event
474
+ expect(events).toHaveLength(1);
475
+ });
476
+ it('new files added later are still detected', async () => {
477
+ const events = [];
478
+ manager.on('permission_prompt', (evt) => events.push(evt));
479
+ manager.start('ts-new-after-old');
480
+ writeRequestFile('prompt-first');
481
+ await waitFor(() => events.length >= 1);
482
+ // Add a second file later
483
+ writeRequestFile('prompt-second', { toolName: 'Write' });
484
+ await waitFor(() => events.length >= 2);
485
+ expect(events).toHaveLength(2);
486
+ expect(events[0].promptId).toBe('prompt-first');
487
+ expect(events[1].promptId).toBe('prompt-second');
488
+ expect(events[1].toolName).toBe('Write');
489
+ });
490
+ });
491
+ // =========================================================================
492
+ // getPendingPromptData()
493
+ // =========================================================================
494
+ describe('getPendingPromptData()', () => {
495
+ it('returns empty array when no prompts are pending', () => {
496
+ manager.start('ts-empty');
497
+ expect(manager.getPendingPromptData()).toEqual([]);
498
+ });
499
+ it('returns pending prompt data with tool details', async () => {
500
+ const events = [];
501
+ manager.on('permission_prompt', (evt) => events.push(evt));
502
+ manager.start('ts-pending-data', 'sk-abc');
503
+ writeRequestFile('prompt-data-1', {
504
+ toolName: 'Bash',
505
+ toolInput: { command: 'npm test' },
506
+ toolUseId: 'toolu_bash_01',
507
+ });
508
+ await waitFor(() => events.length > 0);
509
+ const pending = manager.getPendingPromptData();
510
+ expect(pending).toHaveLength(1);
511
+ expect(pending[0]).toEqual({
512
+ promptId: 'prompt-data-1',
513
+ terminalSessionId: 'ts-pending-data',
514
+ sessionKey: 'sk-abc',
515
+ toolName: 'Bash',
516
+ toolInput: { command: 'npm test' },
517
+ toolUseId: 'toolu_bash_01',
518
+ });
519
+ });
520
+ it('returns multiple pending prompts', async () => {
521
+ const events = [];
522
+ manager.on('permission_prompt', (evt) => events.push(evt));
523
+ manager.start('ts-multi-pending');
524
+ writeRequestFile('prompt-mp-1', { toolName: 'Bash', toolInput: { command: 'ls' }, toolUseId: 'toolu_1' });
525
+ writeRequestFile('prompt-mp-2', { toolName: 'Edit', toolInput: { file_path: '/a.ts' }, toolUseId: 'toolu_2' });
526
+ writeRequestFile('prompt-mp-3', { toolName: 'Write', toolInput: { file_path: '/b.ts' }, toolUseId: 'toolu_3' });
527
+ await waitFor(() => events.length >= 3);
528
+ const pending = manager.getPendingPromptData();
529
+ expect(pending).toHaveLength(3);
530
+ const ids = pending.map(p => p.promptId).sort();
531
+ expect(ids).toEqual(['prompt-mp-1', 'prompt-mp-2', 'prompt-mp-3']);
532
+ });
533
+ it('excludes prompts that have been responded to', async () => {
534
+ const events = [];
535
+ manager.on('permission_prompt', (evt) => events.push(evt));
536
+ manager.start('ts-exclude-responded');
537
+ writeRequestFile('prompt-keep', { toolName: 'Bash', toolInput: {}, toolUseId: 'toolu_k' });
538
+ writeRequestFile('prompt-respond', { toolName: 'Edit', toolInput: {}, toolUseId: 'toolu_r' });
539
+ await waitFor(() => events.length >= 2);
540
+ // Respond to one prompt
541
+ manager.handleResponse('prompt-respond', 'allow');
542
+ const pending = manager.getPendingPromptData();
543
+ expect(pending).toHaveLength(1);
544
+ expect(pending[0].promptId).toBe('prompt-keep');
545
+ });
546
+ });
547
+ // =========================================================================
548
+ // EventEmitter inheritance
549
+ // =========================================================================
550
+ describe('EventEmitter behavior', () => {
551
+ it('is an instance of EventEmitter', () => {
552
+ const { EventEmitter } = require('events');
553
+ expect(manager).toBeInstanceOf(EventEmitter);
554
+ });
555
+ it('supports multiple listeners on permission_prompt', async () => {
556
+ const results1 = [];
557
+ const results2 = [];
558
+ manager.on('permission_prompt', (evt) => results1.push(evt.promptId));
559
+ manager.on('permission_prompt', (evt) => results2.push(evt.promptId));
560
+ manager.start('ts-multi-listener');
561
+ writeRequestFile('prompt-multi-listen');
562
+ await waitFor(() => results1.length > 0 && results2.length > 0);
563
+ expect(results1).toEqual(['prompt-multi-listen']);
564
+ expect(results2).toEqual(['prompt-multi-listen']);
565
+ });
566
+ });
567
+ // =========================================================================
568
+ // 9. Static cleanupStaleTempFiles
569
+ // =========================================================================
570
+ describe('cleanupStaleTempFiles (static)', () => {
571
+ it('removes all files from forkoff-permissions temp directory', () => {
572
+ fs.mkdirSync(REAL_TEMP_DIR, { recursive: true });
573
+ // Write some stale files
574
+ fs.writeFileSync(path.join(REAL_TEMP_DIR, 'stale-1.request.json'), '{}', 'utf-8');
575
+ fs.writeFileSync(path.join(REAL_TEMP_DIR, 'stale-2.response.json'), '{}', 'utf-8');
576
+ fs.writeFileSync(path.join(REAL_TEMP_DIR, 'stale-3.request.json'), '{}', 'utf-8');
577
+ permission_ipc_1.PermissionIpcManager.cleanupStaleTempFiles();
578
+ const remaining = fs.readdirSync(REAL_TEMP_DIR);
579
+ expect(remaining).toHaveLength(0);
580
+ });
581
+ it('does not throw when temp directory does not exist', () => {
582
+ // Remove the directory
583
+ try {
584
+ fs.rmSync(REAL_TEMP_DIR, { recursive: true, force: true });
585
+ }
586
+ catch {
587
+ // ignore
588
+ }
589
+ expect(() => permission_ipc_1.PermissionIpcManager.cleanupStaleTempFiles()).not.toThrow();
590
+ });
591
+ it('does not throw when temp directory is empty', () => {
592
+ fs.mkdirSync(REAL_TEMP_DIR, { recursive: true });
593
+ // Ensure it's empty
594
+ for (const file of fs.readdirSync(REAL_TEMP_DIR)) {
595
+ fs.unlinkSync(path.join(REAL_TEMP_DIR, file));
596
+ }
597
+ expect(() => permission_ipc_1.PermissionIpcManager.cleanupStaleTempFiles()).not.toThrow();
598
+ });
599
+ it('removes only files, not subdirectories', () => {
600
+ fs.mkdirSync(REAL_TEMP_DIR, { recursive: true });
601
+ fs.writeFileSync(path.join(REAL_TEMP_DIR, 'stale-file.json'), '{}', 'utf-8');
602
+ fs.mkdirSync(path.join(REAL_TEMP_DIR, 'subdir'), { recursive: true });
603
+ permission_ipc_1.PermissionIpcManager.cleanupStaleTempFiles();
604
+ const remaining = fs.readdirSync(REAL_TEMP_DIR);
605
+ // subdir should remain, file should be gone
606
+ expect(remaining).toEqual(['subdir']);
607
+ // Cleanup the subdir for subsequent tests
608
+ fs.rmSync(path.join(REAL_TEMP_DIR, 'subdir'), { recursive: true, force: true });
609
+ });
610
+ });
611
+ });
612
+ //# sourceMappingURL=permission-ipc.test.js.map