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.
- package/README.md +7 -4
- package/dist/__tests__/cli-commands.test.d.ts +6 -0
- package/dist/__tests__/cli-commands.test.d.ts.map +1 -0
- package/dist/__tests__/cli-commands.test.js +213 -0
- package/dist/__tests__/cli-commands.test.js.map +1 -0
- package/dist/__tests__/startup.test.d.ts +11 -0
- package/dist/__tests__/startup.test.d.ts.map +1 -0
- package/dist/__tests__/startup.test.js +234 -0
- package/dist/__tests__/startup.test.js.map +1 -0
- package/dist/__tests__/tools/claude-process.test.js +221 -15
- package/dist/__tests__/tools/claude-process.test.js.map +1 -1
- package/dist/__tests__/tools/permission-hook.test.d.ts +17 -0
- package/dist/__tests__/tools/permission-hook.test.d.ts.map +1 -0
- package/dist/__tests__/tools/permission-hook.test.js +616 -0
- package/dist/__tests__/tools/permission-hook.test.js.map +1 -0
- package/dist/__tests__/tools/permission-ipc.test.d.ts +11 -0
- package/dist/__tests__/tools/permission-ipc.test.d.ts.map +1 -0
- package/dist/__tests__/tools/permission-ipc.test.js +612 -0
- package/dist/__tests__/tools/permission-ipc.test.js.map +1 -0
- package/dist/config.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1010 -898
- package/dist/index.js.map +1 -1
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +45 -15
- package/dist/startup.js.map +1 -1
- package/dist/tools/__tests__/claude-sessions.test.d.ts +2 -0
- package/dist/tools/__tests__/claude-sessions.test.d.ts.map +1 -0
- package/dist/tools/__tests__/claude-sessions.test.js +306 -0
- package/dist/tools/__tests__/claude-sessions.test.js.map +1 -0
- package/dist/tools/claude-process.d.ts +81 -4
- package/dist/tools/claude-process.d.ts.map +1 -1
- package/dist/tools/claude-process.js +332 -20
- package/dist/tools/claude-process.js.map +1 -1
- package/dist/tools/claude-sessions.d.ts +5 -0
- package/dist/tools/claude-sessions.d.ts.map +1 -1
- package/dist/tools/claude-sessions.js +16 -2
- package/dist/tools/claude-sessions.js.map +1 -1
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +3 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/permission-hook.d.ts +41 -0
- package/dist/tools/permission-hook.d.ts.map +1 -0
- package/dist/tools/permission-hook.js +312 -0
- package/dist/tools/permission-hook.js.map +1 -0
- package/dist/tools/permission-ipc.d.ts +109 -0
- package/dist/tools/permission-ipc.d.ts.map +1 -0
- package/dist/tools/permission-ipc.js +295 -0
- package/dist/tools/permission-ipc.js.map +1 -0
- package/dist/websocket.d.ts +14 -0
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +34 -4
- package/dist/websocket.js.map +1 -1
- package/jest.config.js +3 -0
- 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
|