agent-gear 1.1.10 → 1.2.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.
Files changed (51) hide show
  1. package/dist/acp/bridge.d.ts.map +1 -1
  2. package/dist/acp/bridge.js +11 -2
  3. package/dist/acp/bridge.js.map +1 -1
  4. package/dist/acp/bridge.test.js +29 -0
  5. package/dist/acp/bridge.test.js.map +1 -1
  6. package/dist/acp/process-registry.d.ts +30 -0
  7. package/dist/acp/process-registry.d.ts.map +1 -0
  8. package/dist/acp/process-registry.integration.test.d.ts +2 -0
  9. package/dist/acp/process-registry.integration.test.d.ts.map +1 -0
  10. package/dist/acp/process-registry.integration.test.js +52 -0
  11. package/dist/acp/process-registry.integration.test.js.map +1 -0
  12. package/dist/acp/process-registry.js +110 -0
  13. package/dist/acp/process-registry.js.map +1 -0
  14. package/dist/acp/process-registry.test.d.ts +2 -0
  15. package/dist/acp/process-registry.test.d.ts.map +1 -0
  16. package/dist/acp/process-registry.test.js +209 -0
  17. package/dist/acp/process-registry.test.js.map +1 -0
  18. package/dist/acp/spawner.d.ts +1 -0
  19. package/dist/acp/spawner.d.ts.map +1 -1
  20. package/dist/acp/spawner.js +32 -10
  21. package/dist/acp/spawner.js.map +1 -1
  22. package/dist/fs-handler.d.ts +11 -0
  23. package/dist/fs-handler.d.ts.map +1 -1
  24. package/dist/fs-handler.js +79 -2
  25. package/dist/fs-handler.js.map +1 -1
  26. package/dist/fs-handler.test.js +78 -1
  27. package/dist/fs-handler.test.js.map +1 -1
  28. package/dist/index.js +32 -2
  29. package/dist/index.js.map +1 -1
  30. package/dist/socketio-client.d.ts.map +1 -1
  31. package/dist/socketio-client.js +177 -4
  32. package/dist/socketio-client.js.map +1 -1
  33. package/dist/worktree-handler.d.ts +12 -1
  34. package/dist/worktree-handler.d.ts.map +1 -1
  35. package/dist/worktree-handler.js +28 -2
  36. package/dist/worktree-handler.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/acp/bridge.test.ts +37 -0
  39. package/src/acp/bridge.ts +11 -2
  40. package/src/acp/process-registry.integration.test.ts +65 -0
  41. package/src/acp/process-registry.test.ts +239 -0
  42. package/src/acp/process-registry.ts +139 -0
  43. package/src/acp/spawner.ts +26 -10
  44. package/src/fs-handler.test.ts +106 -2
  45. package/src/fs-handler.ts +88 -3
  46. package/src/index.ts +27 -2
  47. package/src/socketio-client.ts +176 -5
  48. package/src/worktree-handler.ts +41 -2
  49. package/tests/acp-spawner.test.ts +58 -26
  50. package/tests/fs-handler.test.ts +63 -0
  51. package/tests/socketio-client.test.ts +288 -0
package/src/acp/bridge.ts CHANGED
@@ -298,6 +298,8 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
298
298
  this.acpSessionId = externalSessionId;
299
299
  this.initializing = false;
300
300
 
301
+ // 初始 mode/model 在 acp:initialized 之前应用(时序同 start:initialized → resume:result → prompt 补发)
302
+ await this.applyInitialModeModel();
301
303
  this.emit('acp:initialized', this.buildInitializedPayload(result));
