troxy-cli 1.29.7 → 1.29.9
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 +14 -0
- package/src/launchd.js +16 -6
- package/src/sudo-ownership.js +81 -0
- package/src/tests/launchd.test.js +3 -3
- package/src/tests/sudo-ownership.test.js +104 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.29.
|
|
3
|
+
"version": "1.29.9",
|
|
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
|
@@ -10,6 +10,7 @@ import { ensureInterceptionCerts } from './tls-ca.js';
|
|
|
10
10
|
import { enabledProviders, interceptHostsFor } from './providers.js';
|
|
11
11
|
import { probePort } from './proxy.js';
|
|
12
12
|
import { launchdUserDomain } from './launchd.js';
|
|
13
|
+
import { reclaimOwnership } from './sudo-ownership.js';
|
|
13
14
|
|
|
14
15
|
// Re-point every consumer of the API key (MCP client configs, the background service /
|
|
15
16
|
// systemd env file / launchd plist, OpenClaw) at `key`. Shared by runInit and rotate-key
|
|
@@ -142,6 +143,19 @@ export async function reprovisionKeyConsumers(key, agentName, proxyOptIn = null)
|
|
|
142
143
|
console.log(` Background service ✗ (${err.message})`);
|
|
143
144
|
console.log(' You can start it manually with: troxy daemon &');
|
|
144
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
|
+
]);
|
|
145
159
|
}
|
|
146
160
|
|
|
147
161
|
const AGENTS_START = '<!-- troxy:start -->';
|
package/src/launchd.js
CHANGED
|
@@ -22,11 +22,21 @@
|
|
|
22
22
|
// bootstrap into their own domain the same way. Works correctly either
|
|
23
23
|
// way, unlike load/unload.
|
|
24
24
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
25
|
+
// gui/<uid>, NOT user/<uid> - corrected 2026-09-12 after shipping user/<uid>
|
|
26
|
+
// first and getting this wrong in practice: a real, normal (non-sudo)
|
|
27
|
+
// `troxy update` run then failed with "Bootstrap failed: 5: Input/output
|
|
28
|
+
// error" bootstrapping into user/501. `man launchctl` confirms why - gui
|
|
29
|
+
// and user are DISCRETE domains with their own separate sets of services
|
|
30
|
+
// (not two names for the same thing), and Apple's own canonical example in
|
|
31
|
+
// that same man page loads a per-user service into gui/<uid>, not
|
|
32
|
+
// user/<uid>. The original reasoning for user/<uid> (a server/VM might
|
|
33
|
+
// have no GUI login) doesn't actually apply here: this whole branch is
|
|
34
|
+
// darwin-only, and a Mac running Troxy for Claude Desktop already requires
|
|
35
|
+
// an active GUI session for Claude Desktop itself to run at all - the
|
|
36
|
+
// "headless Mac" case this was trying to additionally support isn't a
|
|
37
|
+
// real configuration for this specific daemon. Lesson: this got shipped
|
|
38
|
+
// on man-page reading plus reasoning about what SHOULD work, not on
|
|
39
|
+
// checking a real bootstrap call - the second attempt fixed that.
|
|
30
40
|
|
|
31
41
|
/**
|
|
32
42
|
* The launchd domain-target to bootstrap/bootout ai.troxy.mcp.plist into -
|
|
@@ -40,5 +50,5 @@
|
|
|
40
50
|
*/
|
|
41
51
|
export function launchdUserDomain(env = process.env, getuid = () => process.getuid?.()) {
|
|
42
52
|
const uid = env.SUDO_UID || String(getuid() ?? '');
|
|
43
|
-
return `
|
|
53
|
+
return `gui/${uid}`;
|
|
44
54
|
}
|
|
@@ -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
|
+
}
|
|
@@ -23,17 +23,17 @@ const uninstallSrc = readFileSync(join(__dirname, '..', 'uninstall.js'), 'utf8')
|
|
|
23
23
|
describe('launchdUserDomain', () => {
|
|
24
24
|
it('uses SUDO_UID when running under sudo, ignoring the elevated (root) uid', () => {
|
|
25
25
|
const domain = launchdUserDomain({ SUDO_UID: '501' }, () => 0);
|
|
26
|
-
assert.equal(domain, '
|
|
26
|
+
assert.equal(domain, 'gui/501');
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
it('falls back to the caller\'s own uid when not running under sudo', () => {
|
|
30
30
|
const domain = launchdUserDomain({}, () => 501);
|
|
31
|
-
assert.equal(domain, '
|
|
31
|
+
assert.equal(domain, 'gui/501');
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
it('prefers SUDO_UID even if somehow both are present', () => {
|
|
35
35
|
const domain = launchdUserDomain({ SUDO_UID: '501' }, () => 0);
|
|
36
|
-
assert.notEqual(domain, '
|
|
36
|
+
assert.notEqual(domain, 'gui/0');
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
it('never throws when getuid is unavailable (e.g. non-POSIX platform)', () => {
|
|
@@ -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
|
+
});
|