troxy-cli 1.29.6 → 1.29.8

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.6",
3
+ "version": "1.29.8",
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,8 @@ 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';
13
+ import { reclaimOwnership } from './sudo-ownership.js';
12
14
 
13
15
  // Re-point every consumer of the API key (MCP client configs, the background service /
14
16
  // systemd env file / launchd plist, OpenClaw) at `key`. Shared by runInit and rotate-key
@@ -141,6 +143,19 @@ export async function reprovisionKeyConsumers(key, agentName, proxyOptIn = null)
141
143
  console.log(` Background service ✗ (${err.message})`);
142
144
  console.log(' You can start it manually with: troxy daemon &');
143
145
  }
146
+
147
+ // Sudo safety net, not part of the normal (unprivileged) flow's own
148
+ // correctness - see sudo-ownership.js's own docstring. A no-op unless
149
+ // this whole process is running elevated via sudo (e.g. `sudo troxy
150
+ // update`, the CLI's own suggested remedy for a permission-denied
151
+ // global npm install), in which case everything reprovisionKeyConsumers
152
+ // just wrote under ~/.troxy (config, TLS interception certs) and the
153
+ // LaunchAgent plist would otherwise be left root-owned - unreadable by
154
+ // the daemon, which always runs as the normal user.
155
+ reclaimOwnership([
156
+ path.join(os.homedir(), '.troxy'),
157
+ path.join(os.homedir(), 'Library/LaunchAgents/ai.troxy.mcp.plist'),
158
+ ]);
144
159
  }
145
160
 
146
161
  const AGENTS_START = '<!-- troxy:start -->';
@@ -467,8 +482,12 @@ WantedBy=multi-user.target
467
482
  // owner only. launchd runs as this same user and can still read it.
468
483
  fs.writeFileSync(plistPath, plist, { mode: 0o600 });
469
484
  fs.chmodSync(plistPath, 0o600);
470
- try { execSync(`launchctl unload ${plistPath} 2>/dev/null`); } catch {}
471
- execSync(`launchctl load ${plistPath}`);
485
+ // bootstrap/bootout with an explicit domain, not load/unload - see
486
+ // launchd.js's own docstring for why plain load/unload breaks when
487
+ // this whole process is running as root (e.g. `sudo troxy update`).
488
+ const domain = launchdUserDomain();
489
+ try { execSync(`launchctl bootout ${domain} ${plistPath} 2>/dev/null`); } catch {}
490
+ execSync(`launchctl bootstrap ${domain} ${plistPath}`);
472
491
 
