troxy-cli 1.29.23 → 1.29.24
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/bin/troxy.js +7 -0
- package/package.json +1 -1
- package/src/init.js +63 -20
- package/src/interceptor.js +29 -1
- package/src/mcp-server.js +27 -0
- package/src/sandbox/compress.js +52 -0
- package/src/sandbox/posttooluse-hook.js +119 -0
- package/src/sandbox/store.js +90 -0
- package/src/tests/claude-code-hook.test.js +65 -67
- package/src/tests/e2e-sandbox-chain.test.js +198 -0
- package/src/tests/init-sandbox-hook.test.js +173 -0
- package/src/tests/interceptor.test.js +59 -0
- package/src/tests/mcp-recall.test.js +22 -0
- package/src/tests/model-checkpoint.test.js +6 -5
- package/src/tests/posttooluse-hook.test.js +199 -0
- package/src/tests/proxy-wiring.test.js +6 -4
- package/src/tests/sandbox-compress.test.js +85 -0
- package/src/tests/sandbox-store.test.js +86 -0
- package/src/uninstall.js +2 -2
|
@@ -120,63 +120,73 @@ describe('unpatchClaudeCodeConfig', () => {
|
|
|
120
120
|
describe('patchClaudeCodeHooks', () => {
|
|
121
121
|
const configPath = () => path.join(dir, 'settings.json');
|
|
122
122
|
const read = () => JSON.parse(fs.readFileSync(configPath(), 'utf8'));
|
|
123
|
+
const writeConfig = (cfg) => {
|
|
124
|
+
fs.mkdirSync(path.dirname(configPath()), { recursive: true });
|
|
125
|
+
fs.writeFileSync(configPath(), JSON.stringify(cfg, null, 2) + '\n');
|
|
126
|
+
};
|
|
123
127
|
|
|
124
128
|
it('creates the Stop hook entry when the file does not exist', () => {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
assert.equal(
|
|
129
|
+
let cfg = {};
|
|
130
|
+
cfg = patchClaudeCodeHooks(cfg, {}, '/usr/local/bin/troxy hook-report');
|
|
131
|
+
writeConfig(cfg);
|
|
132
|
+
const result = read();
|
|
133
|
+
assert.equal(result.hooks.Stop.length, 1);
|
|
134
|
+
assert.equal(result.hooks.Stop[0].hooks[0].command, '/usr/local/bin/troxy hook-report');
|
|
135
|
+
assert.equal(result.hooks.Stop[0].hooks[0].type, 'command');
|
|
130
136
|
});
|
|
131
137
|
|
|
132
138
|
it('is idempotent: running twice produces one Troxy entry, not two', () => {
|
|
133
|
-
|
|
134
|
-
patchClaudeCodeHooks(
|
|
135
|
-
|
|
136
|
-
|
|
139
|
+
let cfg = {};
|
|
140
|
+
cfg = patchClaudeCodeHooks(cfg, {}, 'troxy hook-report');
|
|
141
|
+
cfg = patchClaudeCodeHooks(cfg, {}, 'troxy hook-report');
|
|
142
|
+
writeConfig(cfg);
|
|
143
|
+
const result = read();
|
|
144
|
+
assert.equal(result.hooks.Stop.length, 1);
|
|
137
145
|
});
|
|
138
146
|
|
|
139
147
|
it('updates the command in place on a re-run rather than appending', () => {
|
|
140
|
-
|
|
141
|
-
patchClaudeCodeHooks(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
let cfg = {};
|
|
149
|
+
cfg = patchClaudeCodeHooks(cfg, {}, 'npx troxy-cli hook-report');
|
|
150
|
+
cfg = patchClaudeCodeHooks(cfg, {}, '/usr/local/bin/troxy hook-report');
|
|
151
|
+
writeConfig(cfg);
|
|
152
|
+
const result = read();
|
|
153
|
+
assert.equal(result.hooks.Stop.length, 1);
|
|
154
|
+
assert.equal(result.hooks.Stop[0].hooks[0].command, '/usr/local/bin/troxy hook-report');
|
|
145
155
|
});
|
|
146
156
|
|
|
147
157
|
it("preserves the user's own unrelated Stop hooks in the same file", () => {
|
|
148
|
-
|
|
158
|
+
let cfg = {
|
|
149
159
|
hooks: { Stop: [{ hooks: [{ type: 'command', command: 'my-own-script.sh' }] }] },
|
|
150
|
-
}
|
|
151
|
-
patchClaudeCodeHooks(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
assert.
|
|
155
|
-
assert.ok(
|
|
160
|
+
};
|
|
161
|
+
cfg = patchClaudeCodeHooks(cfg, {}, 'troxy hook-report');
|
|
162
|
+
writeConfig(cfg);
|
|
163
|
+
const result = read();
|
|
164
|
+
assert.equal(result.hooks.Stop.length, 2);
|
|
165
|
+
assert.ok(result.hooks.Stop.some(e => e.hooks[0].command === 'my-own-script.sh'));
|
|
166
|
+
assert.ok(result.hooks.Stop.some(e => e.hooks[0].command === 'troxy hook-report'));
|
|
156
167
|
});
|
|
157
168
|
|
|
158
169
|
it("preserves the user's own other hook events (PreToolUse etc.) untouched", () => {
|
|
159
|
-
|
|
170
|
+
let cfg = {
|
|
160
171
|
hooks: { PreToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'validate.sh' }] }] },
|
|
161
|
-
}
|
|
162
|
-
patchClaudeCodeHooks(
|
|
163
|
-
|
|
164
|
-
|
|
172
|
+
};
|
|
173
|
+
cfg = patchClaudeCodeHooks(cfg, {});
|
|
174
|
+
writeConfig(cfg);
|
|
175
|
+
const result = read();
|
|
176
|
+
assert.ok(result.hooks.PreToolUse, 'an unrelated hook event was dropped by the patch');
|
|
165
177
|
});
|
|
166
178
|
|
|
167
179
|
it('does not throw when the existing file is corrupted JSON', () => {
|
|
168
|
-
|
|
169
|
-
|
|
180
|
+
// Test: patchClaudeCodeHooks should handle corrupted config gracefully
|
|
181
|
+
// Pure function doesn't touch files, but we test it doesn't crash on any input
|
|
182
|
+
let cfg = {}; // In real usage, caller would catch JSON parse errors
|
|
183
|
+
assert.doesNotThrow(() => patchClaudeCodeHooks(cfg, {}));
|
|
170
184
|
});
|
|
171
185
|
});
|
|
172
186
|
|
|
173
187
|
describe('patchClaudeCodeHooks: PreToolUse gate', () => {
|
|
174
|
-
const configPath = () => path.join(dir, 'settings.json');
|
|
175
|
-
const read = () => JSON.parse(fs.readFileSync(configPath(), 'utf8'));
|
|
176
|
-
|
|
177
188
|
it('creates the PreToolUse entry alongside Stop, matching every tool', () => {
|
|
178
|
-
patchClaudeCodeHooks(
|
|
179
|
-
const cfg = read();
|
|
189
|
+
const cfg = patchClaudeCodeHooks({}, {}, 'troxy hook-report', '/usr/local/bin/troxy pretooluse-hook');
|
|
180
190
|
assert.equal(cfg.hooks.PreToolUse.length, 1);
|
|
181
191
|
assert.equal(cfg.hooks.PreToolUse[0].hooks[0].command, '/usr/local/bin/troxy pretooluse-hook');
|
|
182
192
|
// '*' (not the regex '.*') is Claude Code's own documented syntax for
|
|
@@ -185,41 +195,36 @@ describe('patchClaudeCodeHooks: PreToolUse gate', () => {
|
|
|
185
195
|
});
|
|
186
196
|
|
|
187
197
|
it('sets a short per-hook timeout as a second, independent fail-open guarantee', () => {
|
|
188
|
-
patchClaudeCodeHooks(
|
|
189
|
-
const cfg = read();
|
|
198
|
+
const cfg = patchClaudeCodeHooks({}, {}, 'troxy hook-report', 'troxy pretooluse-hook');
|
|
190
199
|
assert.ok(cfg.hooks.PreToolUse[0].hooks[0].timeout > 0);
|
|
191
200
|
assert.ok(cfg.hooks.PreToolUse[0].hooks[0].timeout <= 10, 'timeout should be short, not Claude Code\'s 600s default');
|
|
192
201
|
});
|
|
193
202
|
|
|
194
203
|
it('is idempotent: running twice produces one Troxy PreToolUse entry, not two', () => {
|
|
195
|
-
patchClaudeCodeHooks(
|
|
196
|
-
patchClaudeCodeHooks(
|
|
197
|
-
const cfg = read();
|
|
204
|
+
let cfg = patchClaudeCodeHooks({}, {}, 'troxy hook-report', 'troxy pretooluse-hook');
|
|
205
|
+
cfg = patchClaudeCodeHooks(cfg, {}, 'troxy hook-report', 'troxy pretooluse-hook');
|
|
198
206
|
assert.equal(cfg.hooks.PreToolUse.length, 1);
|
|
199
207
|
});
|
|
200
208
|
|
|
201
209
|
it('updates the PreToolUse command in place on a re-run rather than appending', () => {
|
|
202
|
-
patchClaudeCodeHooks(
|
|
203
|
-
patchClaudeCodeHooks(
|
|
204
|
-
const cfg = read();
|
|
210
|
+
let cfg = patchClaudeCodeHooks({}, {}, 'troxy hook-report', 'npx troxy-cli pretooluse-hook');
|
|
211
|
+
cfg = patchClaudeCodeHooks(cfg, {}, 'troxy hook-report', '/usr/local/bin/troxy pretooluse-hook');
|
|
205
212
|
assert.equal(cfg.hooks.PreToolUse.length, 1);
|
|
206
213
|
assert.equal(cfg.hooks.PreToolUse[0].hooks[0].command, '/usr/local/bin/troxy pretooluse-hook');
|
|
207
214
|
});
|
|
208
215
|
|
|
209
216
|
it("preserves the user's own unrelated PreToolUse hooks in the same file", () => {
|
|
210
|
-
|
|
217
|
+
const initial = {
|
|
211
218
|
hooks: { PreToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'validate.sh' }] }] },
|
|
212
|
-
}
|
|
213
|
-
patchClaudeCodeHooks(
|
|
214
|
-
const cfg = read();
|
|
219
|
+
};
|
|
220
|
+
const cfg = patchClaudeCodeHooks(initial, {}, 'troxy hook-report', 'troxy pretooluse-hook');
|
|
215
221
|
assert.equal(cfg.hooks.PreToolUse.length, 2);
|
|
216
222
|
assert.ok(cfg.hooks.PreToolUse.some(e => e.hooks[0].command === 'validate.sh'));
|
|
217
223
|
assert.ok(cfg.hooks.PreToolUse.some(e => e.hooks[0].command === 'troxy pretooluse-hook'));
|
|
218
224
|
});
|
|
219
225
|
|
|
220
226
|
it('registering PreToolUse never touches the Stop hook entry', () => {
|
|
221
|
-
patchClaudeCodeHooks(
|
|
222
|
-
const cfg = read();
|
|
227
|
+
const cfg = patchClaudeCodeHooks({}, {}, 'troxy hook-report', 'troxy pretooluse-hook');
|
|
223
228
|
assert.equal(cfg.hooks.Stop.length, 1);
|
|
224
229
|
assert.equal(cfg.hooks.Stop[0].hooks[0].command, 'troxy hook-report');
|
|
225
230
|
});
|
|
@@ -230,46 +235,39 @@ describe('patchClaudeCodeHooks: PreToolUse gate', () => {
|
|
|
230
235
|
// to update in place - by command containing 'hook-report'/'pretooluse-hook'),
|
|
231
236
|
// leaving any of the user's own unrelated hooks in the same file untouched.
|
|
232
237
|
describe('unpatchClaudeCodeHooks', () => {
|
|
233
|
-
const configPath = () => path.join(dir, 'settings.json');
|
|
234
|
-
const read = () => JSON.parse(fs.readFileSync(configPath(), 'utf8'));
|
|
235
|
-
|
|
236
238
|
it('removes the Troxy Stop and PreToolUse entries', () => {
|
|
237
|
-
patchClaudeCodeHooks(
|
|
238
|
-
unpatchClaudeCodeHooks(
|
|
239
|
-
const cfg = read();
|
|
239
|
+
let cfg = patchClaudeCodeHooks({}, {}, 'troxy hook-report', 'troxy pretooluse-hook');
|
|
240
|
+
cfg = unpatchClaudeCodeHooks(cfg);
|
|
240
241
|
assert.equal(cfg.hooks.Stop.length, 0);
|
|
241
242
|
assert.equal(cfg.hooks.PreToolUse.length, 0);
|
|
242
243
|
});
|
|
243
244
|
|
|
244
245
|
it("preserves the user's own unrelated Stop and PreToolUse hooks in the same file", () => {
|
|
245
|
-
|
|
246
|
+
const initial = {
|
|
246
247
|
hooks: {
|
|
247
248
|
Stop: [{ hooks: [{ type: 'command', command: 'my-own-script.sh' }] }],
|
|
248
249
|
PreToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'validate.sh' }] }],
|
|
249
250
|
},
|
|
250
|
-
}
|
|
251
|
-
patchClaudeCodeHooks(
|
|
252
|
-
unpatchClaudeCodeHooks(
|
|
253
|
-
const cfg = read();
|
|
251
|
+
};
|
|
252
|
+
let cfg = patchClaudeCodeHooks(initial, {}, 'troxy hook-report', 'troxy pretooluse-hook');
|
|
253
|
+
cfg = unpatchClaudeCodeHooks(cfg);
|
|
254
254
|
assert.equal(cfg.hooks.Stop.length, 1);
|
|
255
255
|
assert.equal(cfg.hooks.Stop[0].hooks[0].command, 'my-own-script.sh');
|
|
256
256
|
assert.equal(cfg.hooks.PreToolUse.length, 1);
|
|
257
257
|
assert.equal(cfg.hooks.PreToolUse[0].hooks[0].command, 'validate.sh');
|
|
258
258
|
});
|
|
259
259
|
|
|
260
|
-
it('does not throw
|
|
261
|
-
assert.doesNotThrow(() => unpatchClaudeCodeHooks(
|
|
262
|
-
assert.equal(fs.existsSync(configPath()), false);
|
|
260
|
+
it('does not throw when called with an empty config object', () => {
|
|
261
|
+
assert.doesNotThrow(() => unpatchClaudeCodeHooks({}));
|
|
263
262
|
});
|
|
264
263
|
|
|
265
|
-
it('does not throw when
|
|
266
|
-
|
|
267
|
-
assert.doesNotThrow(() => unpatchClaudeCodeHooks(configPath()));
|
|
264
|
+
it('does not throw when called with a config missing hooks', () => {
|
|
265
|
+
assert.doesNotThrow(() => unpatchClaudeCodeHooks({ other: 'data' }));
|
|
268
266
|
});
|
|
269
267
|
|
|
270
268
|
it('is idempotent - calling twice is safe', () => {
|
|
271
|
-
patchClaudeCodeHooks(
|
|
272
|
-
unpatchClaudeCodeHooks(
|
|
273
|
-
assert.doesNotThrow(() => unpatchClaudeCodeHooks(
|
|
269
|
+
let cfg = patchClaudeCodeHooks({}, {}, 'troxy hook-report', 'troxy pretooluse-hook');
|
|
270
|
+
cfg = unpatchClaudeCodeHooks(cfg);
|
|
271
|
+
assert.doesNotThrow(() => unpatchClaudeCodeHooks(cfg));
|
|
274
272
|
});
|
|
275
273
|
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { strict as assert } from 'assert';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { handleRecall } from '../mcp-server.js';
|
|
9
|
+
|
|
10
|
+
// Task 10 (final task of the tool-output-sandbox plan): one real, scripted,
|
|
11
|
+
// end-to-end run of the whole chain - no mocks, no in-process shortcuts for
|
|
12
|
+
// the parts that matter - proving all 9 prior tasks actually work together:
|
|
13
|
+
//
|
|
14
|
+
// 1. real `troxy init` (reprovisionKeyConsumers) writes a real
|
|
15
|
+
// ~/.claude/settings.json with a real PostToolUse hook command.
|
|
16
|
+
// 2. that exact installed command, run as a real subprocess against a
|
|
17
|
+
// real >2000-char tool output, returns a compressed response with a
|
|
18
|
+
// real recall handle.
|
|
19
|
+
// 3. handleRecall() (the real troxy_recall tool logic) returns the real,
|
|
20
|
+
// byte-for-byte original content for that handle.
|
|
21
|
+
// 4. with the store directory made unwritable, the same real subprocess
|
|
22
|
+
// fails open: empty stdout, i.e. Claude Code keeps the real output.
|
|
23
|
+
//
|
|
24
|
+
// This mirrors src/tests/init-sandbox-hook.test.js's subprocess-isolation
|
|
25
|
+
// pattern (mutating process.env.HOME in-process would leak across tests,
|
|
26
|
+
// and reprovisionKeyConsumers touches real launchd state unless
|
|
27
|
+
// TROXY_TEST_SKIP_SERVICE_INSTALL=1 short-circuits it before installService
|
|
28
|
+
// is ever reached).
|
|
29
|
+
|
|
30
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const repoRoot = path.join(__dirname, '..', '..');
|
|
32
|
+
const initJsPath = path.join(repoRoot, 'src', 'init.js');
|
|
33
|
+
const binTroxyPath = path.join(repoRoot, 'bin', 'troxy.js');
|
|
34
|
+
|
|
35
|
+
function buildBigOutput(label) {
|
|
36
|
+
const lines = [];
|
|
37
|
+
for (let i = 0; i < 100; i++) {
|
|
38
|
+
lines.push(`[${label}] line ${i}: some representative tool output padding text to reach length`);
|
|
39
|
+
}
|
|
40
|
+
return lines.join('\n');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Real Bash tool_response shape, confirmed 2026-10-05 against this
|
|
44
|
+
// machine's own ~/.claude/projects/*/*.jsonl transcripts - a prior version
|
|
45
|
+
// of this test used an invented `{ tool_response: { output } }` shape that
|
|
46
|
+
// no real tool result ever has, which is exactly how this e2e test passed
|
|
47
|
+
// while the feature was a complete no-op in production.
|
|
48
|
+
function bashResult(stdout) {
|
|
49
|
+
return { interrupted: false, isImage: false, noOutputExpected: false, stderr: '', stdout };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
test('end-to-end: real init -> real hook subprocess -> real recall, and real fail-open', async () => {
|
|
53
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-e2e-home-'));
|
|
54
|
+
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-e2e-shim-'));
|
|
55
|
+
try {
|
|
56
|
+
// --- Step 1: force Claude Desktop detection, exactly as
|
|
57
|
+
// init-sandbox-hook.test.js does, so reprovisionKeyConsumers takes the
|
|
58
|
+
// "patch Claude Code hooks" branch.
|
|
59
|
+
fs.mkdirSync(path.join(tmpHome, '.config', 'Claude'), { recursive: true });
|
|
60
|
+
const desktopConfigPath =
|
|
61
|
+
process.platform === 'darwin'
|
|
62
|
+
? path.join(tmpHome, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
|
|
63
|
+
: path.join(tmpHome, '.config', 'claude', 'claude_desktop_config.json');
|
|
64
|
+
fs.mkdirSync(path.dirname(desktopConfigPath), { recursive: true });
|
|
65
|
+
fs.writeFileSync(desktopConfigPath, '{}');
|
|
66
|
+
|
|
67
|
+
// A `troxy` shim on PATH so troxyPostToolUseCommand()'s `which troxy`
|
|
68
|
+
// resolves to a real, directly-runnable command instead of falling back
|
|
69
|
+
// to `npx troxy-cli` (which would try to hit the network here). The
|
|
70
|
+
// shim just execs the real bin/troxy.js under the real node binary.
|
|
71
|
+
const shimPath = path.join(shimDir, 'troxy');
|
|
72
|
+
fs.writeFileSync(
|
|
73
|
+
shimPath,
|
|
74
|
+
`#!/bin/sh\nexec "${process.execPath}" "${binTroxyPath}" "$@"\n`,
|
|
75
|
+
);
|
|
76
|
+
fs.chmodSync(shimPath, 0o755);
|
|
77
|
+
|
|
78
|
+
const script = `
|
|
79
|
+
import { reprovisionKeyConsumers } from ${JSON.stringify('file://' + initJsPath)};
|
|
80
|
+
await reprovisionKeyConsumers('test-api-key-ci', 'claude-code', false, false);
|
|
81
|
+
`;
|
|
82
|
+
const scriptPath = path.join(tmpHome, 'run.mjs');
|
|
83
|
+
fs.writeFileSync(scriptPath, script);
|
|
84
|
+
|
|
85
|
+
execFileSync(process.execPath, [scriptPath], {
|
|
86
|
+
env: {
|
|
87
|
+
...process.env,
|
|
88
|
+
HOME: tmpHome,
|
|
89
|
+
PATH: `${shimDir}:/usr/bin:/bin`,
|
|
90
|
+
TROXY_TEST_SKIP_SERVICE_INSTALL: '1',
|
|
91
|
+
},
|
|
92
|
+
encoding: 'utf8',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const settingsPath = path.join(tmpHome, '.claude', 'settings.json');
|
|
96
|
+
assert.ok(fs.existsSync(settingsPath), 'expected real init to write Claude Code settings.json');
|
|
97
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
98
|
+
const postEntries = settings.hooks?.PostToolUse;
|
|
99
|
+
assert.ok(Array.isArray(postEntries) && postEntries.length === 1, 'expected one real PostToolUse sandbox hook entry');
|
|
100
|
+
const hookCommand = postEntries[0].hooks[0].command;
|
|
101
|
+
assert.equal(hookCommand, `${shimPath} posttooluse-hook`, 'expected the installed command to be the real, directly-runnable shim');
|
|
102
|
+
|
|
103
|
+
// --- Step 2: feed a real >2000-char tool output through the real
|
|
104
|
+
// installed hook command, as a real subprocess, exactly as Claude Code
|
|
105
|
+
// would invoke it (`sh -c "<installed command>"` with the hook JSON on
|
|
106
|
+
// stdin). Default sandboxFraction=1 / holdoutFraction=0.2 with real
|
|
107
|
+
// Math.random means ~20% of runs land in the holdout bucket (by design,
|
|
108
|
+
// for measurement) and pass the output through unchanged - retry a
|
|
109
|
+
// bounded number of times rather than faking determinism.
|
|
110
|
+
const bigOutput = buildBigOutput('treated');
|
|
111
|
+
assert.ok(bigOutput.length > 2000, 'fixture output must exceed the sandbox threshold');
|
|
112
|
+
const hookInput = JSON.stringify({ tool_response: bashResult(bigOutput) });
|
|
113
|
+
const hookEnv = {
|
|
114
|
+
...process.env,
|
|
115
|
+
HOME: tmpHome,
|
|
116
|
+
PATH: '/usr/bin:/bin',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
let stdout = '';
|
|
120
|
+
let attempts = 0;
|
|
121
|
+
const MAX_ATTEMPTS = 20;
|
|
122
|
+
while (attempts < MAX_ATTEMPTS && stdout.trim() === '') {
|
|
123
|
+
attempts++;
|
|
124
|
+
stdout = execFileSync('sh', ['-c', hookCommand], {
|
|
125
|
+
input: hookInput,
|
|
126
|
+
env: hookEnv,
|
|
127
|
+
encoding: 'utf8',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
console.log(`[e2e] hook subprocess produced a compressed response after ${attempts} attempt(s) (holdout draws retried)`);
|
|
131
|
+
assert.ok(stdout.trim() !== '', `expected a compressed response within ${MAX_ATTEMPTS} attempts (holdout is probabilistic, not broken)`);
|
|
132
|
+
|
|
133
|
+
const response = JSON.parse(stdout);
|
|
134
|
+
const updatedToolOutput = response?.hookSpecificOutput?.updatedToolOutput;
|
|
135
|
+
// Claude Code validates updatedToolOutput against the tool's own output
|
|
136
|
+
// schema and reverts to the original output (plus a visible hook-error
|
|
137
|
+
// message) if the shape doesn't match - so this must be the full real
|
|
138
|
+
// tool_response shape (stdout/stderr/interrupted/...), not a bare string.
|
|
139
|
+
assert.equal(typeof updatedToolOutput, 'object');
|
|
140
|
+
assert.equal(updatedToolOutput.stderr, ''); // untouched sibling field survived the round-trip
|
|
141
|
+
assert.ok(updatedToolOutput.stdout.length < bigOutput.length, 'expected the hook response to be compressed vs. the original output');
|
|
142
|
+
const handleMatch = updatedToolOutput.stdout.match(/troxy_recall\("([a-f0-9]{16})"\)/);
|
|
143
|
+
assert.ok(handleMatch, `expected a real recall handle in: ${updatedToolOutput.stdout}`);
|
|
144
|
+
const handle = handleMatch[1];
|
|
145
|
+
console.log(`[e2e] compressed response:\n${updatedToolOutput.stdout}`);
|
|
146
|
+
|
|
147
|
+
// --- Step 3: call the real troxy_recall tool logic with that handle and
|
|
148
|
+
// confirm it returns the real, original, byte-for-byte content. The
|
|
149
|
+
// subprocess above resolved its store dir from HOME=tmpHome via the
|
|
150
|
+
// real storeDir() default (os.homedir()/.troxy/sandbox).
|
|
151
|
+
const storeDirPath = path.join(tmpHome, '.troxy', 'sandbox');
|
|
152
|
+
const result = await handleRecall({ handle }, storeDirPath);
|
|
153
|
+
assert.equal(result.isError, false);
|
|
154
|
+
assert.equal(result.content[0].text, bigOutput, 'expected troxy_recall to return the exact original output');
|
|
155
|
+
console.log(`[e2e] troxy_recall("${handle}") returned the original ${bigOutput.length}-char output, byte-for-byte`);
|
|
156
|
+
|
|
157
|
+
// --- Step 4: fail-open. Make the store directory unwritable and
|
|
158
|
+
// confirm a large output still passes through unmodified (empty
|
|
159
|
+
// stdout - Claude Code keeps the real tool output as-is).
|
|
160
|
+
//
|
|
161
|
+
// Empty stdout alone doesn't prove fail-open: the same ~20% holdout
|
|
162
|
+
// draw from Step 2 also produces empty stdout, regardless of whether
|
|
163
|
+
// the store is writable. writeState() distinguishes the two - it
|
|
164
|
+
// overwrites the existing last-state.json file's content, which still
|
|
165
|
+
// succeeds under chmod 0o555 (only directory *entries* are protected,
|
|
166
|
+
// not writes to an already-existing file) - so last-state.json's
|
|
167
|
+
// `state` field reports which branch actually ran: 'holdout' means
|
|
168
|
+
// this draw didn't even attempt a write, 'skipped' means the write was
|
|
169
|
+
// attempted and failed, fail-open caught it. Retry while we keep
|
|
170
|
+
// landing on holdout draws, same bounded-retry spirit as Step 2.
|
|
171
|
+
const lastStatePath = path.join(storeDirPath, 'last-state.json');
|
|
172
|
+
fs.chmodSync(storeDirPath, 0o555);
|
|
173
|
+
try {
|
|
174
|
+
let state = 'holdout'; // force the loop to run at least once
|
|
175
|
+
let failOpenAttempts = 0;
|
|
176
|
+
let secondStdout = '';
|
|
177
|
+
while (failOpenAttempts < MAX_ATTEMPTS && state === 'holdout') {
|
|
178
|
+
failOpenAttempts++;
|
|
179
|
+
const secondOutput = buildBigOutput('failopen');
|
|
180
|
+
secondStdout = execFileSync('sh', ['-c', hookCommand], {
|
|
181
|
+
input: JSON.stringify({ tool_response: bashResult(secondOutput) }),
|
|
182
|
+
env: hookEnv,
|
|
183
|
+
encoding: 'utf8',
|
|
184
|
+
});
|
|
185
|
+
state = JSON.parse(fs.readFileSync(lastStatePath, 'utf8')).state;
|
|
186
|
+
}
|
|
187
|
+
console.log(`[e2e] fail-open subprocess landed on state="${state}" after ${failOpenAttempts} attempt(s) (holdout draws retried)`);
|
|
188
|
+
assert.equal(state, 'skipped', `expected a real write-failure (state="skipped"), not a coincidental holdout draw, within ${MAX_ATTEMPTS} attempts`);
|
|
189
|
+
assert.equal(secondStdout, '', 'expected empty stdout (fail-open: real output passed through unmodified) when the store dir is unwritable');
|
|
190
|
+
console.log('[e2e] fail-open confirmed: unwritable store dir -> write failed -> state="skipped" -> empty stdout -> real tool output left untouched');
|
|
191
|
+
} finally {
|
|
192
|
+
fs.chmodSync(storeDirPath, 0o755);
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
196
|
+
fs.rmSync(shimDir, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { strict as assert } from 'assert';
|
|
2
|
+
import { test, describe } from 'node:test';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import {
|
|
9
|
+
patchClaudeCodeHooks,
|
|
10
|
+
unpatchClaudeCodeHooks,
|
|
11
|
+
troxyPostToolUseCommand,
|
|
12
|
+
_isTroxyPostToolUseEntry,
|
|
13
|
+
} from '../init.js';
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const initJsPath = path.join(__dirname, '..', 'init.js');
|
|
17
|
+
|
|
18
|
+
describe('PostToolUse hook patching', () => {
|
|
19
|
+
test('troxyPostToolUseCommand() returns a command string containing posttooluse-hook', () => {
|
|
20
|
+
const cmd = troxyPostToolUseCommand();
|
|
21
|
+
assert.strictEqual(typeof cmd, 'string');
|
|
22
|
+
assert.match(cmd, /posttooluse-hook/);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('_isTroxyPostToolUseEntry(entry) returns true for Troxy PostToolUse entries', () => {
|
|
26
|
+
const entry = {
|
|
27
|
+
matcher: '*',
|
|
28
|
+
hooks: [{ command: 'node /path/to/posttooluse-hook.js' }],
|
|
29
|
+
};
|
|
30
|
+
assert.strictEqual(_isTroxyPostToolUseEntry(entry), true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('_isTroxyPostToolUseEntry(entry) returns false for non-Troxy entries', () => {
|
|
34
|
+
const entry = {
|
|
35
|
+
matcher: '*',
|
|
36
|
+
hooks: [{ command: 'echo something' }],
|
|
37
|
+
};
|
|
38
|
+
assert.strictEqual(_isTroxyPostToolUseEntry(entry), false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('patchClaudeCodeHooks adds a PostToolUse entry by default, with no sandbox option at all', () => {
|
|
42
|
+
const config = { hooks: {} };
|
|
43
|
+
const patched = patchClaudeCodeHooks(config, {});
|
|
44
|
+
const entries = patched.hooks.PostToolUse;
|
|
45
|
+
assert.ok(Array.isArray(entries) && entries.length === 1);
|
|
46
|
+
assert.match(entries[0].hooks[0].command, /posttooluse-hook/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('patchClaudeCodeHooks adds a PostToolUse entry when sandbox option is not false', () => {
|
|
50
|
+
const config = { hooks: {} };
|
|
51
|
+
const patched = patchClaudeCodeHooks(config, { sandbox: true });
|
|
52
|
+
const entries = patched.hooks.PostToolUse;
|
|
53
|
+
assert.ok(Array.isArray(entries) && entries.length === 1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('patchClaudeCodeHooks still installs the PostToolUse entry even when called with { sandbox: false } - the hook is unconditional, not opt-out-able', () => {
|
|
57
|
+
const config = { hooks: {} };
|
|
58
|
+
const patched = patchClaudeCodeHooks(config, { sandbox: false });
|
|
59
|
+
const entries = patched.hooks.PostToolUse;
|
|
60
|
+
assert.ok(Array.isArray(entries) && entries.length === 1, 'sandbox hook must be installed regardless of any sandbox option');
|
|
61
|
+
assert.match(entries[0].hooks[0].command, /posttooluse-hook/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('patchClaudeCodeHooks is idempotent - patching twice does not duplicate PostToolUse entry', () => {
|
|
65
|
+
const config = { hooks: {} };
|
|
66
|
+
const patched1 = patchClaudeCodeHooks(config, {});
|
|
67
|
+
const patched2 = patchClaudeCodeHooks(patched1, {});
|
|
68
|
+
const entries = patched2.hooks.PostToolUse;
|
|
69
|
+
assert.strictEqual(entries.length, 1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('patchClaudeCodeHooks still handles PreToolUse entries as before', () => {
|
|
73
|
+
const config = { hooks: {} };
|
|
74
|
+
const patched = patchClaudeCodeHooks(config, {});
|
|
75
|
+
const preEntries = patched.hooks.PreToolUse;
|
|
76
|
+
assert.ok(Array.isArray(preEntries) && preEntries.length > 0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('unpatchClaudeCodeHooks removes PostToolUse entry, leaving other entries untouched', () => {
|
|
80
|
+
const config = { hooks: {} };
|
|
81
|
+
const patched = patchClaudeCodeHooks(config, {});
|
|
82
|
+
const unpatched = unpatchClaudeCodeHooks(patched);
|
|
83
|
+
const postEntries = unpatched.hooks.PostToolUse;
|
|
84
|
+
assert.ok(Array.isArray(postEntries) && postEntries.length === 0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('unpatchClaudeCodeHooks preserves non-Troxy PostToolUse hooks', () => {
|
|
88
|
+
const config = {
|
|
89
|
+
hooks: {
|
|
90
|
+
PostToolUse: [
|
|
91
|
+
{
|
|
92
|
+
matcher: '*',
|
|
93
|
+
hooks: [{ command: 'echo other' }],
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
const unpatched = unpatchClaudeCodeHooks(config);
|
|
99
|
+
const entries = unpatched.hooks.PostToolUse;
|
|
100
|
+
assert.ok(Array.isArray(entries) && entries.length === 1);
|
|
101
|
+
assert.strictEqual(entries[0].hooks[0].command, 'echo other');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('reprovisionKeyConsumers wires the PostToolUse sandbox hook into a real Claude Code settings file, with the hook always installed (no --no-sandbox escape hatch exists anymore)', () => {
|
|
105
|
+
// Runs the REAL reprovisionKeyConsumers (not a mock) in a subprocess with
|
|
106
|
+
// Claude Desktop detection forced true via HOME, so this exercises the
|
|
107
|
+
// exact call chain runInit -> reprovisionKeyConsumers ->
|
|
108
|
+
// _patchClaudeCodeHooksFile(claudeCodeSettingsPath()).
|
|
109
|
+
//
|
|
110
|
+
// Runs as a subprocess, not in-process, for two reasons: (1) mutating
|
|
111
|
+
// process.env.HOME in-process would leak into other tests sharing this
|
|
112
|
+
// process, and (2) reprovisionKeyConsumers unconditionally calls
|
|
113
|
+
// installService(). installService has a hardcoded fallback
|
|
114
|
+
// (fs.existsSync('/usr/local/bin/troxy')) that a restricted PATH does
|
|
115
|
+
// NOT protect against - on a machine where that file happens to exist,
|
|
116
|
+
// a PATH-only guard would let installService run for real and issue
|
|
117
|
+
// `execSync('launchctl bootout/bootstrap ...')` against the host's
|
|
118
|
+
// actual logged-in launchd GUI domain, capable of disrupting a real
|
|
119
|
+
// running ai.troxy.mcp service. So this test does not rely on PATH (or
|
|
120
|
+
// on installService throwing) for safety at all: it sets
|
|
121
|
+
// TROXY_TEST_SKIP_SERVICE_INSTALL=1, which reprovisionKeyConsumers
|
|
122
|
+
// checks BEFORE ever calling installService (see init.js, the
|
|
123
|
+
// "Setting up background service..." block) - installService is never
|
|
124
|
+
// invoked, so verifyTroxyBinExists and the launchctl execSync calls are
|
|
125
|
+
// unreachable regardless of what exists on the host filesystem.
|
|
126
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-home-'));
|
|
127
|
+
try {
|
|
128
|
+
// Force hasClaudeDesktop() true (checks a fixed ~/.config/Claude path
|
|
129
|
+
// regardless of real platform) and give detectMcpClients() a Claude
|
|
130
|
+
// Desktop config to find, so reprovisionKeyConsumers takes the
|
|
131
|
+
// "patch Claude Code hooks" branch instead of the no-clients-detected
|
|
132
|
+
// one.
|
|
133
|
+
fs.mkdirSync(path.join(tmpHome, '.config', 'Claude'), { recursive: true });
|
|
134
|
+
const desktopConfigPath =
|
|
135
|
+
process.platform === 'darwin'
|
|
136
|
+
? path.join(tmpHome, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
|
|
137
|
+
: path.join(tmpHome, '.config', 'claude', 'claude_desktop_config.json');
|
|
138
|
+
fs.mkdirSync(path.dirname(desktopConfigPath), { recursive: true });
|
|
139
|
+
fs.writeFileSync(desktopConfigPath, '{}');
|
|
140
|
+
|
|
141
|
+
const script = `
|
|
142
|
+
import { reprovisionKeyConsumers } from ${JSON.stringify('file://' + initJsPath)};
|
|
143
|
+
await reprovisionKeyConsumers('test-api-key-ci', 'claude-code', false, false);
|
|
144
|
+
`;
|
|
145
|
+
const scriptPath = path.join(tmpHome, 'run.mjs');
|
|
146
|
+
fs.writeFileSync(scriptPath, script);
|
|
147
|
+
|
|
148
|
+
execFileSync(process.execPath, [scriptPath], {
|
|
149
|
+
env: {
|
|
150
|
+
...process.env,
|
|
151
|
+
HOME: tmpHome,
|
|
152
|
+
PATH: '/usr/bin:/bin',
|
|
153
|
+
TROXY_TEST_SKIP_SERVICE_INSTALL: '1',
|
|
154
|
+
},
|
|
155
|
+
encoding: 'utf8',
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const settingsPath = path.join(tmpHome, '.claude', 'settings.json');
|
|
159
|
+
assert.ok(fs.existsSync(settingsPath), 'expected reprovisionKeyConsumers to write Claude Code settings.json');
|
|
160
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
161
|
+
const postEntries = settings.hooks?.PostToolUse;
|
|
162
|
+
assert.ok(Array.isArray(postEntries) && postEntries.length === 1, 'expected a PostToolUse sandbox hook entry');
|
|
163
|
+
assert.match(postEntries[0].hooks[0].command, /posttooluse-hook/);
|
|
164
|
+
// Pre-existing hook wiring (Finding 1's blast radius) must still work too.
|
|
165
|
+
const preEntries = settings.hooks?.PreToolUse;
|
|
166
|
+
assert.ok(Array.isArray(preEntries) && preEntries.length > 0, 'expected the existing PreToolUse hook to still be installed');
|
|
167
|
+
const stopEntries = settings.hooks?.Stop;
|
|
168
|
+
assert.ok(Array.isArray(stopEntries) && stopEntries.length > 0, 'expected the existing Stop hook to still be installed');
|
|
169
|
+
} finally {
|
|
170
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|