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.
@@ -13,6 +13,9 @@
13
13
 
14
14
  import { describe, it, after } from 'node:test';
15
15
  import assert from 'node:assert/strict';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import os from 'node:os';
16
19
  import net from 'node:net';
17
20
  import tls from 'node:tls';
18
21
  import http from 'node:http';
@@ -105,6 +108,62 @@ describe('buildTroxyForwardHeaders', () => {
105
108
  assert.equal(headers.connection, undefined);
106
109
  assert.equal(headers['content-length'], undefined);
107
110
  });
111
+
112
+ it('adds x-troxy-tool-sandbox header when a fresh state file exists', () => {
113
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
114
+ fs.writeFileSync(path.join(dir, 'last-state.json'), JSON.stringify({ state: 'treated' }));
115
+
116
+ const headers = buildTroxyForwardHeaders(
117
+ { host: 'api.anthropic.com', 'user-agent': 'claude-cli/2.1.236' },
118
+ 'txy-key',
119
+ dir,
120
+ );
121
+
122
+ assert.equal(headers['x-troxy-tool-sandbox'], 'treated');
123
+ });
124
+
125
+ it('omits x-troxy-tool-sandbox header when no state file exists', () => {
126
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
127
+
128
+ const headers = buildTroxyForwardHeaders(
129
+ { host: 'api.anthropic.com', 'user-agent': 'claude-cli/2.1.236' },
130
+ 'txy-key',
131
+ dir,
132
+ );
133
+
134
+ assert.equal('x-troxy-tool-sandbox' in headers, false);
135
+ });
136
+
137
+ it('omits x-troxy-tool-sandbox header when state file is stale (older than 2 minutes)', () => {
138
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
139
+ const stateFile = path.join(dir, 'last-state.json');
140
+ fs.writeFileSync(stateFile, JSON.stringify({ state: 'treated' }));
141
+
142
+ const old = new Date(Date.now() - 3 * 60 * 1000);
143
+ fs.utimesSync(stateFile, old, old);
144
+
145
+ const headers = buildTroxyForwardHeaders(
146
+ { host: 'api.anthropic.com', 'user-agent': 'claude-cli/2.1.236' },
147
+ 'txy-key',
148
+ dir,
149
+ );
150
+
151
+ assert.equal('x-troxy-tool-sandbox' in headers, false);
152
+ });
153
+
154
+ it('does not alter existing x-troxy-surface header behavior when sandbox state is present', () => {
155
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
156
+ fs.writeFileSync(path.join(dir, 'last-state.json'), JSON.stringify({ state: 'holdout' }));
157
+
158
+ const headers = buildTroxyForwardHeaders(
159
+ { host: 'api.anthropic.com', 'user-agent': 'claude-cli/2.1.236 (external, cli)' },
160
+ 'txy-key',
161
+ dir,
162
+ );
163
+
164
+ assert.equal(headers['x-troxy-surface'], 'terminal');
165
+ assert.equal(headers['x-troxy-tool-sandbox'], 'holdout');
166
+ });
108
167
  });
109
168
 
