troxy-cli 1.29.5 → 1.29.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "troxy-cli",
3
- "version": "1.29.5",
3
+ "version": "1.29.7",
4
4
  "description": "A secure control layer for AI agents: policies across payments, messages, logins, destructive actions, model usage, and secrets, all enforceable from the CLI",
5
5
  "homepage": "https://troxy.io",
6
6
  "bugs": {
@@ -0,0 +1,108 @@
1
+ // Token Optimization plan, Step 4 (real scope, not the artifact's original
2
+ // one): rewrite a small, explicit set of known-verbose Bash commands into
3
+ // their own natively-concise equivalent, via PreToolUse's `updatedInput`
4
+ // field - BEFORE the command runs, since that's the only rewrite point
5
+ // Claude Code's real hook contract offers. There is no PostToolUse field
6
+ // that replaces a tool's actual output (confirmed against Claude Code's
7
+ // hooks reference, 2026-09-13) - a hook can only add supplementary
8
+ // `additionalContext` alongside the original output, or block the call
9
+ // entirely and feed Claude a reason instead of a result. So "compact the
10
+ // output after it runs" is not buildable here at all; this only ever
11
+ // rewrites the INPUT of a command whose own concise flag produces
12
+ // equivalent information in less text.
13
+ //
14
+ // Deliberately small and conservative, not exhaustive: every rule here is
15
+ // a pure reformat (git status --porcelain carries the same file-state
16
+ // information as the verbose default, just machine-shaped) or a noise
17
+ // reduction of genuinely redundant detail (pytest's -q drops per-test
18
+ // PASSED lines, not failure detail) - never a rule that could cause an
19
+ // agent to act on less information than it would have had. A rule that
20
+ // trades size for real information loss (git log --oneline drops full
21
+ // commit messages and authorship) does NOT belong here even though it
22
+ // would shrink output - see docs/superpowers/specs for the fuller set of
23
+ // commands considered and explicitly excluded for that reason.
24
+ //
25
+ // Pure and synchronous on purpose: called from pretooluse-hook.js's hot
26
+ // path, which must fail open fast (see that file's own docstring) - no
27
+ // network calls, no I/O, nothing that can hang or throw asynchronously.
28
+
29
+ // Each rule: `test(command)` returns true if this rule applies; `rewrite`
30
+ // returns the new command string. Order matters - first match wins.
31
+ const _RULES = [
32
+ {
33
+ name: 'git-status-porcelain',
34
+ // Bare `git status`, optionally with a pathspec, but not already
35
+ // carrying its own format flag - regex, not a shell parser, so
36
+ // deliberately narrow: only matches when `git status` is the whole
37
+ // command (optionally followed by plain word/path arguments), never
38
+ // inside a compound command (&&, ;, |) where naive rewriting could
39
+ // easily target the wrong part of the line. Missing a rewrite
40
+ // opportunity is harmless; rewriting the wrong thing is not.
41
+ test(command) {
42
+ return /^git\s+status(\s+[^&;|]*)?$/.test(command.trim())
43
+ && !/(--porcelain|--short|-s\b|--long)/.test(command);
44
+ },
45
+ rewrite(command) {
46
+ return `${command.trim()} --porcelain=v1`;
47
+ },
48
+ },
49
+ {
50
+ name: 'pytest-quiet',
51
+ // Bare `pytest`/`python -m pytest`, not already carrying its own
52
+ // verbosity flag. -q drops the per-test PASSED noise; failure output
53
+ // (the only detail an agent actually needs to act on) is unaffected.
54
+ test(command) {
55
+ return /^(python[3]?\s+-m\s+)?pytest(\s+[^&;|]*)?$/.test(command.trim())
56
+ && !/(-q\b|--quiet|-v\b|--verbose|-vv)/.test(command);
57
+ },
58
+ rewrite(command) {
59
+ return `${command.trim()} -q`;
60
+ },
61
+ },
62
+ ];
63
+
64
+ /**
65
+ * Returns a rewritten command string if a rule applies, or null if none do
66
+ * (including on any internal error - never throws, this is a pure
67
+ * best-effort optimization, not a correctness requirement).
68
+ */
69
+ export function compactBashCommand(command) {
70
+ if (typeof command !== 'string' || !command.trim()) return null;
71
+ try {
72
+ for (const rule of _RULES) {
73
+ if (rule.test(command)) {
74
+ // No "is the rewrite shorter" check here on purpose: the command
75
+ // STRING itself is expected to grow (`--porcelain=v1` adds
76
+ // characters to the input), what shrinks is the tool's future
77
+ // OUTPUT once it runs - the size that actually matters, and the
78
+ // one thing this synchronous, pre-execution hook can never
79
+ // observe. The real "never worse than raw" guarantee here is that
80
+ // every rule is a hand-picked, known-safe flag on a real command
81
+ // (see the module docstring) - not something to (mis)approximate
82
+ // by comparing command lengths, which was tried and removed after
83
+ // review: it measured an unrelated quantity and would have passed
84
+ // or failed by accident, never by relevance.
85
+ return rule.rewrite(command) || null;
86
+ }
87
+ }
88
+ } catch {
89
+ return null;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * Entry point for the PreToolUse hook: given the hook's own tool_name and
96
+ * tool_input, returns an `updatedInput` object to merge into
97
+ * hookSpecificOutput, or null if no rewrite applies. Only handles Bash for
98
+ * now - Read/Edit/Write's own large-output problem needs offset/limit
99
+ * changes that risk silently truncating content the agent actually needs
100
+ * (worse than raw, not just no-better), so those are deliberately not
101
+ * covered here; see the module docstring's file-reads note.
102
+ */
103
+ export function updatedInputFor(toolName, toolInput) {
104
+ if (toolName !== 'Bash') return null;
105
+ const command = toolInput?.command;
106
+ const rewritten = compactBashCommand(command);
107
+ return rewritten ? { command: rewritten } : null;
108
+ }
package/src/init.js CHANGED
@@ -9,6 +9,7 @@ import { INTERCEPTOR_PORT } from './daemon.js';
9
9
  import { ensureInterceptionCerts } from './tls-ca.js';
10
10
  import { enabledProviders, interceptHostsFor } from './providers.js';
11
11
  import { probePort } from './proxy.js';
12
+ import { launchdUserDomain } from './launchd.js';
12
13
 
13
14
  // Re-point every consumer of the API key (MCP client configs, the background service /
14
15
  // systemd env file / launchd plist, OpenClaw) at `key`. Shared by runInit and rotate-key
@@ -467,8 +468,12 @@ WantedBy=multi-user.target
467
468
  // owner only. launchd runs as this same user and can still read it.
468
469
  fs.writeFileSync(plistPath, plist, { mode: 0o600 });
469
470
  fs.chmodSync(plistPath, 0o600);
470
- try { execSync(`launchctl unload ${plistPath} 2>/dev/null`); } catch {}
471
- execSync(`launchctl load ${plistPath}`);
471
+ // bootstrap/bootout with an explicit domain, not load/unload - see
472
+ // launchd.js's own docstring for why plain load/unload breaks when
473
+ // this whole process is running as root (e.g. `sudo troxy update`).
474
+ const domain = launchdUserDomain();
475
+ try { execSync(`launchctl bootout ${domain} ${plistPath} 2>/dev/null`); } catch {}
476
+ execSync(`launchctl bootstrap ${domain} ${plistPath}`);
472
477
 
473
478
  } else {
474
479
  throw new Error('Auto-start not supported on this platform. Start manually: troxy mcp &');
package/src/launchd.js ADDED
@@ -0,0 +1,44 @@
1
+ // macOS launchd domain-target helper, shared by init.js's installService and
2
+ // uninstall.js's removeService.
3
+ //
4
+ // Found live 2026-09-12: `sudo troxy update` - the CLI's own suggested
5
+ // remedy for a permission-denied global npm install (see bin/troxy.js's
6
+ // EACCES handling) - runs the whole re-provisioning flow, including the
7
+ // LaunchAgent (re)install, as root. The old code called plain `launchctl
8
+ // load <plist>` / `launchctl unload <plist>` regardless of who's actually
9
+ // running it. launchctl's classic load/unload verbs infer the target
10
+ // domain from the CALLING process's own identity - called as root, they
11
+ // assume you mean a system-wide LaunchDaemon (/Library/LaunchDaemons), and
12
+ // refuse a per-user LaunchAgent plist outright: "Expecting a LaunchDaemons
13
+ // path since the command was run as root... Load failed: 5: Input/output
14
+ // error". A LaunchAgent is inherently a per-user concept - there is no
15
+ // version of this that's actually meant to run as root.
16
+ //
17
+ // The fix launchctl's own warning names is bootstrap/bootout, which take
18
+ // an EXPLICIT target domain instead of inferring one from the caller's
19
+ // identity - root can bootstrap a job into another user's domain by
20
+ // design (this is the standard, supported way for a root-context script
21
+ // to manage a per-user service), and a normal, unprivileged user can
22
+ // bootstrap into their own domain the same way. Works correctly either
23
+ // way, unlike load/unload.
24
+ //
25
+ // user/<uid>, not gui/<uid>: troxy's daemon needs no GUI/Aqua session, and
26
+ // troxy explicitly targets "your laptop, a server, a VM" (see the CLI's
27
+ // own README) - a server or VM may have no GUI login at all, where
28
+ // gui/<uid> would fail outright. user/<uid> exists as soon as the user is
29
+ // logged in at all, GUI or headless SSH-only alike.
30
+
31
+ /**
32
+ * The launchd domain-target to bootstrap/bootout ai.troxy.mcp.plist into -
33
+ * correct whether or not the calling process itself is root.
34
+ *
35
+ * `env.SUDO_UID` is what `sudo` itself sets to the REAL invoking user, not
36
+ * the elevated one - `process.getuid()` is 0 (root) for the whole process
37
+ * once elevated, which is exactly the case this exists to handle. Falls
38
+ * back to `getuid()` for the normal, non-sudo case, where that call
39
+ * already returns the real, correct user.
40
+ */
41
+ export function launchdUserDomain(env = process.env, getuid = () => process.getuid?.()) {
42
+ const uid = env.SUDO_UID || String(getuid() ?? '');
43
+ return `user/${uid}`;
44
+ }
@@ -1,6 +1,7 @@
1
1
  import { loadConfig } from './config.js';
2
2
  import { api } from './api.js';
3
3
  import { usageForCurrentTurn } from './hook-report.js';
4
+ import { updatedInputFor } from './command-compaction.js';
4
5
 
5
6
  // Claude Code PreToolUse hook entry point: `troxy pretooluse-hook` (wired in
6
7
  // bin/troxy.js, registered by patchClaudeCodeHooks in init.js). Fires before
@@ -56,7 +57,29 @@ export function buildDenyPayload(reason) {
56
57
  };
57
58
  }
58
59
 
59
- function allow() {
60
+ // Same reasoning as buildDenyPayload above: exported and pure so the exact
61
+ // contract shape is testable without triggering allow()'s process.exit.
62
+ export function buildUpdatedInputPayload(updatedInput) {
63
+ return {
64
+ hookSpecificOutput: {
65
+ hookEventName: 'PreToolUse',
66
+ updatedInput,
67
+ },
68
+ };
69
+ }
70
+
71
+ // updatedInput: Token Optimization Step 4 - a command-rewrite from
72
+ // command-compaction.js, independent of and unrelated to the policy
73
+ // decision below (see that module's own docstring). Emitted alongside a
74
+ // plain allow (no permissionDecision at all - `undefined`/absent means
75
+ // "the normal permission flow applies", same as today's exit-0-with-
76
+ // nothing-printed behavior when there's no rewrite either), never mixed
77
+ // with a deny: a call about to be blocked doesn't need its command
78
+ // rewritten first.
79
+ function allow(updatedInput) {
80
+ if (updatedInput) {
81
+ process.stdout.write(JSON.stringify(buildUpdatedInputPayload(updatedInput)));
82
+ }
60
83
  process.exit(0);
61
84
  }
62
85
 
@@ -75,8 +98,16 @@ export async function runPreToolUseHook() {
75
98
  return allow(); // can't parse our own input - never block on that
76
99
  }
77
100
 
101
+ // Independent of everything below: a pure, local, synchronous command
102
+ // rewrite (Token Optimization Step 4) that applies (or doesn't)
103
+ // regardless of whether the policy check below even runs - it needs no
104
+ // API key, no transcript, no network call, so it's computed once, up
105
+ // front, and threaded through every allow() below rather than gated
106
+ // behind the same fail-open checks the policy path needs.
107
+ const rewrite = updatedInputFor(payload?.tool_name, payload?.tool_input);
108
+
78
109
  const transcriptPath = payload?.transcript_path;
79
- if (!transcriptPath) return allow();
110
+ if (!transcriptPath) return allow(rewrite);
80
111
 
81
112
  // transcript_path can lag the current turn by a beat (Claude Code's own
82
113
  // docs note this), so this may occasionally read the previous turn's
@@ -85,11 +116,11 @@ export async function runPreToolUseHook() {
85
116
  // not a wrong-direction failure (never wrongly denies because of it),
86
117
  // just an occasional miss, consistent with fail-open throughout this file.
87
118
  const usage = usageForCurrentTurn(transcriptPath);
88
- if (!usage || !usage.model) return allow(); // nothing to gate against yet
119
+ if (!usage || !usage.model) return allow(rewrite); // nothing to gate against yet
89
120
 
90
121
  const config = loadConfig();
91
122
  const apiKey = process.env.TROXY_API_KEY || config?.apiKey;
92
- if (!apiKey) return allow();
123
+ if (!apiKey) return allow(rewrite);
93
124
 
94
125
  const body = { model: usage.model, task: payload.tool_name || '' };
95
126
  // Real effort straight off the transcript entry (see hook-report.js's
@@ -120,7 +151,7 @@ export async function runPreToolUseHook() {
120
151
  // nowhere here to pause and wait on an approval), so it fails open
121
152
  // rather than hanging Claude Code - same reasoning as every other
122
153
  // "never block on our own uncertainty" branch in this file.
123
- return allow();
154
+ return allow(rewrite);
124
155
  } catch {
125
156
  // Network error, timeout, malformed response - never block the user's
126
157
  // session over our own failure.
@@ -0,0 +1,106 @@
1
+ // Pure logic only - no hook plumbing here, see pretooluse-hook.test.js for
2
+ // how updatedInputFor's result actually reaches Claude Code's own
3
+ // hookSpecificOutput contract.
4
+
5
+ import { describe, it } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+
8
+ import { compactBashCommand, updatedInputFor } from '../command-compaction.js';
9
+
10
+ describe('compactBashCommand - git status', () => {
11
+ it('rewrites bare `git status` to porcelain form', () => {
12
+ assert.equal(compactBashCommand('git status'), 'git status --porcelain=v1');
13
+ });
14
+
15
+ it('rewrites `git status` with a pathspec', () => {
16
+ assert.equal(compactBashCommand('git status src/'), 'git status src/ --porcelain=v1');
17
+ });
18
+
19
+ it('does not double-rewrite a command already using --porcelain', () => {
20
+ assert.equal(compactBashCommand('git status --porcelain'), null);
21
+ });
22
+
23
+ it('does not rewrite a command already using -s/--short', () => {
24
+ assert.equal(compactBashCommand('git status -s'), null);
25
+ assert.equal(compactBashCommand('git status --short'), null);
26
+ });
27
+
28
+ it('does not touch git status inside a compound command', () => {
29
+ // Deliberately conservative - see the module docstring on why a
30
+ // missed rewrite is fine but a wrong one isn't.
31
+ assert.equal(compactBashCommand('git status && git log'), null);
32
+ assert.equal(compactBashCommand('git status; echo done'), null);
33
+ assert.equal(compactBashCommand('git status | cat'), null);
34
+ });
35
+
36
+ it('does not touch an unrelated command containing the word status', () => {
37
+ assert.equal(compactBashCommand('echo "git status is useful"'), null);
38
+ });
39
+ });
40
+
41
+ describe('compactBashCommand - pytest', () => {
42
+ it('rewrites bare `pytest` to add -q', () => {
43
+ assert.equal(compactBashCommand('pytest'), 'pytest -q');
44
+ });
45
+
46
+ it('rewrites `pytest` with a path argument', () => {
47
+ assert.equal(compactBashCommand('pytest tests/'), 'pytest tests/ -q');
48
+ });
49
+
50
+ it('rewrites `python -m pytest`', () => {
51
+ assert.equal(compactBashCommand('python -m pytest'), 'python -m pytest -q');
52
+ assert.equal(compactBashCommand('python3 -m pytest'), 'python3 -m pytest -q');
53
+ });
54
+
55
+ it('does not double-rewrite a command already carrying a verbosity flag', () => {
56
+ assert.equal(compactBashCommand('pytest -q'), null);
57
+ assert.equal(compactBashCommand('pytest -v'), null);
58
+ assert.equal(compactBashCommand('pytest --verbose'), null);
59
+ assert.equal(compactBashCommand('pytest --quiet'), null);
60
+ });
61
+ });
62
+
63
+ describe('compactBashCommand - unmatched input', () => {
64
+ it('returns null for a command with no matching rule', () => {
65
+ assert.equal(compactBashCommand('ls -la'), null);
66
+ assert.equal(compactBashCommand('git diff'), null);
67
+ assert.equal(compactBashCommand('git log'), null);
68
+ });
69
+
70
+ it('never throws on malformed input', () => {
71
+ assert.doesNotThrow(() => compactBashCommand(null));
72
+ assert.doesNotThrow(() => compactBashCommand(undefined));
73
+ assert.doesNotThrow(() => compactBashCommand(42));
74
+ assert.doesNotThrow(() => compactBashCommand(''));
75
+ assert.doesNotThrow(() => compactBashCommand(' '));
76
+ });
77
+
78
+ it('returns null, not a throw, for non-string input', () => {
79
+ assert.equal(compactBashCommand(null), null);
80
+ assert.equal(compactBashCommand(undefined), null);
81
+ assert.equal(compactBashCommand(42), null);
82
+ });
83
+ });
84
+
85
+ describe('updatedInputFor', () => {
86
+ it('returns an updatedInput object for a rewritable Bash command', () => {
87
+ assert.deepEqual(updatedInputFor('Bash', { command: 'git status' }), {
88
+ command: 'git status --porcelain=v1',
89
+ });
90
+ });
91
+
92
+ it('returns null for a non-Bash tool', () => {
93
+ assert.equal(updatedInputFor('Read', { file_path: '/tmp/x' }), null);
94
+ assert.equal(updatedInputFor('Edit', { command: 'git status' }), null);
95
+ });
96
+
97
+ it('returns null for a Bash command with no matching rule', () => {
98
+ assert.equal(updatedInputFor('Bash', { command: 'ls -la' }), null);
99
+ });
100
+
101
+ it('never throws on a malformed tool_input', () => {
102
+ assert.doesNotThrow(() => updatedInputFor('Bash', null));
103
+ assert.doesNotThrow(() => updatedInputFor('Bash', {}));
104
+ assert.doesNotThrow(() => updatedInputFor(null, null));
105
+ });
106
+ });
@@ -59,11 +59,14 @@ describe('installService: launchd log capture (source inspection)', () => {
59
59
  });
60
60
 
61
61
  it('creates the ~/.troxy log directory before the plist is loaded', () => {
62
+ // 'launchctl bootstrap', not 'launchctl load' - see launchd.test.js for
63
+ // why the verb changed (bootstrap/bootout with an explicit domain,
64
+ // needed so this doesn't break under `sudo troxy update`).
62
65
  const darwinIdx = initSrc.indexOf("platform === 'darwin'");
63
66
  const troxyDirIdx = initSrc.indexOf('const troxyDir', darwinIdx);
64
67
  const mkdirIdx = initSrc.indexOf('fs.mkdirSync(troxyDir', darwinIdx);
65
- const loadIdx = initSrc.indexOf('launchctl load', darwinIdx);
68
+ const loadIdx = initSrc.indexOf('launchctl bootstrap', darwinIdx);
66
69
  assert.ok(troxyDirIdx !== -1 && mkdirIdx !== -1 && loadIdx !== -1);
67
- assert.ok(mkdirIdx < loadIdx, 'the log directory must be created before launchctl load runs');
70
+ assert.ok(mkdirIdx < loadIdx, 'the log directory must be created before launchctl bootstrap runs');
68
71
  });
69
72
  });
@@ -0,0 +1,70 @@
1
+ // Found live 2026-09-12: `sudo troxy update` (the CLI's own suggested
2
+ // remedy for a permission-denied global npm install) runs the whole
3
+ // re-provisioning flow as root, including the LaunchAgent (re)install.
4
+ // launchctl's classic load/unload verbs infer their target domain from the
5
+ // CALLING process's own identity - called as root, they assume a system
6
+ // LaunchDaemon and refuse a per-user LaunchAgent outright ("Expecting a
7
+ // LaunchDaemons path... Load failed: 5: Input/output error"). See
8
+ // launchd.js's own docstring for the full reasoning and the fix
9
+ // (bootstrap/bootout with an explicit domain instead).
10
+
11
+ import { describe, it } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { readFileSync } from 'node:fs';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dirname, join } from 'node:path';
16
+
17
+ import { launchdUserDomain } from '../launchd.js';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const initSrc = readFileSync(join(__dirname, '..', 'init.js'), 'utf8');
21
+ const uninstallSrc = readFileSync(join(__dirname, '..', 'uninstall.js'), 'utf8');
22
+
23
+ describe('launchdUserDomain', () => {
24
+ it('uses SUDO_UID when running under sudo, ignoring the elevated (root) uid', () => {
25
+ const domain = launchdUserDomain({ SUDO_UID: '501' }, () => 0);
26
+ assert.equal(domain, 'user/501');
27
+ });
28
+
29
+ it('falls back to the caller\'s own uid when not running under sudo', () => {
30
+ const domain = launchdUserDomain({}, () => 501);
31
+ assert.equal(domain, 'user/501');
32
+ });
33
+
34
+ it('prefers SUDO_UID even if somehow both are present', () => {
35
+ const domain = launchdUserDomain({ SUDO_UID: '501' }, () => 0);
36
+ assert.notEqual(domain, 'user/0');
37
+ });
38
+
39
+ it('never throws when getuid is unavailable (e.g. non-POSIX platform)', () => {
40
+ assert.doesNotThrow(() => launchdUserDomain({}, () => undefined));
41
+ });
42
+ });
43
+
44
+ describe('installService / removeService: bootstrap-not-load (source inspection)', () => {
45
+ it('init.js darwin branch uses bootstrap/bootout, not the old load/unload verbs', () => {
46
+ const darwinIdx = initSrc.indexOf("platform === 'darwin'");
47
+ assert.ok(darwinIdx !== -1, 'darwin branch not found');
48
+ const darwinBlock = initSrc.slice(darwinIdx, initSrc.indexOf('function mcpEntry'));
49
+ assert.match(darwinBlock, /launchctl bootstrap/);
50
+ assert.match(darwinBlock, /launchctl bootout/);
51
+ assert.doesNotMatch(darwinBlock, /launchctl load \$\{plistPath\}/);
52
+ assert.doesNotMatch(darwinBlock, /launchctl unload \$\{plistPath\}/);
53
+ });
54
+
55
+ it('init.js passes an explicit domain to both bootstrap and bootout', () => {
56
+ const darwinIdx = initSrc.indexOf("platform === 'darwin'");
57
+ const darwinBlock = initSrc.slice(darwinIdx, initSrc.indexOf('function mcpEntry'));
58
+ assert.match(darwinBlock, /launchdUserDomain\(\)/);
59
+ assert.match(darwinBlock, /launchctl bootstrap \$\{domain\}/);
60
+ assert.match(darwinBlock, /launchctl bootout \$\{domain\}/);
61
+ });
62
+
63
+ it('uninstall.js darwin branch uses bootout, not the old unload verb', () => {
64
+ const darwinIdx = uninstallSrc.indexOf("platform === 'darwin'");
65
+ assert.ok(darwinIdx !== -1, 'darwin branch not found in uninstall.js');
66
+ const darwinBlock = uninstallSrc.slice(darwinIdx, darwinIdx + 400);
67
+ assert.match(darwinBlock, /launchctl bootout \$\{launchdUserDomain\(\)\}/);
68
+ assert.doesNotMatch(darwinBlock, /launchctl unload \$\{plistPath\}/);
69
+ });
70
+ });
@@ -14,7 +14,8 @@
14
14
  import { describe, it } from 'node:test';
15
15
  import assert from 'node:assert/strict';
16
16
 
17
- import { buildDenyPayload } from '../pretooluse-hook.js';
17
+ import { buildDenyPayload, buildUpdatedInputPayload } from '../pretooluse-hook.js';
18
+ import { updatedInputFor } from '../command-compaction.js';
18
19
 
19
20
  describe('buildDenyPayload', () => {
20
21
  it('matches the exact PreToolUse hookSpecificOutput contract', () => {
@@ -40,3 +41,41 @@ describe('buildDenyPayload', () => {
40
41
  );
41
42
  });
42
43
  });
44
+
45
+ // Token Optimization Step 4: the command-rewrite path is independent of the
46
+ // policy-check path above (see pretooluse-hook.js's own comment on why
47
+ // updatedInput is computed once, up front, and threaded through every
48
+ // allow() branch) - these tests cover just the contract shape
49
+ // buildUpdatedInputPayload produces, same "pure and testable without
50
+ // process.exit" reasoning as buildDenyPayload's own tests above.
51
+ describe('buildUpdatedInputPayload', () => {
52
+ it('matches the exact PreToolUse hookSpecificOutput contract for updatedInput', () => {
53
+ const payload = buildUpdatedInputPayload({ command: 'git status --porcelain=v1' });
54
+ assert.deepEqual(payload, {
55
+ hookSpecificOutput: {
56
+ hookEventName: 'PreToolUse',
57
+ updatedInput: { command: 'git status --porcelain=v1' },
58
+ },
59
+ });
60
+ });
61
+
62
+ it('carries no permissionDecision field - a plain allow, not a decision', () => {
63
+ const payload = buildUpdatedInputPayload({ command: 'pytest -q' });
64
+ assert.equal(payload.hookSpecificOutput.permissionDecision, undefined);
65
+ });
66
+
67
+ it('is valid JSON when stringified', () => {
68
+ assert.doesNotThrow(() => JSON.stringify(buildUpdatedInputPayload({ command: 'git status --porcelain=v1' })));
69
+ });
70
+ });
71
+
72
+ describe('updatedInputFor wired to real hook payload shapes', () => {
73
+ it('produces a rewrite for a real Bash tool_use payload shape', () => {
74
+ const rewrite = updatedInputFor('Bash', { command: 'git status', description: 'Check status' });
75
+ assert.deepEqual(rewrite, { command: 'git status --porcelain=v1' });
76
+ });
77
+
78
+ it('produces nothing for a tool this hook does not rewrite', () => {
79
+ assert.equal(updatedInputFor('Read', { file_path: '/tmp/x.py' }), null);
80
+ });
81
+ });
package/src/uninstall.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  unpatchClaudeCodeConfig, unpatchClaudeCodeHooks,
11
11
  unpatchClaudeCodeProxy, unpatchClaudeCodeInterception,
12
12
  } from './init.js';
13
+ import { launchdUserDomain } from './launchd.js';
13
14
 
14
15
  const MCP_CLIENTS = [
15
16
  {
@@ -57,7 +58,9 @@ function removeService() {
57
58
  } else if (platform === 'darwin') {
58
59
  const plistPath = path.join(os.homedir(), 'Library/LaunchAgents/ai.troxy.mcp.plist');
59
60
  if (fs.existsSync(plistPath)) {
60
- try { execSync(`launchctl unload ${plistPath} 2>/dev/null`); } catch {}
61
+ // bootout with an explicit domain, not unload - see launchd.js's
62
+ // docstring for why plain unload breaks under `sudo troxy uninstall`.
63
+ try { execSync(`launchctl bootout ${launchdUserDomain()} ${plistPath} 2>/dev/null`); } catch {}
61
64
  fs.unlinkSync(plistPath);
62
65
  }
63
66
  return true;