troxy-cli 1.29.23 → 1.29.25
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 +51 -1
- package/src/mcp-server.js +27 -0
- package/src/sandbox/compress.js +52 -0
- package/src/sandbox/posttooluse-hook.js +157 -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 +79 -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 +225 -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
|
@@ -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', () => {
|
|
@@ -594,3 +653,23 @@ describe('createInterceptor - integration, real sockets', () => {
|
|
|
594
653
|
assert.equal(result.body, 'tunneled-because-cert-unhealthy');
|
|
595
654
|
});
|
|
596
655
|
});
|
|
656
|
+
|
|
657
|
+
describe('buildTroxyForwardHeaders session arm', () => {
|
|
658
|
+
it('tags a request with its own session arm, ignoring another session\'s last-state', () => {
|
|
659
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
|
|
660
|
+
fs.mkdirSync(path.join(dir, 'sessions'));
|
|
661
|
+
fs.writeFileSync(path.join(dir, 'sessions', 'sess-aaaa-1111.json'), JSON.stringify({ arm: 'holdout' }));
|
|
662
|
+
fs.writeFileSync(path.join(dir, 'last-state.json'), JSON.stringify({ state: 'treated' }));
|
|
663
|
+
const headers = buildTroxyForwardHeaders(
|
|
664
|
+
{ host: 'api.anthropic.com', 'x-claude-code-session-id': 'sess-aaaa-1111' }, 'txy-key', dir);
|
|
665
|
+
assert.equal(headers['x-troxy-tool-sandbox'], 'holdout');
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it('leaves a session with no arm yet untagged, never borrowing another session\'s last-state', () => {
|
|
669
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
|
|
670
|
+
fs.writeFileSync(path.join(dir, 'last-state.json'), JSON.stringify({ state: 'treated' }));
|
|
671
|
+
const headers = buildTroxyForwardHeaders(
|
|
672
|
+
{ host: 'api.anthropic.com', 'x-claude-code-session-id': 'sess-new-9999' }, 'txy-key', dir);
|
|
673
|
+
assert.equal('x-troxy-tool-sandbox' in headers, false);
|
|
674
|
+
});
|
|
675
|
+
});
|
|
@@ -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
|
-
//
|
|
246
|
-
// (2026-08-15, mcp-checkpoints.test.js) to close a real gap,
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
|
|
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,225 @@
|
|
|
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
|
+
});
|
|
200
|
+
|
|
201
|
+
test('session arm is drawn once per session_id and sticks across tool calls', async () => {
|
|
202
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-arm-'));
|
|
203
|
+
const big = { tool_response: { stdout: 'x'.repeat(5000) }, session_id: 'sess-aaaa-1111' };
|
|
204
|
+
// First call draws holdout (random 0 < 0.2).
|
|
205
|
+
const r1 = await buildHookResponse(big, { holdoutFraction: 0.2, randomFn: () => 0, storeDir: dir });
|
|
206
|
+
assert.equal(r1, undefined);
|
|
207
|
+
// Later draws would say treated, but the session stays holdout.
|
|
208
|
+
const r2 = await buildHookResponse(big, { holdoutFraction: 0.2, randomFn: () => 0.99, storeDir: dir });
|
|
209
|
+
assert.equal(r2, undefined);
|
|
210
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(dir, 'sessions', 'sess-aaaa-1111.json'), 'utf8')).arm, 'holdout');
|
|
211
|
+
|
|
212
|
+
// A treated session compresses every big output, no per-call holdout draw.
|
|
213
|
+
const t = { ...big, session_id: 'sess-bbbb-2222' };
|
|
214
|
+
const t1 = await buildHookResponse(t, { holdoutFraction: 0.2, randomFn: () => 0.99, storeDir: dir });
|
|
215
|
+
assert.ok(t1?.hookSpecificOutput);
|
|
216
|
+
const t2 = await buildHookResponse(t, { holdoutFraction: 0.2, randomFn: () => 0, storeDir: dir });
|
|
217
|
+
assert.ok(t2?.hookSpecificOutput, 'treated session must not fall into a per-call holdout');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('session_id with path characters is ignored (no file outside the store)', async () => {
|
|
221
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-arm-'));
|
|
222
|
+
await buildHookResponse({ tool_response: { stdout: 'small' }, session_id: '../../etc/x' },
|
|
223
|
+
{ holdoutFraction: 0.2, randomFn: () => 0, storeDir: dir });
|
|
224
|
+
assert.equal(fs.existsSync(path.join(dir, 'sessions')), false);
|
|
225
|
+
});
|
|
@@ -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('
|
|
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', '
|
|
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,
|
|
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
|
-
|
|
147
|
+
_unpatchClaudeCodeHooksFile(claudeCodeSettingsPath());
|
|
148
148
|
unpatchClaudeCodeProxy(claudeCodeSettingsPath());
|
|
149
149
|
unpatchClaudeCodeInterception(claudeCodeSettingsPath());
|
|
150
150
|
console.log('✓');
|