mstro-app 0.3.0 → 0.3.4
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 +3 -19
- package/bin/mstro.js +62 -174
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +4 -3
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/mcp-config.js +2 -2
- package/dist/server/cli/headless/mcp-config.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts +6 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +36 -4
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +1 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +2 -2
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +3 -2
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +6 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +85 -114
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/security-audit.d.ts +3 -3
- package/dist/server/mcp/security-audit.d.ts.map +1 -1
- package/dist/server/mcp/security-audit.js.map +1 -1
- package/dist/server/mcp/server.js +3 -2
- package/dist/server/mcp/server.js.map +1 -1
- package/dist/server/services/analytics.d.ts +2 -2
- package/dist/server/services/analytics.d.ts.map +1 -1
- package/dist/server/services/analytics.js.map +1 -1
- package/dist/server/services/files.js +7 -7
- package/dist/server/services/files.js.map +1 -1
- package/dist/server/services/pathUtils.js +1 -1
- package/dist/server/services/pathUtils.js.map +1 -1
- package/dist/server/services/platform.d.ts +2 -2
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/sentry.d.ts +1 -1
- package/dist/server/services/sentry.d.ts.map +1 -1
- package/dist/server/services/sentry.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +10 -0
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +32 -4
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -1
- package/dist/server/services/websocket/file-utils.d.ts +4 -0
- package/dist/server/services/websocket/file-utils.d.ts.map +1 -1
- package/dist/server/services/websocket/file-utils.js +48 -23
- package/dist/server/services/websocket/file-utils.js.map +1 -1
- package/dist/server/services/websocket/git-handlers.js +17 -17
- package/dist/server/services/websocket/git-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-pr-handlers.js +3 -3
- package/dist/server/services/websocket/git-pr-handlers.js.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.js +10 -10
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.js +1 -1
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +12 -11
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/tab-handlers.js.map +1 -1
- package/dist/server/services/websocket/terminal-handlers.js +1 -1
- package/dist/server/services/websocket/terminal-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/utils/agent-manager.d.ts +22 -2
- package/dist/server/utils/agent-manager.d.ts.map +1 -1
- package/dist/server/utils/agent-manager.js +2 -2
- package/dist/server/utils/agent-manager.js.map +1 -1
- package/dist/server/utils/paths.d.ts +0 -12
- package/dist/server/utils/paths.d.ts.map +1 -1
- package/dist/server/utils/paths.js +0 -12
- package/dist/server/utils/paths.js.map +1 -1
- package/dist/server/utils/port-manager.js.map +1 -1
- package/package.json +4 -3
- package/server/README.md +0 -1
- package/server/cli/headless/claude-invoker.ts +21 -16
- package/server/cli/headless/mcp-config.ts +8 -8
- package/server/cli/headless/runner.ts +32 -4
- package/server/cli/headless/types.ts +1 -1
- package/server/cli/improvisation-session-manager.ts +8 -7
- package/server/index.ts +15 -9
- package/server/mcp/README.md +0 -5
- package/server/mcp/bouncer-integration.ts +116 -188
- package/server/mcp/security-audit.ts +4 -4
- package/server/mcp/server.ts +6 -5
- package/server/services/analytics.ts +3 -3
- package/server/services/files.ts +13 -13
- package/server/services/pathUtils.ts +2 -2
- package/server/services/platform.ts +5 -5
- package/server/services/sentry.ts +1 -1
- package/server/services/terminal/pty-manager.ts +36 -9
- package/server/services/websocket/file-explorer-handlers.ts +1 -1
- package/server/services/websocket/file-utils.ts +52 -28
- package/server/services/websocket/git-handlers.ts +34 -34
- package/server/services/websocket/git-pr-handlers.ts +6 -6
- package/server/services/websocket/git-worktree-handlers.ts +20 -20
- package/server/services/websocket/handler.ts +2 -2
- package/server/services/websocket/session-handlers.ts +31 -30
- package/server/services/websocket/tab-handlers.ts +1 -1
- package/server/services/websocket/terminal-handlers.ts +2 -2
- package/server/services/websocket/types.ts +2 -0
- package/server/utils/agent-manager.ts +6 -6
- package/server/utils/paths.ts +0 -14
- package/server/utils/port-manager.ts +1 -1
- package/bin/configure-claude.js +0 -298
- package/dist/server/mcp/bouncer-cli.d.ts +0 -3
- package/dist/server/mcp/bouncer-cli.d.ts.map +0 -1
- package/dist/server/mcp/bouncer-cli.js +0 -99
- package/dist/server/mcp/bouncer-cli.js.map +0 -1
- package/hooks/bouncer.sh +0 -145
- package/server/cli/headless/output-utils.test.ts +0 -225
- package/server/cli/headless/stall-assessor.test.ts +0 -165
- package/server/cli/headless/tool-watchdog.test.ts +0 -429
- package/server/mcp/bouncer-cli.ts +0 -127
- package/server/mcp/bouncer-integration.test.ts +0 -161
- package/server/mcp/security-patterns.test.ts +0 -258
- package/server/services/platform.test.ts +0 -1304
- package/server/services/websocket/autocomplete.test.ts +0 -194
- package/server/services/websocket/handler.test.ts +0 -20
|
@@ -1,429 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { DEFAULT_TOOL_TIMEOUT_PROFILES, ToolWatchdog } from './tool-watchdog.js';
|
|
3
|
-
|
|
4
|
-
describe('ToolWatchdog', () => {
|
|
5
|
-
beforeEach(() => {
|
|
6
|
-
vi.useFakeTimers();
|
|
7
|
-
});
|
|
8
|
-
|
|
9
|
-
afterEach(() => {
|
|
10
|
-
vi.useRealTimers();
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
// ========== getProfile ==========
|
|
14
|
-
|
|
15
|
-
describe('getProfile', () => {
|
|
16
|
-
it('returns specific profile for known tools', () => {
|
|
17
|
-
const watchdog = new ToolWatchdog();
|
|
18
|
-
const webFetch = watchdog.getProfile('WebFetch');
|
|
19
|
-
expect(webFetch.coldStartMs).toBe(180_000);
|
|
20
|
-
expect(webFetch.floorMs).toBe(120_000);
|
|
21
|
-
expect(webFetch.ceilingMs).toBe(300_000);
|
|
22
|
-
expect(webFetch.useAdaptive).toBe(true);
|
|
23
|
-
expect(webFetch.useHaikuTiebreaker).toBe(true);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('returns Task profile with long timeouts', () => {
|
|
27
|
-
const watchdog = new ToolWatchdog();
|
|
28
|
-
const task = watchdog.getProfile('Task');
|
|
29
|
-
expect(task.coldStartMs).toBe(900_000);
|
|
30
|
-
expect(task.floorMs).toBe(600_000);
|
|
31
|
-
expect(task.ceilingMs).toBe(2_700_000);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('returns default profile for unknown tools', () => {
|
|
35
|
-
const watchdog = new ToolWatchdog();
|
|
36
|
-
const unknown = watchdog.getProfile('SomeNewTool');
|
|
37
|
-
expect(unknown.coldStartMs).toBe(300_000);
|
|
38
|
-
expect(unknown.floorMs).toBe(120_000);
|
|
39
|
-
expect(unknown.ceilingMs).toBe(600_000);
|
|
40
|
-
expect(unknown.useAdaptive).toBe(false);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('merges custom profiles with defaults', () => {
|
|
44
|
-
const watchdog = new ToolWatchdog({
|
|
45
|
-
profiles: {
|
|
46
|
-
WebFetch: { coldStartMs: 60_000 },
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
const profile = watchdog.getProfile('WebFetch');
|
|
50
|
-
expect(profile.coldStartMs).toBe(60_000);
|
|
51
|
-
// Other fields should come from default WebFetch profile
|
|
52
|
-
expect(profile.floorMs).toBe(DEFAULT_TOOL_TIMEOUT_PROFILES.WebFetch.floorMs);
|
|
53
|
-
expect(profile.useAdaptive).toBe(true);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('allows custom profiles for new tool names', () => {
|
|
57
|
-
const watchdog = new ToolWatchdog({
|
|
58
|
-
profiles: {
|
|
59
|
-
CustomTool: { coldStartMs: 10_000, floorMs: 5_000, ceilingMs: 30_000 },
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
const profile = watchdog.getProfile('CustomTool');
|
|
63
|
-
expect(profile.coldStartMs).toBe(10_000);
|
|
64
|
-
expect(profile.floorMs).toBe(5_000);
|
|
65
|
-
expect(profile.ceilingMs).toBe(30_000);
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
// ========== getTimeout ==========
|
|
70
|
-
|
|
71
|
-
describe('getTimeout', () => {
|
|
72
|
-
it('returns coldStart for non-adaptive tools', () => {
|
|
73
|
-
const watchdog = new ToolWatchdog();
|
|
74
|
-
// Bash is non-adaptive
|
|
75
|
-
expect(watchdog.getTimeout('Bash')).toBe(300_000);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('returns coldStart when no samples recorded', () => {
|
|
79
|
-
const watchdog = new ToolWatchdog();
|
|
80
|
-
expect(watchdog.getTimeout('WebFetch')).toBe(180_000);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('returns adaptive timeout after recording samples', () => {
|
|
84
|
-
const watchdog = new ToolWatchdog();
|
|
85
|
-
// Record a 10s completion for WebFetch
|
|
86
|
-
watchdog.recordCompletion('WebFetch', 10_000);
|
|
87
|
-
|
|
88
|
-
const timeout = watchdog.getTimeout('WebFetch');
|
|
89
|
-
// First sample: est = 10000, dev = 5000, timeout = 10000 + 4*5000 = 30000
|
|
90
|
-
// But floor is 120000, so should be clamped to floor
|
|
91
|
-
expect(timeout).toBe(120_000);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('respects floor clamping', () => {
|
|
95
|
-
const watchdog = new ToolWatchdog();
|
|
96
|
-
// Record very fast completions
|
|
97
|
-
watchdog.recordCompletion('WebFetch', 100);
|
|
98
|
-
watchdog.recordCompletion('WebFetch', 100);
|
|
99
|
-
watchdog.recordCompletion('WebFetch', 100);
|
|
100
|
-
|
|
101
|
-
// Adaptive calculation would be very low, but floor prevents it
|
|
102
|
-
expect(watchdog.getTimeout('WebFetch')).toBe(DEFAULT_TOOL_TIMEOUT_PROFILES.WebFetch.floorMs);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('respects ceiling clamping', () => {
|
|
106
|
-
const watchdog = new ToolWatchdog();
|
|
107
|
-
// Record very slow completions
|
|
108
|
-
watchdog.recordCompletion('WebSearch', 500_000);
|
|
109
|
-
|
|
110
|
-
const timeout = watchdog.getTimeout('WebSearch');
|
|
111
|
-
// Should not exceed ceiling
|
|
112
|
-
expect(timeout).toBeLessThanOrEqual(DEFAULT_TOOL_TIMEOUT_PROFILES.WebSearch.ceilingMs);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('does not record completions for non-adaptive tools', () => {
|
|
116
|
-
const watchdog = new ToolWatchdog();
|
|
117
|
-
// Bash is non-adaptive (Read too)
|
|
118
|
-
watchdog.recordCompletion('Bash', 5_000);
|
|
119
|
-
// Should still return coldStart
|
|
120
|
-
expect(watchdog.getTimeout('Bash')).toBe(300_000);
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
// ========== recordCompletion ==========
|
|
125
|
-
|
|
126
|
-
describe('recordCompletion', () => {
|
|
127
|
-
it('initializes tracker on first sample', () => {
|
|
128
|
-
const watchdog = new ToolWatchdog();
|
|
129
|
-
watchdog.recordCompletion('WebFetch', 20_000);
|
|
130
|
-
|
|
131
|
-
// After first sample: timeout should differ from cold start if above floor
|
|
132
|
-
const timeout = watchdog.getTimeout('WebFetch');
|
|
133
|
-
// est=20000, dev=10000, adaptive=20000+4*10000=60000, floor=120000 → 120000
|
|
134
|
-
expect(timeout).toBe(120_000);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it('updates EMA on subsequent samples', () => {
|
|
138
|
-
const watchdog = new ToolWatchdog();
|
|
139
|
-
// First sample
|
|
140
|
-
watchdog.recordCompletion('Glob', 10_000);
|
|
141
|
-
const timeout1 = watchdog.getTimeout('Glob');
|
|
142
|
-
|
|
143
|
-
// Second sample - much longer
|
|
144
|
-
watchdog.recordCompletion('Glob', 50_000);
|
|
145
|
-
const timeout2 = watchdog.getTimeout('Glob');
|
|
146
|
-
|
|
147
|
-
// Timeout should increase after longer sample
|
|
148
|
-
expect(timeout2).toBeGreaterThanOrEqual(timeout1);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('converges toward actual duration over many samples', () => {
|
|
152
|
-
const watchdog = new ToolWatchdog();
|
|
153
|
-
// Record many similar samples for Glob (adaptive, floor=30000, ceiling=180000)
|
|
154
|
-
for (let i = 0; i < 20; i++) {
|
|
155
|
-
watchdog.recordCompletion('Glob', 45_000);
|
|
156
|
-
}
|
|
157
|
-
const timeout = watchdog.getTimeout('Glob');
|
|
158
|
-
// Should converge near 45000, with deviation near 0
|
|
159
|
-
// adaptive ≈ 45000 + 4*~0 ≈ 45000, but floor is 30000, so should be ~45000
|
|
160
|
-
expect(timeout).toBeGreaterThanOrEqual(30_000);
|
|
161
|
-
expect(timeout).toBeLessThanOrEqual(60_000);
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
// ========== startWatch / clearWatch ==========
|
|
166
|
-
|
|
167
|
-
describe('startWatch / clearWatch', () => {
|
|
168
|
-
it('calls timeout callback when timer expires', async () => {
|
|
169
|
-
const watchdog = new ToolWatchdog();
|
|
170
|
-
const onTimeout = vi.fn();
|
|
171
|
-
|
|
172
|
-
watchdog.startWatch('tool-1', 'WebFetch', { url: 'http://example.com' }, onTimeout);
|
|
173
|
-
|
|
174
|
-
// Advance past WebFetch cold start (180s) — async because internal handler is async
|
|
175
|
-
await vi.advanceTimersByTimeAsync(180_001);
|
|
176
|
-
|
|
177
|
-
// onTimeout should fire (no tiebreaker configured)
|
|
178
|
-
expect(onTimeout).toHaveBeenCalledOnce();
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it('does not call timeout if cleared before expiry', async () => {
|
|
182
|
-
const watchdog = new ToolWatchdog();
|
|
183
|
-
const onTimeout = vi.fn();
|
|
184
|
-
|
|
185
|
-
watchdog.startWatch('tool-1', 'WebFetch', {}, onTimeout);
|
|
186
|
-
watchdog.clearWatch('tool-1');
|
|
187
|
-
|
|
188
|
-
await vi.advanceTimersByTimeAsync(300_000);
|
|
189
|
-
expect(onTimeout).not.toHaveBeenCalled();
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it('replaces existing watch for same ID', async () => {
|
|
193
|
-
const watchdog = new ToolWatchdog();
|
|
194
|
-
const onTimeout1 = vi.fn();
|
|
195
|
-
const onTimeout2 = vi.fn();
|
|
196
|
-
|
|
197
|
-
watchdog.startWatch('tool-1', 'WebFetch', {}, onTimeout1);
|
|
198
|
-
watchdog.startWatch('tool-1', 'WebSearch', {}, onTimeout2);
|
|
199
|
-
|
|
200
|
-
// Advance past WebSearch cold start (90s)
|
|
201
|
-
await vi.advanceTimersByTimeAsync(90_001);
|
|
202
|
-
expect(onTimeout2).toHaveBeenCalledOnce();
|
|
203
|
-
expect(onTimeout1).not.toHaveBeenCalled();
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it('tracks multiple watches independently', async () => {
|
|
207
|
-
const watchdog = new ToolWatchdog();
|
|
208
|
-
const onTimeout1 = vi.fn();
|
|
209
|
-
const onTimeout2 = vi.fn();
|
|
210
|
-
|
|
211
|
-
watchdog.startWatch('tool-1', 'WebSearch', {}, onTimeout1); // 90s
|
|
212
|
-
watchdog.startWatch('tool-2', 'WebFetch', {}, onTimeout2); // 180s
|
|
213
|
-
|
|
214
|
-
await vi.advanceTimersByTimeAsync(90_001);
|
|
215
|
-
expect(onTimeout1).toHaveBeenCalledOnce();
|
|
216
|
-
expect(onTimeout2).not.toHaveBeenCalled();
|
|
217
|
-
|
|
218
|
-
await vi.advanceTimersByTimeAsync(90_000);
|
|
219
|
-
expect(onTimeout2).toHaveBeenCalledOnce();
|
|
220
|
-
});
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
// ========== clearAll ==========
|
|
224
|
-
|
|
225
|
-
describe('clearAll', () => {
|
|
226
|
-
it('clears all active watches', () => {
|
|
227
|
-
const watchdog = new ToolWatchdog();
|
|
228
|
-
const onTimeout1 = vi.fn();
|
|
229
|
-
const onTimeout2 = vi.fn();
|
|
230
|
-
|
|
231
|
-
watchdog.startWatch('tool-1', 'WebFetch', {}, onTimeout1);
|
|
232
|
-
watchdog.startWatch('tool-2', 'WebSearch', {}, onTimeout2);
|
|
233
|
-
watchdog.clearAll();
|
|
234
|
-
|
|
235
|
-
vi.advanceTimersByTime(300_000);
|
|
236
|
-
expect(onTimeout1).not.toHaveBeenCalled();
|
|
237
|
-
expect(onTimeout2).not.toHaveBeenCalled();
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it('clears active watches map', () => {
|
|
241
|
-
const watchdog = new ToolWatchdog();
|
|
242
|
-
watchdog.startWatch('tool-1', 'WebFetch', {}, vi.fn());
|
|
243
|
-
watchdog.startWatch('tool-2', 'WebSearch', {}, vi.fn());
|
|
244
|
-
|
|
245
|
-
watchdog.clearAll();
|
|
246
|
-
expect(watchdog.getActiveWatches().size).toBe(0);
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
// ========== getActiveWatch / getActiveWatches ==========
|
|
251
|
-
|
|
252
|
-
describe('getActiveWatch', () => {
|
|
253
|
-
it('returns watch for active tool', () => {
|
|
254
|
-
const watchdog = new ToolWatchdog();
|
|
255
|
-
watchdog.startWatch('tool-1', 'WebFetch', { url: 'http://test.com' }, vi.fn());
|
|
256
|
-
|
|
257
|
-
const watch = watchdog.getActiveWatch('tool-1');
|
|
258
|
-
expect(watch).toBeDefined();
|
|
259
|
-
expect(watch!.toolName).toBe('WebFetch');
|
|
260
|
-
expect(watch!.toolInput).toEqual({ url: 'http://test.com' });
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it('returns undefined for cleared watch', () => {
|
|
264
|
-
const watchdog = new ToolWatchdog();
|
|
265
|
-
watchdog.startWatch('tool-1', 'WebFetch', {}, vi.fn());
|
|
266
|
-
watchdog.clearWatch('tool-1');
|
|
267
|
-
|
|
268
|
-
expect(watchdog.getActiveWatch('tool-1')).toBeUndefined();
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it('returns undefined for unknown ID', () => {
|
|
272
|
-
const watchdog = new ToolWatchdog();
|
|
273
|
-
expect(watchdog.getActiveWatch('nonexistent')).toBeUndefined();
|
|
274
|
-
});
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
// ========== buildCheckpoint ==========
|
|
278
|
-
|
|
279
|
-
describe('buildCheckpoint', () => {
|
|
280
|
-
it('returns null when hung tool ID not found', () => {
|
|
281
|
-
const watchdog = new ToolWatchdog();
|
|
282
|
-
const checkpoint = watchdog.buildCheckpoint(
|
|
283
|
-
'test prompt', '', '', [], 'missing-id', undefined, Date.now()
|
|
284
|
-
);
|
|
285
|
-
expect(checkpoint).toBeNull();
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
it('builds checkpoint with correct tool separation', () => {
|
|
289
|
-
const watchdog = new ToolWatchdog();
|
|
290
|
-
vi.setSystemTime(new Date('2025-01-01T00:00:00Z'));
|
|
291
|
-
const processStartTime = Date.now();
|
|
292
|
-
|
|
293
|
-
watchdog.startWatch('hung-tool', 'WebFetch', { url: 'http://slow.com' }, vi.fn());
|
|
294
|
-
|
|
295
|
-
const accumulatedTools = [
|
|
296
|
-
{ toolId: 'tool-1', toolName: 'Read', toolInput: { path: 'a.ts' }, result: 'content', isError: false, duration: 100 },
|
|
297
|
-
{ toolId: 'tool-2', toolName: 'Grep', toolInput: { pattern: 'foo' }, result: undefined, isError: false },
|
|
298
|
-
{ toolId: 'hung-tool', toolName: 'WebFetch', toolInput: { url: 'http://slow.com' }, result: undefined, isError: false },
|
|
299
|
-
];
|
|
300
|
-
|
|
301
|
-
const checkpoint = watchdog.buildCheckpoint(
|
|
302
|
-
'find and fix',
|
|
303
|
-
'assistant response text',
|
|
304
|
-
'thinking about it',
|
|
305
|
-
accumulatedTools,
|
|
306
|
-
'hung-tool',
|
|
307
|
-
'session-123',
|
|
308
|
-
processStartTime,
|
|
309
|
-
);
|
|
310
|
-
|
|
311
|
-
expect(checkpoint).not.toBeNull();
|
|
312
|
-
expect(checkpoint!.originalPrompt).toBe('find and fix');
|
|
313
|
-
expect(checkpoint!.assistantText).toBe('assistant response text');
|
|
314
|
-
expect(checkpoint!.thinkingText).toBe('thinking about it');
|
|
315
|
-
expect(checkpoint!.claudeSessionId).toBe('session-123');
|
|
316
|
-
|
|
317
|
-
// Completed tools: only tool-1 (has result and is not hung)
|
|
318
|
-
expect(checkpoint!.completedTools).toHaveLength(1);
|
|
319
|
-
expect(checkpoint!.completedTools[0].toolId).toBe('tool-1');
|
|
320
|
-
|
|
321
|
-
// In-progress tools: tool-2 (no result, not hung)
|
|
322
|
-
expect(checkpoint!.inProgressTools).toHaveLength(1);
|
|
323
|
-
expect(checkpoint!.inProgressTools[0].toolId).toBe('tool-2');
|
|
324
|
-
|
|
325
|
-
// Hung tool
|
|
326
|
-
expect(checkpoint!.hungTool.toolName).toBe('WebFetch');
|
|
327
|
-
expect(checkpoint!.hungTool.toolId).toBe('hung-tool');
|
|
328
|
-
expect(checkpoint!.hungTool.url).toBe('http://slow.com');
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
it('extracts URL from tool input for WebFetch', () => {
|
|
332
|
-
const watchdog = new ToolWatchdog();
|
|
333
|
-
watchdog.startWatch('t1', 'WebFetch', { url: 'http://example.com' }, vi.fn());
|
|
334
|
-
|
|
335
|
-
const tools = [
|
|
336
|
-
{ toolId: 't1', toolName: 'WebFetch', toolInput: { url: 'http://example.com' }, result: undefined, isError: false },
|
|
337
|
-
];
|
|
338
|
-
|
|
339
|
-
const cp = watchdog.buildCheckpoint('prompt', '', '', tools, 't1', undefined, Date.now());
|
|
340
|
-
expect(cp!.hungTool.url).toBe('http://example.com');
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
it('extracts query from tool input for WebSearch', () => {
|
|
344
|
-
const watchdog = new ToolWatchdog();
|
|
345
|
-
watchdog.startWatch('t1', 'WebSearch', { query: 'test search' }, vi.fn());
|
|
346
|
-
|
|
347
|
-
const tools = [
|
|
348
|
-
{ toolId: 't1', toolName: 'WebSearch', toolInput: { query: 'test search' }, result: undefined, isError: false },
|
|
349
|
-
];
|
|
350
|
-
|
|
351
|
-
const cp = watchdog.buildCheckpoint('prompt', '', '', tools, 't1', undefined, Date.now());
|
|
352
|
-
expect(cp!.hungTool.url).toBe('test search');
|
|
353
|
-
});
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
// ========== tiebreaker integration ==========
|
|
357
|
-
|
|
358
|
-
describe('tiebreaker', () => {
|
|
359
|
-
it('extends when tiebreaker returns extend', async () => {
|
|
360
|
-
const onTiebreaker = vi.fn().mockResolvedValue({
|
|
361
|
-
action: 'extend',
|
|
362
|
-
extensionMs: 60_000,
|
|
363
|
-
reason: 'still working',
|
|
364
|
-
});
|
|
365
|
-
const watchdog = new ToolWatchdog({ onTiebreaker });
|
|
366
|
-
const onTimeout = vi.fn();
|
|
367
|
-
|
|
368
|
-
// Use a tool with useHaikuTiebreaker=true and short timeout
|
|
369
|
-
watchdog.startWatch('t1', 'WebFetch', {}, onTimeout);
|
|
370
|
-
|
|
371
|
-
// Advance to trigger timeout
|
|
372
|
-
await vi.advanceTimersByTimeAsync(180_001);
|
|
373
|
-
|
|
374
|
-
// Tiebreaker should have been called
|
|
375
|
-
expect(onTiebreaker).toHaveBeenCalledOnce();
|
|
376
|
-
// onTimeout should NOT have fired (tiebreaker extended)
|
|
377
|
-
expect(onTimeout).not.toHaveBeenCalled();
|
|
378
|
-
|
|
379
|
-
// Now advance past extension
|
|
380
|
-
await vi.advanceTimersByTimeAsync(60_001);
|
|
381
|
-
// Should fire after extension
|
|
382
|
-
expect(onTimeout).toHaveBeenCalledOnce();
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
it('kills when tiebreaker returns kill', async () => {
|
|
386
|
-
const onTiebreaker = vi.fn().mockResolvedValue({
|
|
387
|
-
action: 'kill',
|
|
388
|
-
extensionMs: 0,
|
|
389
|
-
reason: 'process is hung',
|
|
390
|
-
});
|
|
391
|
-
const watchdog = new ToolWatchdog({ onTiebreaker });
|
|
392
|
-
const onTimeout = vi.fn();
|
|
393
|
-
|
|
394
|
-
watchdog.startWatch('t1', 'WebFetch', {}, onTimeout);
|
|
395
|
-
|
|
396
|
-
await vi.advanceTimersByTimeAsync(180_001);
|
|
397
|
-
|
|
398
|
-
expect(onTiebreaker).toHaveBeenCalledOnce();
|
|
399
|
-
expect(onTimeout).toHaveBeenCalledOnce();
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
it('kills when tiebreaker throws', async () => {
|
|
403
|
-
const onTiebreaker = vi.fn().mockRejectedValue(new Error('haiku failed'));
|
|
404
|
-
const watchdog = new ToolWatchdog({ onTiebreaker });
|
|
405
|
-
const onTimeout = vi.fn();
|
|
406
|
-
|
|
407
|
-
watchdog.startWatch('t1', 'WebFetch', {}, onTimeout);
|
|
408
|
-
|
|
409
|
-
await vi.advanceTimersByTimeAsync(180_001);
|
|
410
|
-
|
|
411
|
-
expect(onTiebreaker).toHaveBeenCalledOnce();
|
|
412
|
-
expect(onTimeout).toHaveBeenCalledOnce();
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
it('does not attempt tiebreaker for tools with useHaikuTiebreaker=false', async () => {
|
|
416
|
-
const onTiebreaker = vi.fn();
|
|
417
|
-
const watchdog = new ToolWatchdog({ onTiebreaker });
|
|
418
|
-
const onTimeout = vi.fn();
|
|
419
|
-
|
|
420
|
-
// WebSearch has useHaikuTiebreaker: false
|
|
421
|
-
watchdog.startWatch('t1', 'WebSearch', {}, onTimeout);
|
|
422
|
-
|
|
423
|
-
await vi.advanceTimersByTimeAsync(90_001);
|
|
424
|
-
|
|
425
|
-
expect(onTiebreaker).not.toHaveBeenCalled();
|
|
426
|
-
expect(onTimeout).toHaveBeenCalledOnce();
|
|
427
|
-
});
|
|
428
|
-
});
|
|
429
|
-
});
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
3
|
-
// Licensed under the MIT License. See LICENSE file for details.
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Bouncer CLI - Shell-callable wrapper for Mstro security bouncer
|
|
7
|
-
*
|
|
8
|
-
* This CLI reads Claude Code hook input from stdin and returns a security decision.
|
|
9
|
-
* It's designed to be called from bouncer.sh.
|
|
10
|
-
*
|
|
11
|
-
* Input (stdin): Claude Code PreToolUse hook JSON payload
|
|
12
|
-
* Output (stdout): JSON decision { decision: "allow"|"deny", reason: string }
|
|
13
|
-
*
|
|
14
|
-
* The hook payload includes conversation context that we pass to the bouncer
|
|
15
|
-
* so it can make context-aware decisions.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { type BouncerReviewRequest, reviewOperation } from './bouncer-integration.js';
|
|
19
|
-
|
|
20
|
-
interface HookInput {
|
|
21
|
-
tool_name?: string;
|
|
22
|
-
toolName?: string;
|
|
23
|
-
input?: Record<string, any>;
|
|
24
|
-
toolInput?: Record<string, any>;
|
|
25
|
-
// Conversation context from Claude Code hooks
|
|
26
|
-
session_id?: string;
|
|
27
|
-
conversation?: {
|
|
28
|
-
messages?: Array<{
|
|
29
|
-
role: 'user' | 'assistant';
|
|
30
|
-
content: string;
|
|
31
|
-
}>;
|
|
32
|
-
last_user_message?: string;
|
|
33
|
-
};
|
|
34
|
-
// Additional context fields Claude Code may provide
|
|
35
|
-
tool_use_id?: string;
|
|
36
|
-
working_directory?: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Read all data from stdin (Node.js compatible)
|
|
41
|
-
*/
|
|
42
|
-
async function readStdin(): Promise<string> {
|
|
43
|
-
return new Promise((resolve, reject) => {
|
|
44
|
-
const chunks: Buffer[] = [];
|
|
45
|
-
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
|
46
|
-
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8').trim()));
|
|
47
|
-
process.stdin.on('error', reject);
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function buildOperationString(toolName: string, toolInput: Record<string, any>): string {
|
|
52
|
-
if (toolName === 'Bash' && toolInput.command) {
|
|
53
|
-
return `${toolName}: ${toolInput.command}`;
|
|
54
|
-
}
|
|
55
|
-
if (['Write', 'Edit', 'Read'].includes(toolName)) {
|
|
56
|
-
const filePath = toolInput.file_path || toolInput.filePath || toolInput.path;
|
|
57
|
-
return filePath ? `${toolName}: ${filePath}` : `${toolName}: ${JSON.stringify(toolInput)}`;
|
|
58
|
-
}
|
|
59
|
-
return `${toolName}: ${JSON.stringify(toolInput)}`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function extractConversationContext(hookInput: HookInput): string | undefined {
|
|
63
|
-
const lastUserMessage = hookInput.conversation?.last_user_message;
|
|
64
|
-
if (lastUserMessage) return `User's request: "${lastUserMessage}"`;
|
|
65
|
-
|
|
66
|
-
const recentMessages = hookInput.conversation?.messages?.slice(-5);
|
|
67
|
-
if (recentMessages?.length) {
|
|
68
|
-
return `Recent conversation:\n${recentMessages.map(m => `${m.role}: ${m.content}`).join('\n')}`;
|
|
69
|
-
}
|
|
70
|
-
return undefined;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function main() {
|
|
74
|
-
const inputStr = await readStdin();
|
|
75
|
-
|
|
76
|
-
if (!inputStr) {
|
|
77
|
-
console.log(JSON.stringify({ decision: 'allow', reason: 'Empty input, allowing' }));
|
|
78
|
-
process.exit(0);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
let hookInput: HookInput;
|
|
82
|
-
try {
|
|
83
|
-
hookInput = JSON.parse(inputStr);
|
|
84
|
-
} catch (e) {
|
|
85
|
-
console.error('[bouncer-cli] Failed to parse input JSON:', e);
|
|
86
|
-
console.log(JSON.stringify({ decision: 'allow', reason: 'Invalid JSON input, allowing' }));
|
|
87
|
-
process.exit(0);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const toolName = hookInput.tool_name || hookInput.toolName || 'unknown';
|
|
91
|
-
const toolInput = hookInput.input || hookInput.toolInput || {};
|
|
92
|
-
const userRequestContext = extractConversationContext(hookInput);
|
|
93
|
-
const lastUserMessage = hookInput.conversation?.last_user_message;
|
|
94
|
-
const recentMessages = hookInput.conversation?.messages?.slice(-5);
|
|
95
|
-
|
|
96
|
-
const bouncerRequest: BouncerReviewRequest = {
|
|
97
|
-
operation: buildOperationString(toolName, toolInput),
|
|
98
|
-
context: {
|
|
99
|
-
purpose: userRequestContext || 'Tool use request from Claude',
|
|
100
|
-
workingDirectory: hookInput.working_directory || process.cwd(),
|
|
101
|
-
toolName,
|
|
102
|
-
toolInput,
|
|
103
|
-
userRequest: lastUserMessage,
|
|
104
|
-
conversationHistory: recentMessages?.map(m => `${m.role}: ${m.content}`),
|
|
105
|
-
sessionId: hookInput.session_id,
|
|
106
|
-
},
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
try {
|
|
110
|
-
const decision = await reviewOperation(bouncerRequest);
|
|
111
|
-
console.log(JSON.stringify({
|
|
112
|
-
decision: decision.decision === 'deny' ? 'deny' : 'allow',
|
|
113
|
-
reason: decision.reasoning,
|
|
114
|
-
confidence: decision.confidence,
|
|
115
|
-
threatLevel: decision.threatLevel,
|
|
116
|
-
alternative: decision.alternative,
|
|
117
|
-
}));
|
|
118
|
-
} catch (error: any) {
|
|
119
|
-
console.error('[bouncer-cli] Error:', error.message);
|
|
120
|
-
console.log(JSON.stringify({
|
|
121
|
-
decision: 'allow',
|
|
122
|
-
reason: `Bouncer error: ${error.message}. Allowing to avoid blocking.`
|
|
123
|
-
}));
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
main();
|