troxy-cli 1.29.6 → 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/init.js +7 -2
- package/src/launchd.js +44 -0
- package/src/tests/install-service.test.js +5 -2
- package/src/tests/launchd.test.js +70 -0
- 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": {
|
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
|
+
}
|
|
@@ -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
|
+
});
|
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;
|