473
492
  } else {
474
493
  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
+ }
@@ -0,0 +1,81 @@
1
+ // Found live 2026-09-12, a broader version of the incident launchd.js fixes
2
+ // (same trigger, different subsystem): `sudo troxy update` - the CLI's own
3
+ // suggested remedy for a permission-denied global npm install (see
4
+ // bin/troxy.js's EACCES handling) - runs the WHOLE process as root. In this
5
+ // user's sudo session $HOME was preserved (not reset to root's own home),
6
+ // so every path resolved correctly to the real user's ~/.troxy - but every
7
+ // file written there (most importantly the TLS interception certs
8
+ // tls-ca.js generates, mode 0600/0700) was still created ROOT-OWNED,
9
+ // because the writing PROCESS's effective UID was 0, regardless of which
10
+ // directory it happened to be writing into.
11
+ //
12
+ // The daemon later runs as the normal user (by design - see
13
+ // installService's own plist, which never sets a UserName key, so launchd
14
+ // runs it as whoever loaded it) and cannot read 0600/0700 files it
15
+ // doesn't own. Real, observed failure: the local TLS-intercepting proxy
16
+ // silently failed to bind at all, and every Anthropic call configured to
17
+ // route through it got a bare connection-refused - completely invisible
18
+ // until someone went looking, because installService's own try/catch
19
+ // only ever sees "the daemon started", never "the daemon can actually do
20
+ // its job". The user's own fix was `sudo chown -R` back to themselves;
21
+ // this reclaims that automatically instead of requiring it after the
22
+ // fact.
23
+
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+
27
+ /**
28
+ * {uid, gid} of the user sudo invoked as - SUDO_UID/SUDO_GID are what sudo
29
+ * itself sets to the REAL invoking user, never the elevated (0/root) one
30
+ * process.getuid() reports once elevated. Returns null when this process
31
+ * isn't running elevated via sudo at all: a normal, unprivileged run (the
32
+ * overwhelmingly common case, where nothing here needs to do anything),
33
+ * or root running with no sudo wrapper and thus no real user on record to
34
+ * hand ownership back to - never guessed at.
35
+ */
36
+ export function realUserIds(env = process.env, getuid = () => process.getuid?.()) {
37
+ if (getuid() !== 0) return null;
38
+ if (!env.SUDO_UID) return null;
39
+ const uid = Number(env.SUDO_UID);
40
+ const gid = Number(env.SUDO_GID ?? env.SUDO_UID);
41
+ if (!Number.isInteger(uid) || !Number.isInteger(gid)) return null;
42
+ return { uid, gid };
43
+ }
44
+
45
+ function _chownRecursive(target, uid, gid) {
46
+ let stat;
47
+ try {
48
+ stat = fs.lstatSync(target);
49
+ } catch {
50
+ return; // doesn't exist - nothing to reclaim
51
+ }
52
+ try { fs.chownSync(target, uid, gid); } catch { /* best-effort, see below */ }
53
+ if (stat.isDirectory()) {
54
+ let entries = [];
55
+ try { entries = fs.readdirSync(target); } catch { return; }
56
+ for (const entry of entries) _chownRecursive(path.join(target, entry), uid, gid);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Recursively reclaims ownership of every given path back to the real
62
+ * user, when (and only when) this process is running elevated via sudo -
63
+ * a complete no-op otherwise. Never throws: every failure mode here
64
+ * (a path that doesn't exist, a permission quirk on one file deep in a
65
+ * tree) is worth trying the next path over, never worth failing the
66
+ * command that already did its real job. This is purely defensive
67
+ * cleanup for the sudo case; it is never load-bearing for the normal,
68
+ * far more common unprivileged run.
69
+ */
70
+ export function reclaimOwnership(paths, env = process.env, getuid = () => process.getuid?.()) {
71
+ const real = realUserIds(env, getuid);
72
+ if (!real) return;
73
+ for (const p of paths) {
74
+ try {
75
+ _chownRecursive(p, real.uid, real.gid);
76
+ } catch {
77
+ // Never let reclaiming ownership of one path stop another, or
78
+ // surface as a failure of whatever real operation just ran.
79
+ }
80
+ }
81
+ }
@@ -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
+ });
@@ -0,0 +1,104 @@
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. Every file it wrote under ~/.troxy - most
4
+ // importantly the TLS interception certs - was created root-owned even
5
+ // though the path correctly resolved to the real user's home. The daemon
6
+ // (always run as the normal user) couldn't read its own 0600/0700 cert
7
+ // files afterward, and the local interceptor silently failed to bind -
8
+ // every Anthropic call routed through it got connection-refused. See
9
+ // sudo-ownership.js's own docstring for the full incident and fix.
10
+
11
+ import { describe, it, beforeEach, afterEach } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { readFileSync } from 'node:fs';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { dirname, join } from 'node:path';
19
+
20
+ import { realUserIds, reclaimOwnership } from '../sudo-ownership.js';
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const initSrc = readFileSync(join(__dirname, '..', 'init.js'), 'utf8');
24
+
25
+ describe('realUserIds', () => {
26
+ it('returns the real uid/gid when running elevated via sudo', () => {
27
+ const ids = realUserIds({ SUDO_UID: '501', SUDO_GID: '20' }, () => 0);
28
+ assert.deepEqual(ids, { uid: 501, gid: 20 });
29
+ });
30
+
31
+ it('falls back to SUDO_UID for the gid when SUDO_GID is absent', () => {
32
+ const ids = realUserIds({ SUDO_UID: '501' }, () => 0);
33
+ assert.deepEqual(ids, { uid: 501, gid: 501 });
34
+ });
35
+
36
+ it('returns null when not running as root at all - nothing to reclaim', () => {
37
+ assert.equal(realUserIds({ SUDO_UID: '501' }, () => 501), null);
38
+ });
39
+
40
+ it('returns null when running as root with no sudo wrapper - no real user on record', () => {
41
+ assert.equal(realUserIds({}, () => 0), null);
42
+ });
43
+
44
+ it('never throws when getuid is unavailable', () => {
45
+ assert.doesNotThrow(() => realUserIds({}, () => undefined));
46
+ });
47
+ });
48
+
49
+ describe('reclaimOwnership', () => {
50
+ let dir;
51
+ beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-reclaim-')); });
52
+ afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
53
+
54
+ it('is a complete no-op when not running elevated via sudo', () => {
55
+ const target = path.join(dir, 'file.txt');
56
+ fs.writeFileSync(target, 'x');
57
+ const before = fs.statSync(target);
58
+ assert.doesNotThrow(() => reclaimOwnership([target], {}, () => before.uid));
59
+ // Unchanged - never even attempts a chown outside the sudo case.
60
+ assert.equal(fs.statSync(target).uid, before.uid);
61
+ });
62
+
63
+ it('never throws for a path that does not exist', () => {
64
+ assert.doesNotThrow(() =>
65
+ reclaimOwnership([path.join(dir, 'nope')], { SUDO_UID: '501' }, () => 0));
66
+ });
67
+
68
+ it('never throws when chownSync itself would fail (e.g. no real permission in this test env)', () => {
69
+ // This test process is almost never actually root, so a real chown to
70
+ // an arbitrary uid will fail - the whole point is that reclaimOwnership
71
+ // swallows that rather than throwing, matching this file's "best-effort,
72
+ // never blocks the real operation" contract.
73
+ const target = path.join(dir, 'file.txt');
74
+ fs.writeFileSync(target, 'x');
75
+ assert.doesNotThrow(() =>
76
+ reclaimOwnership([target], { SUDO_UID: '501', SUDO_GID: '20' }, () => 0));
77
+ });
78
+
79
+ it('walks into a directory when given one, not just its top level', () => {
80
+ const sub = path.join(dir, 'sub');
81
+ fs.mkdirSync(sub);
82
+ fs.writeFileSync(path.join(sub, 'nested.txt'), 'x');
83
+ // Same "must not throw even though this test isn't really root" shape
84
+ // as above - the real assertion is that recursion happens at all,
85
+ // which a thrown error partway through would otherwise mask.
86
+ assert.doesNotThrow(() =>
87
+ reclaimOwnership([dir], { SUDO_UID: '501', SUDO_GID: '20' }, () => 0));
88
+ });
89
+ });
90
+
91
+ describe('reprovisionKeyConsumers: reclaims ownership after sudo (source inspection)', () => {
92
+ it('calls reclaimOwnership after installService, covering ~/.troxy and the LaunchAgent plist', () => {
93
+ const fnStart = initSrc.indexOf('export async function reprovisionKeyConsumers');
94
+ assert.ok(fnStart !== -1, 'reprovisionKeyConsumers not found');
95
+ const installIdx = initSrc.indexOf('installService(key, agentName)', fnStart);
96
+ const reclaimIdx = initSrc.indexOf('reclaimOwnership(', fnStart);
97
+ assert.ok(installIdx !== -1 && reclaimIdx !== -1);
98
+ assert.ok(reclaimIdx > installIdx,
99
+ 'ownership must be reclaimed after installService has written the plist, not before');
100
+ const fnBody = initSrc.slice(fnStart, reclaimIdx + 400);
101
+ assert.match(fnBody, /\.troxy/);
102
+ assert.match(fnBody, /LaunchAgents\/ai\.troxy\.mcp\.plist/);
103
+ });
104
+ });
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;