troxy-cli 1.29.22 → 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.
@@ -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
- patchClaudeCodeHooks(configPath(), '/usr/local/bin/troxy hook-report');
126
- const cfg = read();
127
- assert.equal(cfg.hooks.Stop.length, 1);
128
- assert.equal(cfg.hooks.Stop[0].hooks[0].command, '/usr/local/bin/troxy hook-report');
129
- assert.equal(cfg.hooks.Stop[0].hooks[0].type, 'command');
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
- patchClaudeCodeHooks(configPath(), 'troxy hook-report');
134
- patchClaudeCodeHooks(configPath(), 'troxy hook-report');
135
- const cfg = read();
136
- assert.equal(cfg.hooks.Stop.length, 1);
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
- patchClaudeCodeHooks(configPath(), 'npx troxy-cli hook-report');
141
- patchClaudeCodeHooks(configPath(), '/usr/local/bin/troxy hook-report');
142
- const cfg = read();
143
- assert.equal(cfg.hooks.Stop.length, 1);
144
- assert.equal(cfg.hooks.Stop[0].hooks[0].command, '/usr/local/bin/troxy hook-report');
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
- fs.writeFileSync(configPath(), JSON.stringify({
158
+ let cfg = {
149
159
  hooks: { Stop: [{ hooks: [{ type: 'command', command: 'my-own-script.sh' }] }] },
150
- }));
151
- patchClaudeCodeHooks(configPath(), 'troxy hook-report');
152
- const cfg = read();
153
- assert.equal(cfg.hooks.Stop.length, 2);
154
- assert.ok(cfg.hooks.Stop.some(e => e.hooks[0].command === 'my-own-script.sh'));
155
- assert.ok(cfg.hooks.Stop.some(e => e.hooks[0].command === 'troxy hook-report'));
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
- fs.writeFileSync(configPath(), JSON.stringify({
170
+ let cfg = {
160
171
  hooks: { PreToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'validate.sh' }] }] },
161
- }));
162
- patchClaudeCodeHooks(configPath(), 'troxy hook-report');
163
- const cfg = read();
164
- assert.ok(cfg.hooks.PreToolUse, 'an unrelated hook event was dropped by the patch');
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
- fs.writeFileSync(configPath(), 'not json at all');
169
- assert.doesNotThrow(() => patchClaudeCodeHooks(configPath(), 'troxy hook-report'));
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(configPath(), 'troxy hook-report', '/usr/local/bin/troxy pretooluse-hook');
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(configPath(), 'troxy hook-report', 'troxy pretooluse-hook');
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(configPath(), 'troxy hook-report', 'troxy pretooluse-hook');
196
- patchClaudeCodeHooks(configPath(), 'troxy hook-report', 'troxy pretooluse-hook');
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(configPath(), 'troxy hook-report', 'npx troxy-cli pretooluse-hook');
203
- patchClaudeCodeHooks(configPath(), 'troxy hook-report', '/usr/local/bin/troxy pretooluse-hook');
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
- fs.writeFileSync(configPath(), JSON.stringify({
217
+ const initial = {
211
218
  hooks: { PreToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'validate.sh' }] }] },
212
- }));
213
- patchClaudeCodeHooks(configPath(), 'troxy hook-report', 'troxy pretooluse-hook');
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(configPath(), 'troxy hook-report', 'troxy pretooluse-hook');
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(configPath(), 'troxy hook-report', 'troxy pretooluse-hook');
238
- unpatchClaudeCodeHooks(configPath());
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
- fs.writeFileSync(configPath(), JSON.stringify({
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(configPath(), 'troxy hook-report', 'troxy pretooluse-hook');
252
- unpatchClaudeCodeHooks(configPath());
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 and does not create a file when called against a non-existent settings file', () => {
261
- assert.doesNotThrow(() => unpatchClaudeCodeHooks(configPath()));
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 the existing file is corrupted JSON', () => {
266
- fs.writeFileSync(configPath(), '{ not json');
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(configPath(), 'troxy hook-report', 'troxy pretooluse-hook');
272
- unpatchClaudeCodeHooks(configPath());
273
- assert.doesNotThrow(() => unpatchClaudeCodeHooks(configPath()));
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
+ });