troxy-cli 1.29.8 → 1.29.10
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/bin/troxy.js +42 -2
- package/package.json +1 -1
- package/src/launchd.js +16 -6
- package/src/tests/launchd.test.js +3 -3
- package/src/tests/update-reprovision.test.js +82 -0
package/bin/troxy.js
CHANGED
|
@@ -573,7 +573,7 @@ switch (command) {
|
|
|
573
573
|
// is its picture of which checkpoints exist, and that lives on the server.
|
|
574
574
|
// This is a no-op on a machine with an MCP client, which gets a fresh tool
|
|
575
575
|
// list from the MCP handshake every session anyway.
|
|
576
|
-
const { refreshHostedInstructions
|
|
576
|
+
const { refreshHostedInstructions } = await import('../src/init.js');
|
|
577
577
|
const refresh = async () => {
|
|
578
578
|
try {
|
|
579
579
|
return await refreshHostedInstructions((loadConfig() || {}).apiKey);
|
|
@@ -614,10 +614,38 @@ switch (command) {
|
|
|
614
614
|
// until someone thought to re-run `init` by hand. Best-effort: a
|
|
615
615
|
// failure here must not make `update` itself look like it failed when
|
|
616
616
|
// the actual package upgrade already succeeded.
|
|
617
|
+
//
|
|
618
|
+
// Found live 2026-09-13: this used to call reprovisionKeyConsumers()
|
|
619
|
+
// in-process via a dynamic import of ../src/init.js. That import is
|
|
620
|
+
// resolved by Node's ES module cache, which is keyed by file URL, not
|
|
621
|
+
// re-checked against disk - and this same process already loaded
|
|
622
|
+
// ../src/init.js (transitively, via bin/troxy.js's own top-of-file
|
|
623
|
+
// static imports) back when it STARTED, i.e. running the version
|
|
624
|
+
// being upgraded FROM. The npm install above only changes what's on
|
|
625
|
+
// disk; it can't evict this process's already-cached module graph.
|
|
626
|
+
// So every "provision the just-installed version" step here was
|
|
627
|
+
// actually running the PREVIOUS version's code - invisible unless
|
|
628
|
+
// that code path itself changed between versions, which is exactly
|
|
629
|
+
// what happened with the gui/user launchd domain fix (1.29.9): update
|
|
630
|
+
// correctly installed 1.29.9's files, then reprovisioned using
|
|
631
|
+
// 1.29.8's stale in-memory launchdUserDomain(), reintroducing the
|
|
632
|
+
// bug `troxy update` had just shipped the fix for. A dynamic import,
|
|
633
|
+
// no matter where or how it's written, can't fix this - it needs an
|
|
634
|
+
// actual new OS process, which loads ../src/init.js fresh from the
|
|
635
|
+
// now-updated disk. Hence shelling out to the just-installed binary's
|
|
636
|
+
// own hidden `_reprovision` command instead of calling the function
|
|
637
|
+
// directly.
|
|
617
638
|
const savedConfig = loadConfig();
|
|
618
639
|
if (savedConfig?.apiKey) {
|
|
619
640
|
try {
|
|
620
|
-
await
|
|
641
|
+
const { execFileSync } = await import('child_process');
|
|
642
|
+
const globalRoot = execSync('npm root -g', { stdio: 'pipe' }).toString().trim();
|
|
643
|
+
const freshBin = `${globalRoot}/troxy-cli/bin/troxy.js`;
|
|
644
|
+
execFileSync(process.execPath, [
|
|
645
|
+
freshBin, '_reprovision',
|
|
646
|
+
'--key', savedConfig.apiKey,
|
|
647
|
+
'--name', savedConfig.agentName || 'my-agent',
|
|
648
|
+
], { stdio: 'inherit' });
|
|
621
649
|
} catch (err) {
|
|
622
650
|
console.error(`\n Could not refresh MCP client config: ${err.message}`);
|
|
623
651
|
console.error(' Run "troxy init --key <your-key>" by hand to finish updating.\n');
|
|
@@ -639,6 +667,18 @@ switch (command) {
|
|
|
639
667
|
break;
|
|
640
668
|
}
|
|
641
669
|
|
|
670
|
+
// Internal, undocumented - not for direct use. `update` shells out to
|
|
671
|
+
// this in a brand new process (see the comment at its call site) purely
|
|
672
|
+
// to force a fresh, on-disk read of ../src/init.js after an in-place
|
|
673
|
+
// npm upgrade, instead of reusing this same process's already-cached
|
|
674
|
+
// module graph from the pre-upgrade version.
|
|
675
|
+
case '_reprovision': {
|
|
676
|
+
if (!flags.key) process.exit(1);
|
|
677
|
+
const { reprovisionKeyConsumers } = await import('../src/init.js');
|
|
678
|
+
await reprovisionKeyConsumers(flags.key, flags.name || 'my-agent');
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
681
|
+
|
|
642
682
|
// ── Help / default ────────────────────────────────────────────
|
|
643
683
|
default:
|
|
644
684
|
if (command) console.error(` Unknown command: ${command}\n`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.29.
|
|
3
|
+
"version": "1.29.10",
|
|
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/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
|
}
|
|
@@ -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,82 @@
|
|
|
1
|
+
// Found live 2026-09-13: `troxy update`'s post-install reprovisioning step
|
|
2
|
+
// used to call reprovisionKeyConsumers() in-process via a dynamic import of
|
|
3
|
+
// ../src/init.js. Node's ES module cache is keyed by file URL and is never
|
|
4
|
+
// re-checked against disk - bin/troxy.js's own top-of-file static imports
|
|
5
|
+
// already pull in ../src/init.js the moment the process starts, i.e. running
|
|
6
|
+
// whatever version is being upgraded FROM. `npm install -g` only changes
|
|
7
|
+
// files on disk; it can't evict this process's already-cached module graph.
|
|
8
|
+
// So the "provision the just-installed version" step was silently always
|
|
9
|
+
// running the PREVIOUS version's code - invisible unless that exact code
|
|
10
|
+
// path changed between versions, which is exactly what happened with the
|
|
11
|
+
// gui/user launchd domain fix (1.29.9): update correctly installed 1.29.9's
|
|
12
|
+
// files, then reprovisioned using 1.29.8's stale in-memory
|
|
13
|
+
// launchdUserDomain(), reintroducing the very bug that release shipped the
|
|
14
|
+
// fix for. The fix is bin/troxy.js's `update` case shelling out (via
|
|
15
|
+
// execFileSync, a real new OS process) to the just-installed binary's own
|
|
16
|
+
// hidden `_reprovision` command, which loads ../src/init.js fresh from the
|
|
17
|
+
// now-updated disk - a dynamic import from the still-running update process
|
|
18
|
+
// can't do this no matter where it's written.
|
|
19
|
+
|
|
20
|
+
import { describe, it } from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const troxySrc = readFileSync(join(__dirname, '..', '..', 'bin', 'troxy.js'), 'utf8');
|
|
28
|
+
|
|
29
|
+
function updateCaseBody(src) {
|
|
30
|
+
const start = src.indexOf("case 'update':");
|
|
31
|
+
assert.ok(start !== -1, "case 'update' not found");
|
|
32
|
+
const end = src.indexOf("case '_reprovision':", start);
|
|
33
|
+
assert.ok(end !== -1, "case '_reprovision' not found after 'update'");
|
|
34
|
+
return src.slice(start, end);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("update case: reprovisions via a fresh subprocess, not an in-process import", () => {
|
|
38
|
+
it('does not call reprovisionKeyConsumers directly inside the update case', () => {
|
|
39
|
+
const body = updateCaseBody(troxySrc);
|
|
40
|
+
assert.doesNotMatch(body, /await reprovisionKeyConsumers\(/,
|
|
41
|
+
'must not call reprovisionKeyConsumers() in-process - that reuses this process\'s pre-upgrade module cache');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('no longer destructure-imports reprovisionKeyConsumers into the update case', () => {
|
|
45
|
+
const body = updateCaseBody(troxySrc);
|
|
46
|
+
assert.doesNotMatch(body, /\{[^}]*reprovisionKeyConsumers[^}]*\} = await import/,
|
|
47
|
+
'the whole point of the fix is that update never binds the in-process function');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('still imports refreshHostedInstructions in-process (unaffected by this bug)', () => {
|
|
51
|
+
const body = updateCaseBody(troxySrc);
|
|
52
|
+
assert.match(body, /const \{ refreshHostedInstructions \} = await import\('\.\.\/src\/init\.js'\);/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('shells out to a freshly-resolved binary path via execFileSync, not the running process\'s own code', () => {
|
|
56
|
+
const body = updateCaseBody(troxySrc);
|
|
57
|
+
assert.match(body, /execFileSync\(/, 'must spawn a real new process to force a fresh disk read');
|
|
58
|
+
assert.match(body, /npm root -g/, 'must resolve the just-installed package location, not assume one');
|
|
59
|
+
assert.match(body, /_reprovision/, 'must invoke the hidden _reprovision entry point');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('passes the API key as a separate argv entry, not interpolated into a shell string', () => {
|
|
63
|
+
const body = updateCaseBody(troxySrc);
|
|
64
|
+
const callIdx = body.indexOf('execFileSync(process.execPath, [');
|
|
65
|
+
assert.ok(callIdx !== -1, 'expected execFileSync(process.execPath, [...]) call shape');
|
|
66
|
+
const callBlock = body.slice(callIdx, body.indexOf(');', callIdx));
|
|
67
|
+
assert.doesNotMatch(callBlock, /`[^`]*\$\{savedConfig\.apiKey\}/,
|
|
68
|
+
'the key must not be template-interpolated into a shell-executed string');
|
|
69
|
+
assert.match(callBlock, /'--key',\s*savedConfig\.apiKey/);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("'_reprovision': hidden entry point the update subprocess invokes", () => {
|
|
74
|
+
it('exists, requires --key, and calls reprovisionKeyConsumers with fresh flags', () => {
|
|
75
|
+
const start = troxySrc.indexOf("case '_reprovision':");
|
|
76
|
+
assert.ok(start !== -1, "case '_reprovision' not found");
|
|
77
|
+
const block = troxySrc.slice(start, start + 500);
|
|
78
|
+
assert.match(block, /if \(!flags\.key\) process\.exit\(1\);/);
|
|
79
|
+
assert.match(block, /await import\('\.\.\/src\/init\.js'\)/);
|
|
80
|
+
assert.match(block, /reprovisionKeyConsumers\(flags\.key, flags\.name \|\| 'my-agent'\)/);
|
|
81
|
+
});
|
|
82
|
+
});
|