110
169
  describe('buildRelayHeaders', () => {
@@ -0,0 +1,22 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import fs from 'node:fs';
6
+ import { put } from '../sandbox/store.js';
7
+ import { handleRecall } from '../mcp-server.js';
8
+
9
+ test('troxy_recall returns the stored content for a valid handle', async () => {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
11
+ const { handle } = await put('the full raw output', { summary: 'x', tags: [] }, dir);
12
+ const result = await handleRecall({ handle }, dir);
13
+ assert.equal(result.isError, false);
14
+ assert.equal(result.content[0].text, 'the full raw output');
15
+ });
16
+
17
+ test('troxy_recall returns a clear error for an unknown handle', async () => {
18
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
19
+ const result = await handleRecall({ handle: 'does-not-exist' }, dir);
20
+ assert.equal(result.isError, true);
21
+ assert.match(result.content[0].text, /not found/i);
22
+ });
@@ -242,10 +242,11 @@ test('no new tool was introduced for the chat path', () => {
242
242
  const toolsSeg = src.slice(src.indexOf('tools: ['), src.indexOf('server.setRequestHandler(CallToolRequestSchema'));
243
243
  const toolNames = [...toolsSeg.matchAll(/name: '([a-z_]+)'/g)].map(m => m[1]);
244
244
  const unique = new Set(toolNames);
245
- // 9, not this test's original 8: evaluate_secrets was added afterward
246
- // (2026-08-15, mcp-checkpoints.test.js) to close a real gap, not to give
247
- // chat its own tool - the assertion this test cares about is still that no
248
- // *tenth*, chat-specific tool exists.
249
- assert.strictEqual(unique.size, 9, 'a tool was added or removed for this change');
245
+ // 10, not this test's original 8: evaluate_secrets was added afterward
246
+ // (2026-08-15, mcp-checkpoints.test.js) to close a real gap, and
247
+ // troxy_recall was added later still (tool-output-sandbox) to fetch back
248
+ // sandboxed output - neither gives chat its own tool. The assertion this
249
+ // test cares about is still that no *eleventh*, chat-specific tool exists.
250
+ assert.strictEqual(unique.size, 10, 'a tool was added or removed for this change');
250
251
  assert.ok(unique.has('evaluate_model') && unique.has('report_model_usage'));
251
252
  });
@@ -0,0 +1,199 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
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 { buildHookResponse } from '../sandbox/posttooluse-hook.js';
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const troxyBin = path.join(__dirname, '..', '..', 'bin', 'troxy.js');
12
+
13
+ function readState(dir) {
14
+ return JSON.parse(fs.readFileSync(path.join(dir, 'last-state.json'), 'utf8'));
15
+ }
16
+
17
+ // Real tool_response shapes, confirmed 2026-10-05 against this machine's own
18
+ // ~/.claude/projects/*/*.jsonl transcripts - a prior version of this file
19
+ // used an invented `{ tool_response: { output } }` shape that no real tool
20
+ // result ever has, which is why 9 clean task reviews never caught that the
21
+ // hook was a complete no-op in production. Bash is real tool_response's most
22
+ // common shape by a wide margin (~20k of ~33k sampled real results).
23
+ function bashResult(stdout) {
24
+ return { interrupted: false, isImage: false, noOutputExpected: false, stderr: '', stdout };
25
+ }
26
+
27
+ test('small Bash output passes through unmodified, no store write', async () => {
28
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
29
+ const response = await buildHookResponse(
30
+ { tool_response: bashResult('short') },
31
+ { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0, storeDir: dir });
32
+ assert.equal(response, undefined); // undefined = Claude Code keeps the real output
33
+ });
34
+
35
+ test('small output writes skipped state', async () => {
36
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
37
+ await buildHookResponse(
38
+ { tool_response: bashResult('short') },
39
+ { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0, storeDir: dir });
40
+ assert.equal(readState(dir).state, 'skipped');
41
+ });
42
+
43
+ test('large Bash stdout gets replaced with a summary containing the real handle, other fields untouched', async () => {
44
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
45
+ const big = 'x'.repeat(3000);
46
+ const response = await buildHookResponse(
47
+ { tool_response: bashResult(big) },
48
+ { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir });
49
+ assert.equal(response.hookSpecificOutput.hookEventName, 'PostToolUse');
50
+ const updated = response.hookSpecificOutput.updatedToolOutput;
51
+ // Must be the full real tool_response shape, not a bare string - Claude
52
+ // Code validates updatedToolOutput against the tool's own output schema
53
+ // and silently reverts to the original output (plus a visible hook-error
54
+ // message) if the shape doesn't match.
55
+ assert.equal(typeof updated, 'object');
56
+ assert.equal(updated.stderr, ''); // untouched sibling field survives
57
+ assert.equal(updated.interrupted, false);
58
+ assert.ok(updated.stdout.length < big.length);
59
+ const index = JSON.parse(fs.readFileSync(path.join(dir, 'index.json'), 'utf8'));
60
+ const handle = index[0].handle;
61
+ assert.ok(handle, 'expected a handle to have been written to the store index');
62
+ assert.ok(updated.stdout.includes(handle),
63
+ 'updatedToolOutput.stdout must embed the real recall handle, not just the word troxy_recall');
64
+ });
65
+
66
+ test('large Read file content gets replaced with a summary, nested under file.content', async () => {
67
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
68
+ const big = 'y'.repeat(3000);
69
+ const response = await buildHookResponse(
70
+ { tool_response: { type: 'text', file: { filePath: '/tmp/big.txt', content: big } } },
71
+ { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir });
72
+ const updated = response.hookSpecificOutput.updatedToolOutput;
73
+ assert.equal(updated.file.filePath, '/tmp/big.txt'); // untouched sibling field survives
74
+ assert.ok(updated.file.content.length < big.length);
75
+ });
76
+
77
+ test('large Write/create content gets replaced with a summary on the content field', async () => {
78
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
79
+ const big = 'z'.repeat(3000);
80
+ const response = await buildHookResponse(
81
+ { tool_response: { type: 'create', filePath: '/tmp/new.txt', content: big } },
82
+ { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir });
83
+ const updated = response.hookSpecificOutput.updatedToolOutput;
84
+ assert.equal(updated.filePath, '/tmp/new.txt');
85
+ assert.ok(updated.content.length < big.length);
86
+ });
87
+
88
+ test('a bare-string tool_response (MCP tools) is sandboxed and rebuilt as a plain string', async () => {
89
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
90
+ const big = 'w'.repeat(3000);
91
+ const response = await buildHookResponse(
92
+ { tool_response: big },
93
+ { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir });
94
+ assert.equal(typeof response.hookSpecificOutput.updatedToolOutput, 'string');
95
+ assert.ok(response.hookSpecificOutput.updatedToolOutput.length < big.length);
96
+ });
97
+
98
+ test('an unrecognized tool_response shape (e.g. Grep matches) is left untouched, not guessed at', async () => {
99
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
100
+ // Grep's real shape - no stdout/content/file.content field this hook
101
+ // knows how to extract or rebuild safely, so it must pass through rather
102
+ // than risk a schema mismatch on a shape nobody verified.
103
+ const response = await buildHookResponse(
104
+ { tool_response: { matches: Array(500).fill('/some/long/path/to/a/file.js'), query: 'x' } },
105
+ { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir });
106
+ assert.equal(response, undefined);
107
+ assert.equal(readState(dir).state, 'skipped');
108
+ });
109
+
110
+ test('successful compression writes treated state', async () => {
111
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
112
+ const big = 'x'.repeat(3000);
113
+ await buildHookResponse(
114
+ { tool_response: bashResult(big) },
115
+ { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir });
116
+ assert.equal(readState(dir).state, 'treated');
117
+ });
118
+
119
+ test('excluded by sandboxFraction draw returns undefined and writes skipped state', async () => {
120
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
121
+ const big = 'x'.repeat(3000);
122
+ const response = await buildHookResponse(
123
+ { tool_response: bashResult(big) },
124
+ { sandboxFraction: 0, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir });
125
+ assert.equal(response, undefined);
126
+ assert.equal(readState(dir).state, 'skipped');
127
+ });
128
+
129
+ test('holdout draw skips compression even for large output, and records holdout state', async () => {
130
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
131
+ const big = 'x'.repeat(3000);
132
+ const response = await buildHookResponse(
133
+ { tool_response: bashResult(big) },
134
+ { sandboxFraction: 1, holdoutFraction: 1, randomFn: () => 0, storeDir: dir });
135
+ assert.equal(response, undefined);
136
+ assert.equal(readState(dir).state, 'holdout');
137
+ });
138
+
139
+ test('a thrown error during compression fails open to the real output', async () => {
140
+ const dir = '/nonexistent/path/that/cannot/be/created/\0bad';
141
+ const big = 'x'.repeat(3000);
142
+ const response = await buildHookResponse(
143
+ { tool_response: bashResult(big) },
144
+ { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir });
145
+ assert.equal(response, undefined); // store write failed, fails open
146
+ });
147
+
148
+ test('a put() failure does not leave a stale treated state behind', async () => {
149
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
150
+ // Seed a prior 'treated' state, then make the store directory unwritable so
151
+ // put() throws - the failure must overwrite this with 'skipped', not leave
152
+ // the stale 'treated' value looking like this turn succeeded.
153
+ fs.writeFileSync(path.join(dir, 'last-state.json'), JSON.stringify({ state: 'treated' }));
154
+ fs.chmodSync(dir, 0o500); // read+execute only, no write
155
+ const big = 'x'.repeat(3000);
156
+ try {
157
+ const response = await buildHookResponse(
158
+ { tool_response: bashResult(big) },
159
+ { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir });
160
+ assert.equal(response, undefined);
161
+ } finally {
162
+ fs.chmodSync(dir, 0o700); // restore so the test runner can clean up the tmpdir
163
+ }
164
+ const state = readState(dir);
165
+ assert.notEqual(state.state, 'treated');
166
+ assert.equal(state.state, 'skipped');
167
+ });
168
+
169
+ test('bin/troxy.js posttooluse-hook dispatch: real CLI subprocess does not throw and produces sensible stdout', () => {
170
+ // Regression test for Finding 2: bin/troxy.js dynamically imports
171
+ // runPostToolUseHook and awaits it. Before the fix that export did not
172
+ // exist (only an unexported main()), so this threw
173
+ // "TypeError: runPostToolUseHook is not a function", was caught by
174
+ // bin/troxy.js's top-level try/catch, and exited 1 on every real hook
175
+ // invocation. Spawning the real CLI is the only way to catch that -
176
+ // calling buildHookResponse directly (as the tests above do) never
177
+ // touches bin/troxy.js's import at all.
178
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-home-'));
179
+ try {
180
+ // Small output: deterministically below the sandboxing threshold regardless
181
+ // of the holdout coin flip, so this assertion can't flake. The point isn't
182
+ // exercising buildHookResponse's branches (covered above) - it's proving
183
+ // bin/troxy.js's dynamic import of runPostToolUseHook actually resolves
184
+ // and runs to completion through the real CLI entrypoint.
185
+ const stdout = execFileSync(
186
+ process.execPath,
187
+ [troxyBin, 'posttooluse-hook'],
188
+ {
189
+ input: JSON.stringify({ tool_response: bashResult('short') }),
190
+ env: { ...process.env, HOME: tmpHome },
191
+ encoding: 'utf8',
192
+ }
193
+ );
194
+ // Fail-open/skip path: no replacement needed, so no stdout is written.
195
+ assert.equal(stdout, '');
196
+ } finally {
197
+ fs.rmSync(tmpHome, { recursive: true, force: true });
198
+ }
199
+ });
@@ -32,13 +32,13 @@ describe('reprovisionKeyConsumers: desktop detection gate', () => {
32
32
  });
33
33
 
34
34
  it('gates patchClaudeCodeHooks on (hasClaude || hasDesktop), not hasClaude alone', () => {
35
- const hooksIdx = initSrc.indexOf('patchClaudeCodeHooks(claudeCodeSettingsPath())');
36
- assert.ok(hooksIdx !== -1, 'patchClaudeCodeHooks call not found');
35
+ const hooksIdx = initSrc.indexOf('_patchClaudeCodeHooksFile(claudeCodeSettingsPath()');
36
+ assert.ok(hooksIdx !== -1, 'patchClaudeCodeHooks hook call not found');
37
37
  const preceding = initSrc.slice(Math.max(0, hooksIdx - 200), hooksIdx);
38
38
  assert.match(
39
39
  preceding,
40
40
  /if\s*\(hasClaude\s*\|\|\s*hasDesktop\)\s*\{/,
41
- 'the Stop/PreToolUse hook step must be gated on hasClaude || hasDesktop',
41
+ 'the Stop/PreToolUse/PostToolUse hook step must be gated on hasClaude || hasDesktop',
42
42
  );
43
43
  });
44
44
 
@@ -100,9 +100,11 @@ describe('bin/troxy.js: proxy subcommand wiring', () => {
100
100
 
101
101
  describe('uninstall.js: settings.json symmetry (fail-open layer 6)', () => {
102
102
  it('calls all four unpatch functions', () => {
103
- for (const fn of ['unpatchClaudeCodeConfig', 'unpatchClaudeCodeHooks', 'unpatchClaudeCodeProxy', 'unpatchClaudeCodeInterception']) {
103
+ for (const fn of ['unpatchClaudeCodeConfig', 'unpatchClaudeCodeProxy', 'unpatchClaudeCodeInterception']) {
104
104
  assert.ok(uninstallSrc.includes(`${fn}(`), `${fn} is not called from uninstall.js`);
105
105
  }
106
+ // unpatchClaudeCodeHooks is called via its file-based wrapper _unpatchClaudeCodeHooksFile
107
+ assert.ok(uninstallSrc.includes('_unpatchClaudeCodeHooksFile('), '_unpatchClaudeCodeHooksFile is not called from uninstall.js');
106
108
  });
107
109
 
108
110
  it('reverts settings.json BEFORE deleting ~/.troxy, not after', () => {
@@ -0,0 +1,85 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { shouldSandbox, summarize } from '../sandbox/compress.js';
4
+
5
+ test('shouldSandbox is false for small output', () => {
6
+ assert.equal(shouldSandbox('short output'), false);
7
+ });
8
+
9
+ test('shouldSandbox is true for output over 2000 chars', () => {
10
+ assert.equal(shouldSandbox('x'.repeat(2001)), true);
11
+ });
12
+
13
+ test('summarize keeps failing test lines verbatim', () => {
14
+ const output = Array.from({ length: 200 }, (_, i) => `test_${i} PASSED`).join('\n')
15
+ + '\ntest_login FAILED: AssertionError: expected 200, got 403'
16
+ + '\n' + Array.from({ length: 200 }, (_, i) => `test_${i + 200} PASSED`).join('\n');
17
+ const summary = summarize(output);
18
+ assert.match(summary, /test_login FAILED: AssertionError: expected 200, got 403/);
19
+ });
20
+
21
+ test('summarize falls back to head+tail with a byte count for generic output', () => {
22
+ const output = Array.from({ length: 500 }, (_, i) => `line ${i}`).join('\n');
23
+ const summary = summarize(output);
24
+ assert.match(summary, /line 0/);
25
+ assert.match(summary, /line 499/);
26
+ assert.match(summary, /use troxy_recall/);
27
+ });
28
+
29
+ test('summarize keeps a fused Python exception verbatim', () => {
30
+ const output = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n')
31
+ + "\nValueError: invalid literal for int() with base 10: 'abc'"
32
+ + '\n' + Array.from({ length: 200 }, (_, i) => `line ${i + 200}`).join('\n');
33
+ const summary = summarize(output);
34
+ assert.match(summary, /ValueError: invalid literal for int\(\) with base 10: 'abc'/);
35
+ });
36
+
37
+ test('summarize keeps Python traceback frame lines verbatim', () => {
38
+ const output = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n')
39
+ + '\nTraceback (most recent call last):'
40
+ + '\n File "/app/main.py", line 42, in <module>'
41
+ + '\nKeyError: \'missing_key\''
42
+ + '\n' + Array.from({ length: 200 }, (_, i) => `line ${i + 200}`).join('\n');
43
+ const summary = summarize(output);
44
+ assert.match(summary, /File "\/app\/main\.py", line 42, in <module>/);
45
+ assert.match(summary, /KeyError: 'missing_key'/);
46
+ });
47
+
48
+ test('summarize keeps a jest-style failure verbatim', () => {
49
+ const output = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n')
50
+ + '\nFAIL src/foo.test.js'
51
+ + '\n ✕ adds two numbers (3 ms)'
52
+ + '\n' + Array.from({ length: 200 }, (_, i) => `line ${i + 200}`).join('\n');
53
+ const summary = summarize(output);
54
+ assert.match(summary, /FAIL src\/foo\.test\.js/);
55
+ assert.match(summary, /✕ adds two numbers/);
56
+ });
57
+
58
+ test('summarize keeps a go-style failure verbatim', () => {
59
+ const output = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n')
60
+ + '\n--- FAIL: TestForgottenIndex (0.00s)'
61
+ + '\n' + Array.from({ length: 200 }, (_, i) => `line ${i + 200}`).join('\n');
62
+ const summary = summarize(output);
63
+ assert.match(summary, /--- FAIL: TestForgottenIndex \(0\.00s\)/);
64
+ });
65
+
66
+ test('summarize keeps a rust panic verbatim', () => {
67
+ const output = Array.from({ length: 200 }, (_, i) => `line ${i}`).join('\n')
68
+ + "\nthread 'main' panicked at 'index out of bounds', src/main.rs:5:4"
69
+ + '\n' + Array.from({ length: 200 }, (_, i) => `line ${i + 200}`).join('\n');
70
+ const summary = summarize(output);
71
+ assert.match(summary, /thread 'main' panicked at 'index out of bounds', src\/main\.rs:5:4/);
72
+ });
73
+
74
+ test('summarize falls back to character truncation for a single giant line', () => {
75
+ const output = 'x'.repeat(5000);
76
+ const summary = summarize(output);
77
+ assert.ok(summary.length < output.length, 'summary should be smaller than the original blob');
78
+ assert.match(summary, /use troxy_recall/);
79
+ });
80
+
81
+ test('summarize notes truncation when more than 20 signal lines match', () => {
82
+ const matching = Array.from({ length: 25 }, (_, i) => `Error: failure ${i}`).join('\n');
83
+ const summary = summarize(matching);
84
+ assert.match(summary, /showing 20 of 25 matching lines/);
85
+ });
@@ -0,0 +1,86 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import fs from 'node:fs';
6
+ import { spawn } from 'node:child_process';
7
+ import { put, get, search, storeDir } from '../sandbox/store.js';
8
+
9
+ test('put stores content and get retrieves it by handle', async () => {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
11
+ const { handle } = await put('full raw output here', { summary: 'pytest: 40 passed', tags: ['pytest'] }, dir);
12
+ const content = await get(handle, dir);
13
+ assert.equal(content, 'full raw output here');
14
+ });
15
+
16
+ test('get returns null for an unknown handle', async () => {
17
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
18
+ const content = await get('does-not-exist', dir);
19
+ assert.equal(content, null);
20
+ });
21
+
22
+ test('search matches on summary substring, case-insensitive', async () => {
23
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
24
+ await put('output a', { summary: 'pytest: 40 passed, 2 failed', tags: ['pytest'] }, dir);
25
+ await put('output b', { summary: 'docker build succeeded', tags: ['docker'] }, dir);
26
+ const hits = await search('FAILED', dir);
27
+ assert.equal(hits.length, 1);
28
+ assert.match(hits[0].summary, /2 failed/);
29
+ });
30
+
31
+ test('concurrent put calls do not corrupt the index', async () => {
32
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
33
+ await Promise.all(Array.from({ length: 20 }, (_, i) =>
34
+ put(`content ${i}`, { summary: `entry ${i}`, tags: [] }, dir)));
35
+ const hits = await search('entry', dir);
36
+ assert.equal(hits.length, 5); // search caps at 5
37
+ const index = JSON.parse(fs.readFileSync(path.join(dir, 'index.json'), 'utf8'));
38
+ assert.equal(index.length, 20); // all 20 writes landed, none lost
39
+ });
40
+
41
+ test('concurrent put calls from separate OS processes do not corrupt the index', async () => {
42
+ // The single-process test above never truly interleaves (no await between
43
+ // sync fs calls on the uncontended path), and production load is real
44
+ // separate `node posttooluse-hook.js` processes, not concurrent async calls
45
+ // in one process. Spawn real child processes to exercise the actual lock.
46
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
47
+ const workerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-worker-'));
48
+ const storeUrl = new URL('../sandbox/store.js', import.meta.url).href;
49
+ const workerPath = path.join(workerDir, 'worker.mjs');
50
+ const putsPerProcess = 5;
51
+ const numProcesses = 6;
52
+ fs.writeFileSync(workerPath, [
53
+ "import { put } from 'STORE_URL_PLACEHOLDER';",
54
+ 'const [, , dir, prefix, countStr] = process.argv;',
55
+ 'const count = Number(countStr);',
56
+ 'for (let i = 0; i < count; i++) {',
57
+ " await put('content ' + prefix + '-' + i, { summary: 'entry ' + prefix + '-' + i, tags: [] }, dir);",
58
+ '}',
59
+ ].join('\n').replace('STORE_URL_PLACEHOLDER', storeUrl));
60
+
61
+ const runWorker = (prefix) => new Promise((resolve, reject) => {
62
+ const child = spawn(process.execPath, [workerPath, dir, String(prefix), String(putsPerProcess)]);
63
+ let stderr = '';
64
+ child.stderr.on('data', (d) => { stderr += d; });
65
+ child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`worker ${prefix} exited ${code}: ${stderr}`))));
66
+ });
67
+
68
+ await Promise.all(Array.from({ length: numProcesses }, (_, i) => runWorker(i)));
69
+
70
+ const index = JSON.parse(fs.readFileSync(path.join(dir, 'index.json'), 'utf8'));
71
+ assert.equal(index.length, numProcesses * putsPerProcess); // every write from every process landed
72
+ assert.equal(new Set(index.map((e) => e.summary)).size, numProcesses * putsPerProcess); // none lost or overwritten
73
+ });
74
+
75
+ test('get rejects a path-traversal handle instead of reading outside the store dir', async () => {
76
+ // handle is agent-controlled (troxy_recall's argument comes from the
77
+ // model) - a prompt-injected tool call could otherwise pass
78
+ // "../../../etc/passwd"-style handles and read arbitrary files.
79
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
80
+ const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-secret-'));
81
+ fs.writeFileSync(path.join(secretDir, 'secret.txt'), 'SECRET-CONTENTS');
82
+ const traversal = path.relative(dir, path.join(secretDir, 'secret')); // "../troxy-secret-xxx/secret"
83
+ assert.equal(await get(traversal, dir), null);
84
+ assert.equal(await get('../etc/passwd', dir), null);
85
+ assert.equal(await get('not-16-hex-chars', dir), null);
86
+ });
package/src/uninstall.js CHANGED
@@ -7,7 +7,7 @@ import { loadConfig } from './config.js';
7
7
  import { api } from './api.js';
8
8
  import {
9
9
  claudeCodeConfigPath, claudeCodeSettingsPath,
10
- unpatchClaudeCodeConfig, unpatchClaudeCodeHooks,
10
+ unpatchClaudeCodeConfig, _unpatchClaudeCodeHooksFile,
11
11
  unpatchClaudeCodeProxy, unpatchClaudeCodeInterception,
12
12
  } from './init.js';
13
13
  import { launchdUserDomain } from './launchd.js';
@@ -144,7 +144,7 @@ export async function runUninstall() {
144
144
  process.stdout.write(' Reverting Claude Code settings... ');
145
145
  try {
146
146
  unpatchClaudeCodeConfig(claudeCodeConfigPath());
147
- unpatchClaudeCodeHooks(claudeCodeSettingsPath());
147
+ _unpatchClaudeCodeHooksFile(claudeCodeSettingsPath());
148
148
  unpatchClaudeCodeProxy(claudeCodeSettingsPath());
149
149
  unpatchClaudeCodeInterception(claudeCodeSettingsPath());
150
150
  console.log('✓');