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 +1 -1
- package/src/command-compaction.js +108 -0
- package/src/init.js +7 -2
- package/src/launchd.js +44 -0
- package/src/pretooluse-hook.js +36 -5
- package/src/tests/command-compaction.test.js +106 -0
- package/src/tests/install-service.test.js +5 -2
- package/src/tests/launchd.test.js +70 -0
- package/src/tests/pretooluse-hook.test.js +40 -1
- package/src/uninstall.js +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.29.
|
|
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
|
-
|
|
471
|
-
|
|
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
|
+
}
|
package/src/pretooluse-hook.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|