302
304
  } catch (error) {
303
305
  // resumeSession may fail if agent doesn't support session.resume
@@ -311,6 +313,7 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
311
313
  this.acpSessionId = result.sessionId;
312
314
  this.initializing = false;
313
315
 
316
+ await this.applyInitialModeModel();
314
317
  this.emit('acp:initialized', this.buildInitializedPayload(result));
315
318
  } catch (fallbackError) {
316
319
  this.initializing = false;
@@ -322,8 +325,14 @@ export class AcpSessionBridge extends EventEmitter implements acp.Client {
322
325
  async stop(): Promise<void> {
323
326
  try {
324
327
  if (this.acpSessionId && this.connection) {
325
- await this.connection.cancel({ sessionId: this.acpSessionId });
326
- await this.connection.closeSession({ sessionId: this.acpSessionId });
328
+ // 5s timeout for cancel/closeSession to prevent hanging
329
+ await Promise.race([
330
+ (async () => {
331
+ await this.connection!.cancel({ sessionId: this.acpSessionId! });
332
+ await this.connection!.closeSession({ sessionId: this.acpSessionId! });
333
+ })(),
334
+ new Promise<void>((resolve) => setTimeout(resolve, 5000)),
335
+ ]);
327
336
  }
328
337
  } catch { /* ignore */ }
329
338
  this.activePrompt = null;
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
2
+ import { rmSync, existsSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ // Real-filesystem integration test: no fs mock. REGISTRY_DIR is redirected to a
6
+ // unique temp dir via the os mock so the module-level constant picks it up at
7
+ // import time. The dir itself is created lazily by the registry's ensureDir.
8
+ const TEST_HOME = vi.hoisted(() =>
9
+ `${process.env.TMPDIR ?? '/tmp'}/zag-registry-it-${process.pid}-${Math.random().toString(36).slice(2)}`);
10
+
11
+ vi.mock('os', async (importOriginal) => {
12
+ const actual = await importOriginal<typeof import('os')>();
13
+ return { ...actual, homedir: () => TEST_HOME };
14
+ });
15
+
16
+ import { ProcessRegistry } from './process-registry.js';
17
+
18
+ const REGISTRY_DIR = join(TEST_HOME, '.zag');
19
+ const REGISTRY_FILE = join(REGISTRY_DIR, 'processes.json');
20
+
21
+ describe('ProcessRegistry (real fs integration)', () => {
22
+ beforeEach(() => {
23
+ rmSync(REGISTRY_DIR, { recursive: true, force: true });
24
+ });
25
+
26
+ afterAll(() => {
27
+ rmSync(TEST_HOME, { recursive: true, force: true });
28
+ });
29
+
30
+ it('persists registered entries to disk and reloads them', () => {
31
+ const registry = new ProcessRegistry();
32
+ registry.register('s1', { pid: process.pid, cwd: '/tmp/project', acpServerType: 'opencode' });
33
+
34
+ expect(existsSync(REGISTRY_FILE)).toBe(true);
35
+ const onDisk = JSON.parse(readFileSync(REGISTRY_FILE, 'utf-8'));
36
+ expect(onDisk.version).toBe(1);
37
+ expect(onDisk.processes).toHaveLength(1);
38
+ expect(onDisk.processes[0].sessionId).toBe('s1');
39
+
40
+ const reloaded = new ProcessRegistry();
41
+ expect(reloaded.get('s1')).toBeDefined();
42
+ expect(reloaded.get('s1')!.pid).toBe(process.pid);
43
+ });
44
+
45
+ it('deregister removes the entry from disk', () => {
46
+ const registry = new ProcessRegistry();
47
+ registry.register('s1', { pid: process.pid, cwd: '/tmp', acpServerType: 'opencode' });
48
+ registry.deregister('s1');
49
+
50
+ const onDisk = JSON.parse(readFileSync(REGISTRY_FILE, 'utf-8'));
51
+ expect(onDisk.processes).toHaveLength(0);
52
+ expect(new ProcessRegistry().get('s1')).toBeUndefined();
53
+ });
54
+
55
+ it('prunes dead PIDs on load', () => {
56
+ const registry = new ProcessRegistry();
57
+ registry.register('alive', { pid: process.pid, cwd: '/tmp', acpServerType: 'opencode' });
58
+ // PID beyond common pid_max values, must not exist
59
+ registry.register('dead', { pid: 4194304 + 12345, cwd: '/tmp', acpServerType: 'opencode' });
60
+
61
+ const reloaded = new ProcessRegistry();
62
+ expect(reloaded.get('alive')).toBeDefined();
63
+ expect(reloaded.get('dead')).toBeUndefined();
64
+ });
65
+ });
@@ -0,0 +1,239 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ const REGISTRY_DIR = join(homedir(), '.zag');
6
+ const REGISTRY_FILE = join(REGISTRY_DIR, 'processes.json');
7
+
8
+ vi.mock('fs', () => ({
9
+ existsSync: vi.fn(() => false),
10
+ readFileSync: vi.fn(() => { throw new Error('ENOENT'); }),
11
+ writeFileSync: vi.fn(),
12
+ mkdirSync: vi.fn(),
13
+ renameSync: vi.fn(),
14
+ unlinkSync: vi.fn(),
15
+ }));
16
+
17
+ vi.mock('os', () => ({
18
+ homedir: vi.fn(() => '/home/test'),
19
+ tmpdir: vi.fn(() => '/tmp'),
20
+ }));
21
+
22
+ import { ProcessRegistry } from './process-registry.js';
23
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
24
+
25
+ function getMockFs() {
26
+ return {
27
+ existsSync: vi.mocked(existsSync),
28
+ readFileSync: vi.mocked(readFileSync),
29
+ writeFileSync: vi.mocked(writeFileSync),
30
+ mkdirSync: vi.mocked(mkdirSync),
31
+ renameSync: vi.mocked(renameSync),
32
+ };
33
+ }
34
+
35
+ describe('ProcessRegistry', () => {
36
+ let registry: ProcessRegistry;
37
+ let fs: ReturnType<typeof getMockFs>;
38
+ let processKillSpy: ReturnType<typeof vi.spyOn>;
39
+
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ fs = getMockFs();
43
+ processKillSpy = vi.spyOn(process, 'kill').mockReturnValue(true as any);
44
+ // Default: file doesn't exist
45
+ fs.existsSync.mockReturnValue(false);
46
+ registry = new ProcessRegistry();
47
+ });
48
+
49
+ afterEach(() => {
50
+ processKillSpy.mockRestore();
51
+ });
52
+
53
+ describe('constructor / load', () => {
54
+ it('should create empty registry when file does not exist', () => {
55
+ expect(registry.list()).toEqual([]);
56
+ });
57
+
58
+ it('should create empty registry on malformed JSON', () => {
59
+ fs.existsSync.mockReturnValue(true);
60
+ fs.readFileSync.mockReturnValue('not valid json{{{');
61
+ const reg = new ProcessRegistry();
62
+ expect(reg.list()).toEqual([]);
63
+ });
64
+
65
+ it('should create empty registry on invalid schema (missing version)', () => {
66
+ fs.existsSync.mockReturnValue(true);
67
+ fs.readFileSync.mockReturnValue(JSON.stringify({ processes: [] }));
68
+ const reg = new ProcessRegistry();
69
+ expect(reg.list()).toEqual([]);
70
+ });
71
+
72
+ it('should create empty registry on invalid schema (missing processes)', () => {
73
+ fs.existsSync.mockReturnValue(true);
74
+ fs.readFileSync.mockReturnValue(JSON.stringify({ version: 1 }));
75
+ const reg = new ProcessRegistry();
76
+ expect(reg.list()).toEqual([]);
77
+ });
78
+
79
+ it('should load alive processes from registry file', () => {
80
+ fs.existsSync.mockReturnValue(true);
81
+ fs.readFileSync.mockReturnValue(JSON.stringify({
82
+ version: 1,
83
+ processes: [
84
+ { sessionId: 's1', pid: process.pid, cwd: '/tmp', acpServerType: 'opencode', startedAt: '2026-01-01T00:00:00Z' },
85
+ ],
86
+ }));
87
+ // process.kill(pid, 0) returns true for current process
88
+ processKillSpy.mockImplementation((pid: number, _sig?: any) => {
89
+ if (_sig === 0 && pid === process.pid) return true;
90
+ throw new Error('ESRCH');
91
+ });
92
+
93
+ const reg = new ProcessRegistry();
94
+ expect(reg.list()).toHaveLength(1);
95
+ expect(reg.get('s1')).toBeDefined();
96
+ });
97
+
98
+ it('should treat EPERM (process owned by another user) as alive', () => {
99
+ fs.existsSync.mockReturnValue(true);
100
+ fs.readFileSync.mockReturnValue(JSON.stringify({
101
+ version: 1,
102
+ processes: [
103
+ { sessionId: 's1', pid: 4242, cwd: '/tmp', acpServerType: 'opencode', startedAt: '2026-01-01T00:00:00Z' },
104
+ ],
105
+ }));
106
+ processKillSpy.mockImplementation(() => {
107
+ const err = new Error('kill EPERM') as NodeJS.ErrnoException;
108
+ err.code = 'EPERM';
109
+ throw err;
110
+ });
111
+
112
+ const reg = new ProcessRegistry();
113
+ expect(reg.list()).toHaveLength(1);
114
+ expect(reg.get('s1')).toBeDefined();
115
+ });
116
+
117
+ it('should prune dead processes on load and rewrite file', () => {
118
+ fs.existsSync.mockReturnValue(true);
119
+ fs.readFileSync.mockReturnValue(JSON.stringify({
120
+ version: 1,
121
+ processes: [
122
+ { sessionId: 's1', pid: 99999, cwd: '/tmp', acpServerType: 'opencode', startedAt: '2026-01-01T00:00:00Z' },
123
+ ],
124
+ }));
125
+ // pid 99999 is dead
126
+ processKillSpy.mockImplementation(() => { throw new Error('ESRCH'); });
127
+
128
+ const reg = new ProcessRegistry();
129
+ expect(reg.list()).toHaveLength(0);
130
+ // Should have rewritten file with empty list
131
+ expect(fs.writeFileSync).toHaveBeenCalled();
132
+ });
133
+ });
134
+
135
+ describe('register', () => {
136
+ it('should register a process', () => {
137
+ registry.register('s1', { pid: 1234, cwd: '/tmp/project', acpServerType: 'opencode' });
138
+ const entry = registry.get('s1');
139
+ expect(entry).toBeDefined();
140
+ expect(entry!.pid).toBe(1234);
141
+ expect(entry!.cwd).toBe('/tmp/project');
142
+ expect(entry!.acpServerType).toBe('opencode');
143
+ });
144
+
145
+ it('should persist to file on register', () => {
146
+ registry.register('s1', { pid: 1234, cwd: '/tmp', acpServerType: 'opencode' });
147
+ expect(fs.writeFileSync).toHaveBeenCalled();
148
+ const written = JSON.parse(fs.writeFileSync.mock.calls[0][1] as string);
149
+ expect(written.version).toBe(1);
150
+ expect(written.processes).toHaveLength(1);
151
+ expect(written.processes[0].sessionId).toBe('s1');
152
+ });
153
+
154
+ it('should write the temp file inside REGISTRY_DIR (same-filesystem atomic rename)', () => {
155
+ registry.register('s1', { pid: 1234, cwd: '/tmp', acpServerType: 'opencode' });
156
+ const tmpPath = fs.writeFileSync.mock.calls[0][0] as string;
157
+ expect(tmpPath.startsWith(REGISTRY_DIR)).toBe(true);
158
+ expect(tmpPath).not.toBe(REGISTRY_FILE);
159
+ expect(fs.renameSync).toHaveBeenCalledWith(tmpPath, REGISTRY_FILE);
160
+ });
161
+
162
+ it('should not throw when flush fails (e.g. unwritable directory)', () => {
163
+ fs.writeFileSync.mockImplementation(() => { throw new Error('EROFS: read-only file system'); });
164
+ expect(() => registry.register('s1', { pid: 1234, cwd: '/tmp', acpServerType: 'opencode' })).not.toThrow();
165
+ expect(registry.get('s1')).toBeDefined();
166
+ });
167
+
168
+ it('should use provided startedAt', () => {
169
+ registry.register('s1', { pid: 1234, cwd: '/tmp', acpServerType: 'opencode', startedAt: '2026-01-01' });
170
+ expect(registry.get('s1')!.startedAt).toBe('2026-01-01');
171
+ });
172
+
173
+ it('should auto-generate startedAt if not provided', () => {
174
+ registry.register('s1', { pid: 1234, cwd: '/tmp', acpServerType: 'opencode' });
175
+ expect(registry.get('s1')!.startedAt).toBeTruthy();
176
+ });
177
+ });
178
+
179
+ describe('deregister', () => {
180
+ it('should deregister an existing entry', () => {
181
+ registry.register('s1', { pid: 1234, cwd: '/tmp', acpServerType: 'opencode' });
182
+ const result = registry.deregister('s1');
183
+ expect(result).toBe(true);
184
+ expect(registry.get('s1')).toBeUndefined();
185
+ });
186
+
187
+ it('should return false for non-existent entry', () => {
188
+ const result = registry.deregister('nonexistent');
189
+ expect(result).toBe(false);
190
+ });
191
+
192
+ it('should persist to file on deregister', () => {
193
+ registry.register('s1', { pid: 1234, cwd: '/tmp', acpServerType: 'opencode' });
194
+ vi.mocked(writeFileSync).mockClear();
195
+ registry.deregister('s1');
196
+ expect(fs.writeFileSync).toHaveBeenCalled();
197
+ });
198
+
199
+ it('should not persist if entry did not exist', () => {
200
+ vi.mocked(writeFileSync).mockClear();
201
+ registry.deregister('nonexistent');
202
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
203
+ });
204
+ });
205
+
206
+ describe('list', () => {
207
+ it('should return all entries without sessionId', () => {
208
+ registry.register('s1', { pid: 111, cwd: '/a', acpServerType: 'opencode' });
209
+ registry.register('s2', { pid: 222, cwd: '/b', acpServerType: 'claude_code' });
210
+ const list = registry.list();
211
+ expect(list).toHaveLength(2);
212
+ expect(list[0]).not.toHaveProperty('sessionId');
213
+ expect(list[1]).not.toHaveProperty('sessionId');
214
+ });
215
+ });
216
+
217
+ describe('listEntries', () => {
218
+ it('should return all entries with sessionId', () => {
219
+ registry.register('s1', { pid: 111, cwd: '/a', acpServerType: 'opencode' });
220
+ const entries = registry.listEntries();
221
+ expect(entries).toHaveLength(1);
222
+ expect(entries[0].sessionId).toBe('s1');
223
+ });
224
+ });
225
+
226
+ describe('get', () => {
227
+ it('should return entry by sessionId without sessionId field', () => {
228
+ registry.register('s1', { pid: 1234, cwd: '/tmp', acpServerType: 'opencode' });
229
+ const entry = registry.get('s1');
230
+ expect(entry).toBeDefined();
231
+ expect(entry).not.toHaveProperty('sessionId');
232
+ expect(entry!.pid).toBe(1234);
233
+ });
234
+
235
+ it('should return undefined for non-existent sessionId', () => {
236
+ expect(registry.get('nonexistent')).toBeUndefined();
237
+ });
238
+ });
239
+ });
@@ -0,0 +1,139 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ const REGISTRY_DIR = join(homedir(), '.zag');
6
+ const REGISTRY_FILE = join(REGISTRY_DIR, 'processes.json');
7
+
8
+ interface ProcessEntry {
9
+ sessionId: string;
10
+ pid: number;
11
+ cwd: string;
12
+ acpServerType: string;
13
+ startedAt: string;
14
+ }
15
+
16
+ interface RegistryFile {
17
+ version: 1;
18
+ processes: ProcessEntry[];
19
+ }
20
+
21
+ export interface ProcessInfo {
22
+ pid: number;
23
+ cwd: string;
24
+ acpServerType: string;
25
+ startedAt: string;
26
+ }
27
+
28
+ function ensureDir(): void {
29
+ if (!existsSync(REGISTRY_DIR)) {
30
+ mkdirSync(REGISTRY_DIR, { recursive: true });
31
+ }
32
+ }
33
+
34
+ export function isProcessAlive(pid: number): boolean {
35
+ try {
36
+ process.kill(pid, 0);
37
+ return true;
38
+ } catch (err) {
39
+ // EPERM: process exists but is owned by another user — alive, not dead
40
+ return (err as NodeJS.ErrnoException).code === 'EPERM';
41
+ }
42
+ }
43
+
44
+ function readRaw(): RegistryFile | null {
45
+ if (!existsSync(REGISTRY_FILE)) return null;
46
+ try {
47
+ const content = readFileSync(REGISTRY_FILE, 'utf-8');
48
+ const parsed = JSON.parse(content) as unknown;
49
+ if (!parsed || typeof parsed !== 'object') return null;
50
+ const obj = parsed as Record<string, unknown>;
51
+ if (obj.version !== 1 || !Array.isArray(obj.processes)) return null;
52
+ return { version: 1, processes: obj.processes as ProcessEntry[] };
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function writeRaw(data: RegistryFile): void {
59
+ ensureDir();
60
+ // Temp file lives inside REGISTRY_DIR: rename is only atomic on the same
61
+ // filesystem (writing to os.tmpdir() first breaks with EXDEV on Linux).
62
+ const tmpFile = join(REGISTRY_DIR, `processes-${Date.now()}-${Math.random().toString(36).slice(2)}.json.tmp`);
63
+ writeFileSync(tmpFile, JSON.stringify(data, null, 2), 'utf-8');
64
+ renameSync(tmpFile, REGISTRY_FILE);
65
+ }
66
+
67
+ export class ProcessRegistry {
68
+ private processes: Map<string, ProcessEntry> = new Map();
69
+
70
+ constructor() {
71
+ this.load();
72
+ }
73
+
74
+ private load(): void {
75
+ const raw = readRaw();
76
+ if (!raw) {
77
+ this.processes = new Map();
78
+ return;
79
+ }
80
+
81
+ const alive = new Map<string, ProcessEntry>();
82
+ for (const entry of raw.processes) {
83
+ if (isProcessAlive(entry.pid)) {
84
+ alive.set(entry.sessionId, entry);
85
+ }
86
+ }
87
+
88
+ if (alive.size !== raw.processes.length) {
89
+ writeRaw({ version: 1, processes: Array.from(alive.values()) });
90
+ }
91
+
92
+ this.processes = alive;
93
+ }
94
+
95
+ register(sessionId: string, info: Omit<ProcessInfo, 'startedAt'> & { startedAt?: string }): void {
96
+ const entry: ProcessEntry = {
97
+ sessionId,
98
+ pid: info.pid,
99
+ cwd: info.cwd,
100
+ acpServerType: info.acpServerType,
101
+ startedAt: info.startedAt ?? new Date().toISOString(),
102
+ };
103
+ this.processes.set(sessionId, entry);
104
+ this.flush();
105
+ }
106
+
107
+ deregister(sessionId: string): boolean {
108
+ const existed = this.processes.delete(sessionId);
109
+ if (existed) this.flush();
110
+ return existed;
111
+ }
112
+
113
+ list(): ProcessInfo[] {
114
+ return Array.from(this.processes.values()).map(({ sessionId: _, ...rest }) => rest);
115
+ }
116
+
117
+ listEntries(): ProcessEntry[] {
118
+ return Array.from(this.processes.values());
119
+ }
120
+
121
+ get(sessionId: string): ProcessInfo | undefined {
122
+ const entry = this.processes.get(sessionId);
123
+ if (!entry) return undefined;
124
+ const { sessionId: _, ...rest } = entry;
125
+ return rest;
126
+ }
127
+
128
+ private flush(): void {
129
+ try {
130
+ writeRaw({ version: 1, processes: Array.from(this.processes.values()) });
131
+ } catch (err) {
132
+ // Persistence is best-effort: a flush failure must never crash the
133
+ // controller (this can be called from timer callbacks).
134
+ console.warn(`[ProcessRegistry] flush failed: ${(err as Error).message}`);
135
+ }
136
+ }
137
+ }
138
+
139
+ export const processRegistry = new ProcessRegistry();
@@ -1,4 +1,4 @@
1
- import { spawn, execFileSync, type ChildProcess, type SpawnOptions } from 'child_process';
1
+ import { spawn, execFileSync, execFile, type ChildProcess, type SpawnOptions } from 'child_process';
2
2
  import { Writable, Readable } from 'node:stream';
3
3
  import { existsSync, mkdirSync } from 'fs';
4
4
  import { platform } from 'os';
@@ -37,7 +37,14 @@ export class AcpSpawner {
37
37
  stdio: ['pipe', 'pipe', 'pipe'],
38
38
  };
39
39
  if (platform() === 'win32') {
40
- spawnOptions.shell = true;
40
+ // .cmd/.bat cannot be spawned without a shell since Node 18.20.2/20.12.2
41
+ // (CVE-2024-27980, spawn throws EINVAL); real executables spawn directly
42
+ // (no shell) for reliable taskkill targeting.
43
+ if (/\.(cmd|bat)$/i.test(cmd)) {
44
+ spawnOptions.shell = true;
45
+ }
46
+ } else {
47
+ spawnOptions.detached = true;
41
48
  }
42
49
 
43
50
  this.process = spawn(cmd, this.args, spawnOptions);
@@ -71,20 +78,29 @@ export class AcpSpawner {
71
78
  return this._closed;
72
79
  }
73
80
 
81
+ get pid(): number | null {
82
+ return this.process?.pid ?? null;
83
+ }
84
+
74
85
  stop(): void {
75
86
  if (!this.process) return;
76
- if (platform() === 'win32') {
87
+ const pid = this.process.pid;
88
+ if (!pid) {
77
89
  this.process.kill();
78
- setTimeout(() => {
79
- if (this.process) {
80
- this.process.kill();
90
+ return;
91
+ }
92
+ if (platform() === 'win32') {
93
+ execFile('taskkill', ['/PID', String(pid), '/T', '/F'], (err) => {
94
+ if (err) {
95
+ try { process.kill(pid, 'SIGKILL'); } catch { /* process already gone */ }
81
96
  }
82
- }, 5000);
97
+ });
83
98
  } else {
84
- this.process.kill('SIGTERM');
99
+ process.kill(-pid, 'SIGTERM');
85
100
  setTimeout(() => {
86
- if (this.process) {
87
- this.process.kill('SIGKILL');
101
+ // Only send SIGKILL if process is still running
102
+ if (this.process && this.process.pid === pid) {
103
+ try { process.kill(-pid, 'SIGKILL'); } catch { /* process already gone */ }
88
104
  }
89
105
  }, 5000);
90
106
  }
@@ -2,9 +2,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
 
3
3
  vi.mock('fs', () => ({
4
4
  mkdirSync: vi.fn(),
5
+ rmSync: vi.fn(),
6
+ }));
7
+
8
+ vi.mock('os', () => ({
9
+ homedir: vi.fn(() => '/home/testuser'),
5
10
  }));
6
11
 
7
12
  import { FsHandler } from './fs-handler.js';
13
+ import { mkdirSync, rmSync } from 'fs';
8
14
 
9
15
  describe('FsHandler', () => {
10
16
  let handler: FsHandler;
@@ -24,7 +30,6 @@ describe('FsHandler', () => {
24
30
  });
25
31
 
26
32
  it('should handle creation failure', async () => {
27
- const { mkdirSync } = await import('fs');
28
33
  vi.mocked(mkdirSync).mockImplementation(() => {
29
34
  throw new Error('EEXIST: file already exists');
30
35
  });
@@ -34,5 +39,104 @@ describe('FsHandler', () => {
34
39
  expect(result.success).toBe(false);
35
40
  expect(result.error).toBeDefined();
36
41
  });
42
+
43
+ it('should reject system-critical paths', async () => {
44
+ const result = await handler.mkdir('/etc', 'evil');
45
+
46
+ expect(result.success).toBe(false);
47
+ expect(mkdirSync).not.toHaveBeenCalled();
48
+ });
49
+ });
50
+
51
+ describe('removeDir whitelist', () => {
52
+ it('should reject the filesystem root', async () => {
53
+ const result = await handler.removeDir('/');
54
+
55
+ expect(result.success).toBe(false);
56
+ expect(rmSync).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it('should reject the user home directory', async () => {
60
+ const result = await handler.removeDir('/home/testuser');
61
+
62
+ expect(result.success).toBe(false);
63
+ expect(rmSync).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it('should reject ancestors of the home directory', async () => {
67
+ const result = await handler.removeDir('/home');
68
+
69
+ expect(result.success).toBe(false);
70
+ expect(rmSync).not.toHaveBeenCalled();
71
+ });
72
+
73
+ it('should reject the current working directory and its ancestors', async () => {
74
+ for (const target of [process.cwd(), '/']) {
75
+ const result = await handler.removeDir(target);
76
+ expect(result.success).toBe(false);
77
+ }
78
+ expect(rmSync).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it('should reject Windows drive roots and system paths', async () => {
82
+ for (const target of ['C:\\', 'C:\\Windows', 'c:\\Users', 'D:\\']) {
83
+ const result = await handler.removeDir(target);
84
+ expect(result.success).toBe(false);
85
+ }
86
+ expect(rmSync).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it('should reject arbitrary unregistered directories under home', async () => {
90
+ const result = await handler.removeDir('/home/testuser/Documents');
91
+
92
+ expect(result.success).toBe(false);
93
+ expect(rmSync).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it('should allow a registered worktree path', async () => {
97
+ handler.registerAllowedPath('/home/testuser/workspaces/wt-1');
98
+
99
+ const result = await handler.removeDir('/home/testuser/workspaces/wt-1');
100
+
101
+ expect(result.success).toBe(true);
102
+ expect(rmSync).toHaveBeenCalledWith('/home/testuser/workspaces/wt-1', { recursive: true, force: true });
103
+ });
104
+
105
+ it('should allow subpaths of a registered worktree path', async () => {
106
+ handler.registerAllowedPath('/home/testuser/workspaces/wt-1');
107
+
108
+ const result = await handler.removeDir('/home/testuser/workspaces/wt-1/nested/dir');
109
+
110
+ expect(result.success).toBe(true);
111
+ expect(rmSync).toHaveBeenCalled();
112
+ });
113
+
114
+ it('should reject sibling paths that merely share a prefix', async () => {
115
+ handler.registerAllowedPath('/home/testuser/workspaces/wt-1');
116
+
117
+ const result = await handler.removeDir('/home/testuser/workspaces/wt-1-evil');
118
+
119
+ expect(result.success).toBe(false);
120
+ expect(rmSync).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it('should reject a path after it is unregistered', async () => {
124
+ handler.registerAllowedPath('/home/testuser/workspaces/wt-1');
125
+ handler.unregisterAllowedPath('/home/testuser/workspaces/wt-1');
126
+
127
+ const result = await handler.removeDir('/home/testuser/workspaces/wt-1');
128
+
129
+ expect(result.success).toBe(false);
130
+ expect(rmSync).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it('should refuse to register explicitly denied paths as allowed roots', async () => {
134
+ handler.registerAllowedPath('/');
135
+ handler.registerAllowedPath('/home/testuser');
136
+
137
+ expect((await handler.removeDir('/home/testuser/Documents')).success).toBe(false);
138
+ expect((await handler.removeDir('/')).success).toBe(false);
139
+ expect(rmSync).not.toHaveBeenCalled();
140
+ });
37
141
  });
38
- });
142
+ });