ccmanager 2.11.6 → 3.0.0
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/dist/components/Configuration.js +14 -0
- package/dist/components/ConfigureCustomCommand.d.ts +9 -0
- package/dist/components/ConfigureCustomCommand.js +44 -0
- package/dist/components/ConfigureOther.d.ts +6 -0
- package/dist/components/ConfigureOther.js +87 -0
- package/dist/components/ConfigureOther.test.d.ts +1 -0
- package/dist/components/ConfigureOther.test.js +80 -0
- package/dist/components/ConfigureStatusHooks.js +7 -1
- package/dist/components/CustomCommandSummary.d.ts +6 -0
- package/dist/components/CustomCommandSummary.js +10 -0
- package/dist/components/Menu.recent-projects.test.js +2 -0
- package/dist/components/Menu.test.js +2 -0
- package/dist/components/Session.d.ts +2 -2
- package/dist/components/Session.js +67 -4
- package/dist/constants/statusIcons.d.ts +3 -1
- package/dist/constants/statusIcons.js +3 -0
- package/dist/services/autoApprovalVerifier.d.ts +25 -0
- package/dist/services/autoApprovalVerifier.js +265 -0
- package/dist/services/autoApprovalVerifier.test.d.ts +1 -0
- package/dist/services/autoApprovalVerifier.test.js +120 -0
- package/dist/services/configurationManager.d.ts +7 -0
- package/dist/services/configurationManager.js +35 -0
- package/dist/services/sessionManager.autoApproval.test.d.ts +1 -0
- package/dist/services/sessionManager.autoApproval.test.js +160 -0
- package/dist/services/sessionManager.d.ts +5 -0
- package/dist/services/sessionManager.js +149 -1
- package/dist/services/sessionManager.statePersistence.test.js +2 -0
- package/dist/services/sessionManager.test.js +6 -0
- package/dist/types/index.d.ts +14 -1
- package/dist/utils/hookExecutor.test.js +8 -0
- package/dist/utils/logger.d.ts +83 -14
- package/dist/utils/logger.js +218 -17
- package/dist/utils/worktreeUtils.test.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { ProcessError } from '../types/errors.js';
|
|
3
|
+
import { configurationManager } from './configurationManager.js';
|
|
4
|
+
import { logger } from '../utils/logger.js';
|
|
5
|
+
import { execFile, spawn, } from 'child_process';
|
|
6
|
+
const AUTO_APPROVAL_TIMEOUT_MS = 60000;
|
|
7
|
+
const createAbortError = () => {
|
|
8
|
+
const error = new Error('Auto-approval verification aborted');
|
|
9
|
+
error.name = 'AbortError';
|
|
10
|
+
return error;
|
|
11
|
+
};
|
|
12
|
+
const PLACEHOLDER = {
|
|
13
|
+
terminal: '{{TERMINAL_OUTPUT}}',
|
|
14
|
+
};
|
|
15
|
+
const PROMPT_TEMPLATE = `You are a safety gate preventing risky auto-approvals of CLI actions. Examine the terminal output below and decide if the agent must pause for user permission.
|
|
16
|
+
|
|
17
|
+
Terminal Output:
|
|
18
|
+
${PLACEHOLDER.terminal}
|
|
19
|
+
|
|
20
|
+
Return true (permission needed) if ANY of these apply:
|
|
21
|
+
- Output includes or references commands that write/modify/delete files (e.g., rm, mv, chmod, chown, cp, tee, sed -i), manage packages (npm/pip/apt/brew install), change git history, or alter configs.
|
|
22
|
+
- Privilege escalation or sensitive areas are involved (sudo, root, /etc, /var, /boot, system services), or anything touching SSH keys/credentials, browser data, environment secrets, or home dotfiles.
|
|
23
|
+
- Network or data exfiltration is possible (curl/wget, ssh/scp/rsync, docker/podman, port binding, npm publish, git push/fetch from unknown hosts).
|
|
24
|
+
- Process/system impact is likely (kill, pkill, systemctl, reboot, heavy loops, resource-intensive builds/tests, spawning many processes).
|
|
25
|
+
- Signs of command injection, untrusted input being executed, or unclear placeholders like \`<path>\`, \`$(...)\`, backticks, or pipes that could be unsafe.
|
|
26
|
+
- Errors, warnings, ambiguous states, manual review requests, or anything not clearly safe/read-only.
|
|
27
|
+
|
|
28
|
+
Return false (auto-approve) when:
|
|
29
|
+
- The output clearly shows explicit user intent/confirmation to run the exact action (e.g., user typed the command AND confirmed, or explicitly said “I want to delete <path>; please do it now”). Explicit intent should normally override the risk list unless there are signs of coercion/compromise, the target path is unclear, or the action differs from what was confirmed.
|
|
30
|
+
- The output shows strictly read-only, low-risk operations (e.g., lint/test passing, help text, formatting dry runs, simple logs) with no pending commands that could change the system or touch sensitive data.
|
|
31
|
+
|
|
32
|
+
When unsure, return true.
|
|
33
|
+
|
|
34
|
+
Respond with ONLY valid JSON matching: {"needsPermission": true|false, "reason"?: string}. When needsPermission is true, include a brief reason (<=140 chars) explaining why permission is needed. Do not add any other fields or text.`;
|
|
35
|
+
const buildPrompt = (terminalOutput) => PROMPT_TEMPLATE.replace(PLACEHOLDER.terminal, terminalOutput);
|
|
36
|
+
/**
|
|
37
|
+
* Service to verify if auto-approval should be granted for pending states
|
|
38
|
+
* Uses Claude Haiku model to analyze terminal output and determine if
|
|
39
|
+
* user permission is required before proceeding
|
|
40
|
+
*/
|
|
41
|
+
export class AutoApprovalVerifier {
|
|
42
|
+
constructor() {
|
|
43
|
+
Object.defineProperty(this, "model", {
|
|
44
|
+
enumerable: true,
|
|
45
|
+
configurable: true,
|
|
46
|
+
writable: true,
|
|
47
|
+
value: 'haiku'
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
createExecOptions(signal) {
|
|
51
|
+
return {
|
|
52
|
+
encoding: 'utf8',
|
|
53
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
54
|
+
signal,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
runClaudePrompt(prompt, jsonSchema, signal) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
let settled = false;
|
|
60
|
+
let child;
|
|
61
|
+
const execOptions = this.createExecOptions(signal);
|
|
62
|
+
const settle = (action) => {
|
|
63
|
+
if (settled)
|
|
64
|
+
return;
|
|
65
|
+
settled = true;
|
|
66
|
+
removeAbortListener();
|
|
67
|
+
clearTimeout(timeoutId);
|
|
68
|
+
action();
|
|
69
|
+
};
|
|
70
|
+
const abortListener = () => {
|
|
71
|
+
settle(() => {
|
|
72
|
+
if (child?.pid) {
|
|
73
|
+
child.kill('SIGKILL');
|
|
74
|
+
}
|
|
75
|
+
reject(createAbortError());
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
const removeAbortListener = () => {
|
|
79
|
+
if (!signal)
|
|
80
|
+
return;
|
|
81
|
+
signal.removeEventListener('abort', abortListener);
|
|
82
|
+
};
|
|
83
|
+
const timeoutId = setTimeout(() => {
|
|
84
|
+
settle(() => {
|
|
85
|
+
logger.warn('Auto-approval verification timed out, terminating helper Claude process');
|
|
86
|
+
if (child?.pid) {
|
|
87
|
+
child.kill('SIGKILL');
|
|
88
|
+
}
|
|
89
|
+
reject(new Error('Auto-approval verification timed out after 60s'));
|
|
90
|
+
});
|
|
91
|
+
}, AUTO_APPROVAL_TIMEOUT_MS);
|
|
92
|
+
if (signal) {
|
|
93
|
+
if (signal.aborted) {
|
|
94
|
+
abortListener();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
signal.addEventListener('abort', abortListener, { once: true });
|
|
98
|
+
}
|
|
99
|
+
child = execFile('claude', [
|
|
100
|
+
'--model',
|
|
101
|
+
this.model,
|
|
102
|
+
'-p',
|
|
103
|
+
'--output-format',
|
|
104
|
+
'json',
|
|
105
|
+
'--json-schema',
|
|
106
|
+
jsonSchema,
|
|
107
|
+
], execOptions, (error, stdout) => {
|
|
108
|
+
settle(() => {
|
|
109
|
+
if (error) {
|
|
110
|
+
reject(error);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
resolve(stdout);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
child.stderr?.on('data', chunk => {
|
|
117
|
+
logger.debug('Auto-approval stderr chunk', chunk.toString());
|
|
118
|
+
});
|
|
119
|
+
child.on('error', err => {
|
|
120
|
+
settle(() => reject(err));
|
|
121
|
+
});
|
|
122
|
+
if (child.stdin) {
|
|
123
|
+
child.stdin.write(prompt);
|
|
124
|
+
child.stdin.end();
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
settle(() => reject(new Error('claude stdin unavailable')));
|
|
128
|
+
}
|
|
129
|
+
child.on('close', code => {
|
|
130
|
+
if (code && code !== 0) {
|
|
131
|
+
settle(() => reject(new Error(`claude exited with code ${code}`)));
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
async runCustomCommand(command, prompt, terminalOutput, signal) {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
let settled = false;
|
|
139
|
+
let timeoutId;
|
|
140
|
+
const settle = (action) => {
|
|
141
|
+
if (settled)
|
|
142
|
+
return;
|
|
143
|
+
settled = true;
|
|
144
|
+
clearTimeout(timeoutId);
|
|
145
|
+
if (signal) {
|
|
146
|
+
signal.removeEventListener('abort', abortListener);
|
|
147
|
+
}
|
|
148
|
+
action();
|
|
149
|
+
};
|
|
150
|
+
const abortListener = () => {
|
|
151
|
+
settle(() => {
|
|
152
|
+
child.kill('SIGKILL');
|
|
153
|
+
reject(createAbortError());
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
const spawnOptions = {
|
|
157
|
+
shell: true,
|
|
158
|
+
env: {
|
|
159
|
+
...process.env,
|
|
160
|
+
DEFAULT_PROMPT: prompt,
|
|
161
|
+
TERMINAL_OUTPUT: terminalOutput,
|
|
162
|
+
},
|
|
163
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
164
|
+
signal,
|
|
165
|
+
};
|
|
166
|
+
const child = spawn(command, [], spawnOptions);
|
|
167
|
+
let stdout = '';
|
|
168
|
+
let stderr = '';
|
|
169
|
+
timeoutId = setTimeout(() => {
|
|
170
|
+
logger.warn('Auto-approval custom command timed out, terminating process');
|
|
171
|
+
settle(() => {
|
|
172
|
+
child.kill('SIGKILL');
|
|
173
|
+
reject(new Error('Auto-approval verification custom command timed out after 60s'));
|
|
174
|
+
});
|
|
175
|
+
}, AUTO_APPROVAL_TIMEOUT_MS);
|
|
176
|
+
if (signal) {
|
|
177
|
+
if (signal.aborted) {
|
|
178
|
+
abortListener();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
signal.addEventListener('abort', abortListener, { once: true });
|
|
182
|
+
}
|
|
183
|
+
child.stdout?.on('data', chunk => {
|
|
184
|
+
stdout += chunk.toString();
|
|
185
|
+
});
|
|
186
|
+
child.stderr?.on('data', chunk => {
|
|
187
|
+
const data = chunk.toString();
|
|
188
|
+
stderr += data;
|
|
189
|
+
logger.debug('Auto-approval custom command stderr', data);
|
|
190
|
+
});
|
|
191
|
+
child.on('error', error => {
|
|
192
|
+
settle(() => reject(error));
|
|
193
|
+
});
|
|
194
|
+
child.on('exit', (code, signalExit) => {
|
|
195
|
+
settle(() => {
|
|
196
|
+
if (code === 0) {
|
|
197
|
+
resolve(stdout);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const message = signalExit !== null
|
|
201
|
+
? `Custom command terminated by signal ${signalExit}`
|
|
202
|
+
: `Custom command exited with code ${code}`;
|
|
203
|
+
reject(new Error(stderr ? `${message}\nStderr: ${stderr}` : message));
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Verify if the current terminal output requires user permission
|
|
210
|
+
* before proceeding with auto-approval
|
|
211
|
+
*
|
|
212
|
+
* @param terminalOutput - Current terminal output to analyze
|
|
213
|
+
* @returns Effect that resolves to true if permission needed, false if can auto-approve
|
|
214
|
+
*/
|
|
215
|
+
verifyNeedsPermission(terminalOutput, options) {
|
|
216
|
+
const attemptVerification = Effect.tryPromise({
|
|
217
|
+
try: async () => {
|
|
218
|
+
const autoApprovalConfig = configurationManager.getAutoApprovalConfig();
|
|
219
|
+
const customCommand = autoApprovalConfig.customCommand?.trim();
|
|
220
|
+
const prompt = buildPrompt(terminalOutput);
|
|
221
|
+
const jsonSchema = JSON.stringify({
|
|
222
|
+
type: 'object',
|
|
223
|
+
properties: {
|
|
224
|
+
needsPermission: {
|
|
225
|
+
type: 'boolean',
|
|
226
|
+
description: 'Whether user permission is needed before auto-approval',
|
|
227
|
+
},
|
|
228
|
+
reason: {
|
|
229
|
+
type: 'string',
|
|
230
|
+
description: 'Optional reason describing why user permission is needed',
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
required: ['needsPermission'],
|
|
234
|
+
});
|
|
235
|
+
const signal = options?.signal;
|
|
236
|
+
if (signal?.aborted) {
|
|
237
|
+
throw createAbortError();
|
|
238
|
+
}
|
|
239
|
+
const responseText = customCommand
|
|
240
|
+
? await this.runCustomCommand(customCommand, prompt, terminalOutput, signal)
|
|
241
|
+
: await this.runClaudePrompt(prompt, jsonSchema, signal);
|
|
242
|
+
return JSON.parse(responseText);
|
|
243
|
+
},
|
|
244
|
+
catch: (error) => error,
|
|
245
|
+
});
|
|
246
|
+
return Effect.catchAll(attemptVerification, (error) => {
|
|
247
|
+
if (error.name === 'AbortError') {
|
|
248
|
+
return Effect.fail(new ProcessError({
|
|
249
|
+
command: 'autoApprovalVerifier.verifyNeedsPermission',
|
|
250
|
+
message: 'Auto-approval verification aborted',
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
const isParseError = error instanceof SyntaxError;
|
|
254
|
+
const reason = isParseError
|
|
255
|
+
? 'Failed to parse auto-approval helper response'
|
|
256
|
+
: 'Auto-approval helper command failed';
|
|
257
|
+
logger.error(reason, error);
|
|
258
|
+
return Effect.succeed({
|
|
259
|
+
needsPermission: true,
|
|
260
|
+
reason: `${reason}: ${error.message ?? 'unknown error'}`,
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
export const autoApprovalVerifier = new AutoApprovalVerifier();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { EventEmitter } from 'events';
|
|
4
|
+
const execFileMock = vi.fn();
|
|
5
|
+
vi.mock('child_process', () => ({
|
|
6
|
+
execFile: (...args) => execFileMock(...args),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('./configurationManager.js', () => ({
|
|
9
|
+
configurationManager: {
|
|
10
|
+
getAutoApprovalConfig: vi.fn().mockReturnValue({ enabled: false }),
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
describe('AutoApprovalVerifier', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.useFakeTimers();
|
|
16
|
+
execFileMock.mockImplementation((_cmd, _args, _options, callback) => {
|
|
17
|
+
const child = new EventEmitter();
|
|
18
|
+
const write = vi.fn();
|
|
19
|
+
const end = vi.fn();
|
|
20
|
+
child.stdin = { write, end };
|
|
21
|
+
setTimeout(() => {
|
|
22
|
+
callback(null, '{"needsPermission":false}', '');
|
|
23
|
+
child.emit('close', 0);
|
|
24
|
+
}, 5);
|
|
25
|
+
return child;
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
vi.useRealTimers();
|
|
30
|
+
vi.clearAllMocks();
|
|
31
|
+
});
|
|
32
|
+
it('executes claude check asynchronously without blocking input', async () => {
|
|
33
|
+
const { autoApprovalVerifier } = await import('./autoApprovalVerifier.js');
|
|
34
|
+
let ticked = false;
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
ticked = true;
|
|
37
|
+
}, 1);
|
|
38
|
+
const needsPermissionPromise = Effect.runPromise(autoApprovalVerifier.verifyNeedsPermission('output'));
|
|
39
|
+
await vi.runAllTimersAsync();
|
|
40
|
+
const result = await needsPermissionPromise;
|
|
41
|
+
expect(result.needsPermission).toBe(false);
|
|
42
|
+
expect(ticked).toBe(true);
|
|
43
|
+
const child = execFileMock.mock.results[0]?.value;
|
|
44
|
+
expect(execFileMock).toHaveBeenCalledWith('claude', expect.arrayContaining(['--model', 'haiku']), expect.objectContaining({ encoding: 'utf8' }), expect.any(Function));
|
|
45
|
+
expect(child.stdin.write).toHaveBeenCalledTimes(1);
|
|
46
|
+
expect(child.stdin.end).toHaveBeenCalledTimes(1);
|
|
47
|
+
});
|
|
48
|
+
it('returns true when Claude response indicates permission is needed', async () => {
|
|
49
|
+
execFileMock.mockImplementationOnce((_cmd, _args, _options, callback) => {
|
|
50
|
+
const child = new EventEmitter();
|
|
51
|
+
child.stdin = { write: vi.fn(), end: vi.fn() };
|
|
52
|
+
setTimeout(() => {
|
|
53
|
+
callback(null, '{"needsPermission":true}', '');
|
|
54
|
+
child.emit('close', 0);
|
|
55
|
+
}, 0);
|
|
56
|
+
return child;
|
|
57
|
+
});
|
|
58
|
+
const { autoApprovalVerifier } = await import('./autoApprovalVerifier.js');
|
|
59
|
+
const resultPromise = Effect.runPromise(autoApprovalVerifier.verifyNeedsPermission('Error: critical'));
|
|
60
|
+
await vi.runAllTimersAsync();
|
|
61
|
+
const result = await resultPromise;
|
|
62
|
+
expect(result.needsPermission).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
it('defaults to requiring permission on malformed JSON', async () => {
|
|
65
|
+
execFileMock.mockImplementationOnce((_cmd, _args, _options, callback) => {
|
|
66
|
+
const child = new EventEmitter();
|
|
67
|
+
child.stdin = { write: vi.fn(), end: vi.fn() };
|
|
68
|
+
setTimeout(() => {
|
|
69
|
+
callback(null, 'not-json', '');
|
|
70
|
+
child.emit('close', 0);
|
|
71
|
+
}, 0);
|
|
72
|
+
return child;
|
|
73
|
+
});
|
|
74
|
+
const { autoApprovalVerifier } = await import('./autoApprovalVerifier.js');
|
|
75
|
+
const resultPromise = Effect.runPromise(autoApprovalVerifier.verifyNeedsPermission('logs'));
|
|
76
|
+
await vi.runAllTimersAsync();
|
|
77
|
+
const result = await resultPromise;
|
|
78
|
+
expect(result.needsPermission).toBe(true);
|
|
79
|
+
expect(result.reason).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
it('defaults to requiring permission when execution errors', async () => {
|
|
82
|
+
execFileMock.mockImplementationOnce((_cmd, _args, _options, callback) => {
|
|
83
|
+
const child = new EventEmitter();
|
|
84
|
+
child.stdin = { write: vi.fn(), end: vi.fn() };
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
callback(new Error('Command failed'), '', '');
|
|
87
|
+
child.emit('close', 1);
|
|
88
|
+
}, 0);
|
|
89
|
+
return child;
|
|
90
|
+
});
|
|
91
|
+
const { autoApprovalVerifier } = await import('./autoApprovalVerifier.js');
|
|
92
|
+
const resultPromise = Effect.runPromise(autoApprovalVerifier.verifyNeedsPermission('logs'));
|
|
93
|
+
await vi.runAllTimersAsync();
|
|
94
|
+
const result = await resultPromise;
|
|
95
|
+
expect(result.needsPermission).toBe(true);
|
|
96
|
+
expect(result.reason).toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
it('passes JSON schema flag and prompt content to claude helper', async () => {
|
|
99
|
+
const write = vi.fn();
|
|
100
|
+
const terminalOutput = 'test output';
|
|
101
|
+
execFileMock.mockImplementationOnce((_cmd, args, _options, callback) => {
|
|
102
|
+
const child = new EventEmitter();
|
|
103
|
+
child.stdin = { write, end: vi.fn() };
|
|
104
|
+
setTimeout(() => {
|
|
105
|
+
callback(null, '{"needsPermission":false}', '');
|
|
106
|
+
child.emit('close', 0);
|
|
107
|
+
}, 0);
|
|
108
|
+
// Capture the args for assertions
|
|
109
|
+
child.capturedArgs = args;
|
|
110
|
+
return child;
|
|
111
|
+
});
|
|
112
|
+
const { autoApprovalVerifier } = await import('./autoApprovalVerifier.js');
|
|
113
|
+
const resultPromise = Effect.runPromise(autoApprovalVerifier.verifyNeedsPermission(terminalOutput));
|
|
114
|
+
await vi.runAllTimersAsync();
|
|
115
|
+
await resultPromise;
|
|
116
|
+
const args = execFileMock.mock.calls[0]?.[1] ?? [];
|
|
117
|
+
expect(args).toEqual(expect.arrayContaining(['--output-format', 'json', '--json-schema']));
|
|
118
|
+
expect(write).toHaveBeenCalledWith(expect.stringContaining(terminalOutput));
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -21,6 +21,9 @@ export declare class ConfigurationManager {
|
|
|
21
21
|
setConfiguration(config: ConfigurationData): void;
|
|
22
22
|
getWorktreeConfig(): WorktreeConfig;
|
|
23
23
|
setWorktreeConfig(worktreeConfig: WorktreeConfig): void;
|
|
24
|
+
getAutoApprovalConfig(): NonNullable<ConfigurationData['autoApproval']>;
|
|
25
|
+
setAutoApprovalConfig(autoApproval: NonNullable<ConfigurationData['autoApproval']>): void;
|
|
26
|
+
setAutoApprovalEnabled(enabled: boolean): void;
|
|
24
27
|
getCommandConfig(): CommandConfig;
|
|
25
28
|
setCommandConfig(commandConfig: CommandConfig): void;
|
|
26
29
|
private migrateLegacyCommandToPresets;
|
|
@@ -109,5 +112,9 @@ export declare class ConfigurationManager {
|
|
|
109
112
|
* Synchronous legacy shortcuts migration helper
|
|
110
113
|
*/
|
|
111
114
|
private migrateLegacyShortcutsSync;
|
|
115
|
+
/**
|
|
116
|
+
* Get whether auto-approval is enabled
|
|
117
|
+
*/
|
|
118
|
+
isAutoApprovalEnabled(): boolean;
|
|
112
119
|
}
|
|
113
120
|
export declare const configurationManager: ConfigurationManager;
|
|
@@ -99,6 +99,14 @@ export class ConfigurationManager {
|
|
|
99
99
|
command: 'claude',
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
|
+
if (!this.config.autoApproval) {
|
|
103
|
+
this.config.autoApproval = {
|
|
104
|
+
enabled: false,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
else if (!Object.prototype.hasOwnProperty.call(this.config.autoApproval, 'enabled')) {
|
|
108
|
+
this.config.autoApproval.enabled = false;
|
|
109
|
+
}
|
|
102
110
|
// Migrate legacy command config to presets if needed
|
|
103
111
|
this.migrateLegacyCommandToPresets();
|
|
104
112
|
}
|
|
@@ -165,6 +173,19 @@ export class ConfigurationManager {
|
|
|
165
173
|
this.config.worktree = worktreeConfig;
|
|
166
174
|
this.saveConfig();
|
|
167
175
|
}
|
|
176
|
+
getAutoApprovalConfig() {
|
|
177
|
+
return (this.config.autoApproval || {
|
|
178
|
+
enabled: false,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
setAutoApprovalConfig(autoApproval) {
|
|
182
|
+
this.config.autoApproval = autoApproval;
|
|
183
|
+
this.saveConfig();
|
|
184
|
+
}
|
|
185
|
+
setAutoApprovalEnabled(enabled) {
|
|
186
|
+
const currentConfig = this.getAutoApprovalConfig();
|
|
187
|
+
this.setAutoApprovalConfig({ ...currentConfig, enabled });
|
|
188
|
+
}
|
|
168
189
|
getCommandConfig() {
|
|
169
190
|
// For backward compatibility, return the default preset as CommandConfig
|
|
170
191
|
const defaultPreset = this.getDefaultPreset();
|
|
@@ -511,6 +532,14 @@ export class ConfigurationManager {
|
|
|
511
532
|
command: 'claude',
|
|
512
533
|
};
|
|
513
534
|
}
|
|
535
|
+
if (!config.autoApproval) {
|
|
536
|
+
config.autoApproval = {
|
|
537
|
+
enabled: false,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
else if (!Object.prototype.hasOwnProperty.call(config.autoApproval, 'enabled')) {
|
|
541
|
+
config.autoApproval.enabled = false;
|
|
542
|
+
}
|
|
514
543
|
return config;
|
|
515
544
|
}
|
|
516
545
|
/**
|
|
@@ -537,5 +566,11 @@ export class ConfigurationManager {
|
|
|
537
566
|
}
|
|
538
567
|
return null;
|
|
539
568
|
}
|
|
569
|
+
/**
|
|
570
|
+
* Get whether auto-approval is enabled
|
|
571
|
+
*/
|
|
572
|
+
isAutoApprovalEnabled() {
|
|
573
|
+
return this.config.autoApproval?.enabled ?? false;
|
|
574
|
+
}
|
|
540
575
|
}
|
|
541
576
|
export const configurationManager = new ConfigurationManager();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import { spawn } from 'node-pty';
|
|
4
|
+
import { STATE_CHECK_INTERVAL_MS, STATE_PERSISTENCE_DURATION_MS, } from '../constants/statePersistence.js';
|
|
5
|
+
import { Effect } from 'effect';
|
|
6
|
+
const detectStateMock = vi.fn();
|
|
7
|
+
const verifyNeedsPermissionMock = vi.fn(() => Effect.succeed({ needsPermission: false }));
|
|
8
|
+
vi.mock('node-pty', () => ({
|
|
9
|
+
spawn: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock('./stateDetector.js', () => ({
|
|
12
|
+
createStateDetector: () => ({ detectState: detectStateMock }),
|
|
13
|
+
}));
|
|
14
|
+
vi.mock('./configurationManager.js', () => ({
|
|
15
|
+
configurationManager: {
|
|
16
|
+
getConfig: vi.fn().mockReturnValue({
|
|
17
|
+
commands: [
|
|
18
|
+
{
|
|
19
|
+
id: 'test',
|
|
20
|
+
name: 'Test',
|
|
21
|
+
command: 'test',
|
|
22
|
+
args: [],
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
defaultCommandId: 'test',
|
|
26
|
+
}),
|
|
27
|
+
getPresetById: vi.fn().mockReturnValue({
|
|
28
|
+
id: 'test',
|
|
29
|
+
name: 'Test',
|
|
30
|
+
command: 'test',
|
|
31
|
+
args: [],
|
|
32
|
+
}),
|
|
33
|
+
getDefaultPreset: vi.fn().mockReturnValue({
|
|
34
|
+
id: 'test',
|
|
35
|
+
name: 'Test',
|
|
36
|
+
command: 'test',
|
|
37
|
+
args: [],
|
|
38
|
+
}),
|
|
39
|
+
getHooks: vi.fn().mockReturnValue({}),
|
|
40
|
+
getStatusHooks: vi.fn().mockReturnValue({}),
|
|
41
|
+
setWorktreeLastOpened: vi.fn(),
|
|
42
|
+
getWorktreeLastOpenedTime: vi.fn(),
|
|
43
|
+
getWorktreeLastOpened: vi.fn(() => ({})),
|
|
44
|
+
isAutoApprovalEnabled: vi.fn(() => true),
|
|
45
|
+
setAutoApprovalEnabled: vi.fn(),
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
48
|
+
vi.mock('@xterm/headless', () => ({
|
|
49
|
+
default: {
|
|
50
|
+
Terminal: vi.fn().mockImplementation(() => ({
|
|
51
|
+
buffer: {
|
|
52
|
+
active: {
|
|
53
|
+
length: 0,
|
|
54
|
+
getLine: vi.fn(),
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
write: vi.fn(),
|
|
58
|
+
})),
|
|
59
|
+
},
|
|
60
|
+
}));
|
|
61
|
+
vi.mock('./autoApprovalVerifier.js', () => ({
|
|
62
|
+
autoApprovalVerifier: { verifyNeedsPermission: verifyNeedsPermissionMock },
|
|
63
|
+
}));
|
|
64
|
+
describe('SessionManager - Auto Approval Recovery', () => {
|
|
65
|
+
let SessionManager;
|
|
66
|
+
let sessionManager;
|
|
67
|
+
let mockPtyInstances;
|
|
68
|
+
let eventEmitters;
|
|
69
|
+
beforeEach(async () => {
|
|
70
|
+
vi.useFakeTimers();
|
|
71
|
+
detectStateMock.mockReset();
|
|
72
|
+
verifyNeedsPermissionMock.mockClear();
|
|
73
|
+
mockPtyInstances = new Map();
|
|
74
|
+
eventEmitters = new Map();
|
|
75
|
+
spawn.mockImplementation((_command, _args, options) => {
|
|
76
|
+
const path = options.cwd;
|
|
77
|
+
const eventEmitter = new EventEmitter();
|
|
78
|
+
eventEmitters.set(path, eventEmitter);
|
|
79
|
+
const mockPty = {
|
|
80
|
+
onData: vi.fn((callback) => {
|
|
81
|
+
eventEmitter.on('data', callback);
|
|
82
|
+
return { dispose: vi.fn() };
|
|
83
|
+
}),
|
|
84
|
+
onExit: vi.fn((callback) => {
|
|
85
|
+
eventEmitter.on('exit', callback);
|
|
86
|
+
return { dispose: vi.fn() };
|
|
87
|
+
}),
|
|
88
|
+
write: vi.fn(),
|
|
89
|
+
resize: vi.fn(),
|
|
90
|
+
kill: vi.fn(),
|
|
91
|
+
process: 'test',
|
|
92
|
+
pid: 12345 + mockPtyInstances.size,
|
|
93
|
+
};
|
|
94
|
+
mockPtyInstances.set(path, mockPty);
|
|
95
|
+
return mockPty;
|
|
96
|
+
});
|
|
97
|
+
// Detection sequence: first prompt (no auto-approval), back to busy, second prompt (should auto-approve)
|
|
98
|
+
const detectionStates = [
|
|
99
|
+
'waiting_input',
|
|
100
|
+
'waiting_input',
|
|
101
|
+
'waiting_input',
|
|
102
|
+
'busy',
|
|
103
|
+
'busy',
|
|
104
|
+
'busy',
|
|
105
|
+
'waiting_input',
|
|
106
|
+
'waiting_input',
|
|
107
|
+
'waiting_input',
|
|
108
|
+
];
|
|
109
|
+
let callIndex = 0;
|
|
110
|
+
detectStateMock.mockImplementation(() => {
|
|
111
|
+
const state = detectionStates[Math.min(callIndex, detectionStates.length - 1)];
|
|
112
|
+
callIndex++;
|
|
113
|
+
return state;
|
|
114
|
+
});
|
|
115
|
+
const sessionManagerModule = await import('./sessionManager.js');
|
|
116
|
+
SessionManager = sessionManagerModule.SessionManager;
|
|
117
|
+
sessionManager = new SessionManager();
|
|
118
|
+
});
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
sessionManager.destroy();
|
|
121
|
+
vi.useRealTimers();
|
|
122
|
+
vi.clearAllMocks();
|
|
123
|
+
});
|
|
124
|
+
it('re-enables auto approval after leaving waiting_input', async () => {
|
|
125
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
126
|
+
// Simulate a prior auto-approval failure
|
|
127
|
+
session.autoApprovalFailed = true;
|
|
128
|
+
// First waiting_input cycle (auto-approval suppressed)
|
|
129
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 3);
|
|
130
|
+
expect(session.state).toBe('waiting_input');
|
|
131
|
+
expect(session.autoApprovalFailed).toBe(true);
|
|
132
|
+
// Transition back to busy should reset the failure flag
|
|
133
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 3);
|
|
134
|
+
expect(session.state).toBe('busy');
|
|
135
|
+
expect(session.autoApprovalFailed).toBe(false);
|
|
136
|
+
// Next waiting_input should trigger pending_auto_approval
|
|
137
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 3 + STATE_PERSISTENCE_DURATION_MS);
|
|
138
|
+
expect(session.state).toBe('pending_auto_approval');
|
|
139
|
+
await Promise.resolve(); // allow handleAutoApproval promise to resolve
|
|
140
|
+
expect(verifyNeedsPermissionMock).toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
it('cancels auto approval when user input is detected', async () => {
|
|
143
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
144
|
+
const abortController = new AbortController();
|
|
145
|
+
session.state = 'pending_auto_approval';
|
|
146
|
+
session.autoApprovalAbortController = abortController;
|
|
147
|
+
session.pendingState = 'pending_auto_approval';
|
|
148
|
+
session.pendingStateStart = Date.now();
|
|
149
|
+
const handler = vi.fn();
|
|
150
|
+
sessionManager.on('sessionStateChanged', handler);
|
|
151
|
+
sessionManager.cancelAutoApproval(session.worktreePath, 'User pressed a key');
|
|
152
|
+
expect(abortController.signal.aborted).toBe(true);
|
|
153
|
+
expect(session.autoApprovalAbortController).toBeUndefined();
|
|
154
|
+
expect(session.autoApprovalFailed).toBe(true);
|
|
155
|
+
expect(session.state).toBe('waiting_input');
|
|
156
|
+
expect(session.pendingState).toBeUndefined();
|
|
157
|
+
expect(handler).toHaveBeenCalledWith(session);
|
|
158
|
+
sessionManager.off('sessionStateChanged', handler);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -6,6 +6,7 @@ export interface SessionCounts {
|
|
|
6
6
|
idle: number;
|
|
7
7
|
busy: number;
|
|
8
8
|
waiting_input: number;
|
|
9
|
+
pending_auto_approval: number;
|
|
9
10
|
total: number;
|
|
10
11
|
}
|
|
11
12
|
export declare class SessionManager extends EventEmitter implements ISessionManager {
|
|
@@ -14,6 +15,9 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
14
15
|
private busyTimers;
|
|
15
16
|
private spawn;
|
|
16
17
|
detectTerminalState(session: Session): SessionState;
|
|
18
|
+
private getTerminalContent;
|
|
19
|
+
private handleAutoApproval;
|
|
20
|
+
private cancelAutoApprovalVerification;
|
|
17
21
|
constructor();
|
|
18
22
|
private createSessionId;
|
|
19
23
|
private createTerminal;
|
|
@@ -50,6 +54,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
50
54
|
private cleanupSession;
|
|
51
55
|
getSession(worktreePath: string): Session | undefined;
|
|
52
56
|
setSessionActive(worktreePath: string, active: boolean): void;
|
|
57
|
+
cancelAutoApproval(worktreePath: string, reason?: string): void;
|
|
53
58
|
destroySession(worktreePath: string): void;
|
|
54
59
|
/**
|
|
55
60
|
* Terminate session and cleanup resources using Effect-based error handling
|