fraim 2.0.322 → 2.0.323

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.
@@ -14,6 +14,7 @@ exports.checkAgentCliHealthByCommand = checkAgentCliHealthByCommand;
14
14
  const child_process_1 = require("child_process");
15
15
  const path_1 = __importDefault(require("path"));
16
16
  const managed_agent_paths_1 = require("../../utils/managed-agent-paths");
17
+ const machine_path_1 = require("../../utils/machine-path");
17
18
  const command_resolution_1 = require("../../mcp/command-resolution");
18
19
  // CLIs with a managed-install fallback (npm install -g into FRAIM's portable
19
20
  // Node when no system install is found). Add gemini/copilot here when their
@@ -57,18 +58,14 @@ function probeVersion(commandPath) {
57
58
  // shell already open before the fix landed still uses) — issue #1285's
58
59
  // exact "update said success but --version looked wrong" ambiguity.
59
60
  // Windows-only; there is no equivalent persisted-PATH registry on macOS/Linux.
61
+ // Issue #1748: this used to shell out to PowerShell for HKCU Environment Path on its own,
62
+ // a second independently-written Windows registry PATH reader. Two implementations of the
63
+ // same read drift apart, and a parsing fix applied to one is easy to miss on the other,
64
+ // which is the same PATH-resolution-drift bug class this check exists to report. Delegates
65
+ // to the shared reader instead, which also covers HKLM rather than only the user hive and
66
+ // expands REG_EXPAND_SZ references.
60
67
  function readPersistedUserPath() {
61
- if (process.platform !== 'win32')
62
- return null;
63
- try {
64
- const result = (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', "[Environment]::GetEnvironmentVariable('PATH','User')"], { encoding: 'utf8', timeout: 5000 });
65
- if (result.status !== 0 || result.error)
66
- return null;
67
- return (result.stdout || '').trim() || null;
68
- }
69
- catch {
70
- return null;
71
- }
68
+ return (0, machine_path_1.resolveMachinePathCached)();
72
69
  }
73
70
  async function runAgentCliHealthCheck(cli) {
74
71
  // "Ambient" is this process's own raw, unmodified PATH — exactly what a
@@ -83,7 +80,16 @@ async function runAgentCliHealthCheck(cli) {
83
80
  // is system-PATH-first for launch purposes and would silently agree with
84
81
  // a stale ambient entry instead of surfacing the drift this check exists
85
82
  // to catch.
86
- const ambientPath = (0, command_resolution_1.getSystemCommandPath)(cli.command);
83
+ // Issue #1748: process.env.PATH no longer carries FRAIM's managed bin dirs (the
84
+ // module-load mutation that put them there was the defect). Without a fallback,
85
+ // `ambientPath` is null whenever the only install is FRAIM-managed, and both clauses
86
+ // of `versionMismatch` below short-circuit on it, so this check would return
87
+ // "consistent" while the #1285 stale-shim drift it exists to catch went unreported.
88
+ // The machine PATH is what a newly opened shell resolves, which is the notion of
89
+ // "ambient" this check actually wants once the process is no longer self-mutating.
90
+ const machinePathForAmbient = (0, machine_path_1.resolveMachinePathCached)();
91
+ const ambientPath = (0, command_resolution_1.getSystemCommandPath)(cli.command)
92
+ || (machinePathForAmbient ? (0, command_resolution_1.getSystemCommandPath)(cli.command, machinePathForAmbient) : null);
87
93
  const managedSearchPath = (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH);
88
94
  const managedPath = (0, command_resolution_1.getSystemCommandPath)(cli.command, managedSearchPath);
89
95
  if (!ambientPath && !managedPath) {
@@ -87,8 +87,13 @@ const resolveManagedCommand = (command) => {
87
87
  // path and the status-check path. Fall back to the FRAIM-managed portable
88
88
  // copy only when no system install is found by either check. Last resort:
89
89
  // bare command name.
90
- return (0, exports.getSystemCommandPath)(command)
91
- || (0, exports.getSystemCommandPath)(command, (0, managed_agent_paths_1.buildRecoveredSystemPath)(process.env.PATH))
90
+ // Issue #1748: resolve ONLY against the recovered system PATH. The previous first tier
91
+ // scanned this process's ambient PATH, which carries FRAIM's own managed dirs (registered
92
+ // on the machine PATH at install, and formerly appended again at module load), so a
93
+ // FRAIM-managed binary was returned as if it were a system install and the tiers below
94
+ // never ran. buildRecoveredSystemPath() re-reads the machine PATH and excludes the managed
95
+ // dirs, so FRAIM's bundled copy is reachable only through the explicit tier below.
96
+ return (0, exports.getSystemCommandPath)(command, (0, managed_agent_paths_1.buildRecoveredSystemPath)(process.env.PATH))
92
97
  || (0, exports.getPortableManagedCommandPath)(command)
93
98
  || command;
94
99
  };
@@ -72,7 +72,7 @@ ${buildDeferredToolBootstrapSection(profile)}1. **Confirm FRAIM activation**:
72
72
  If local FRAIM job stubs are present in the workspace, inspect those first and match the request locally. Also inspect \`fraim/personalized-employee/jobs/\` for local overrides or repo-specific jobs. If local files are missing or you cannot inspect workspace files, call \`list_fraim_jobs()\` to view the full catalog, including any proxy-discoverable personalized jobs.
73
73
 
74
74
  3. **Find the match**:
75
- If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists, ask once: "No catalog job matches. Would you like to run this as an ad-hoc task?" On confirmation, call \`get_fraim_job({ job: "adhoc-prompt" })\` and execute it with the user's instructions as the task input — do not pick the nearest catalog job. Do not ask again if the user already provided instructions.
75
+ If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists, call \`get_fraim_job({ job: "adhoc-prompt" })\` directly and execute it with the user's instructions as the task input — no confirmation question, and do not pick the nearest catalog job.
76
76
 
77
77
  4. **Load the full content**:
78
78
  - For jobs, call \`get_fraim_job({ job: "<matched-job-name>" })\`.
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseRegQueryPathValue = parseRegQueryPathValue;
7
+ exports.expandWindowsEnvRefs = expandWindowsEnvRefs;
8
+ exports.composeMachinePath = composeMachinePath;
9
+ exports.resolveMachinePathCached = resolveMachinePathCached;
10
+ exports.clearMachinePathCache = clearMachinePathCache;
11
+ exports.setMachinePathResolver = setMachinePathResolver;
12
+ const path_1 = __importDefault(require("path"));
13
+ const child_process_1 = require("child_process");
14
+ // ─── Issue #1748: Windows machine-PATH recovery ─────────────────────────────
15
+ //
16
+ // A Windows process inherits its environment at creation and never observes later
17
+ // registry changes (Node/Electron do not handle WM_SETTINGCHANGE). A Hub running for
18
+ // hours therefore holds the PATH as of its launch. When a user installs an agent CLI
19
+ // after that (Claude Code's native installer adding ~/.local/bin, say), the Hub cannot
20
+ // see it while a freshly opened terminal can, so the Hub falls through to FRAIM's own
21
+ // managed copy and runs a different CLI than the user installed and signed into.
22
+ //
23
+ // `defaultResolveLoginShellPath()` in managed-agent-paths.ts returns null on win32 (there
24
+ // is no login-shell PATH concept there), so before this the only recovery on Windows was
25
+ // the npm-prefix lookup: nothing re-read HKLM/HKCU. This module is the win32 counterpart
26
+ // to that POSIX recovery. Ask the OS what the PATH is now, rather than trusting the
27
+ // snapshot this process was handed.
28
+ //
29
+ // Lives in its own module rather than inside managed-agent-paths.ts so that file stays
30
+ // under the 500-line architecture threshold, and because "read the OS environment" is a
31
+ // distinct concern from "compose the PATH FRAIM resolves agents against". The dependency
32
+ // runs one way only (managed-agent-paths imports this), so there is no import cycle.
33
+ // Per-read ceiling. Both registry reads run sequentially (spawnSync is synchronous by
34
+ // definition, so they cannot overlap without making the whole resolution path async,
35
+ // which every caller is sync today), so the worst case is twice this. Measured cost is
36
+ // ~25ms per read, so 2s is already ~80x headroom while bounding a pathological stall at
37
+ // 4s rather than 10s.
38
+ const MACHINE_PATH_TIMEOUT_MS = 2000;
39
+ // The machine PATH changes when the user installs or moves a CLI, which is exactly the
40
+ // case this issue exists to handle, so this cache must expire rather than live for the
41
+ // life of the process. A Hub can run for days; a permanent cache would mean "install a
42
+ // CLI, then use it" still required a restart. Bounded by a TTL rather than an explicit
43
+ // invalidation call because the install that matters most is the one made OUTSIDE FRAIM
44
+ // (a vendor installer adding ~/.local/bin), which FRAIM never observes and so cannot
45
+ // invalidate on. Measured cost of a refresh on Windows is ~50ms for both registry reads,
46
+ // far below the blocking-spawn budget issue #1010 set (the POSIX login-shell read it sits
47
+ // beside costs 1-3s and is cached for the whole process).
48
+ const MACHINE_PATH_CACHE_TTL_MS = 60_000;
49
+ let machinePathResolverOverride = null;
50
+ let cachedMachinePath;
51
+ let cachedMachinePathAtMs = 0;
52
+ // The two registry values Windows itself composes a process PATH from, in the order it
53
+ // composes them: Machine first, then User.
54
+ const WINDOWS_PATH_REGISTRY_KEYS = [
55
+ [String.raw `HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment`, 'Path'],
56
+ [String.raw `HKCU\Environment`, 'Path'],
57
+ ];
58
+ // Resolve reg.exe by absolute path rather than letting the OS search PATH for it. This
59
+ // module's whole purpose is to stop a PATH entry from deciding which binary FRAIM runs,
60
+ // so invoking its own helper by bare name through that same untrusted PATH would undercut
61
+ // the control it implements. Falls back to the bare name only if SystemRoot is unset,
62
+ // which on a real Windows install it is not.
63
+ function resolveRegExePath() {
64
+ const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT;
65
+ return systemRoot ? path_1.default.join(systemRoot, 'System32', 'reg.exe') : 'reg';
66
+ }
67
+ // `reg query` prints a line shaped like " Path REG_EXPAND_SZ C:\foo;C:\bar".
68
+ // Split on the value type rather than on whitespace, because a PATH entry may itself
69
+ // contain spaces (C:\Program Files\...).
70
+ function parseRegQueryPathValue(stdout) {
71
+ for (const line of (stdout || '').split(/\r?\n/)) {
72
+ const match = line.match(/\s+(?:REG_EXPAND_SZ|REG_SZ)\s+(.*)$/);
73
+ if (match && match[1] !== undefined) {
74
+ const value = match[1].trim();
75
+ if (value)
76
+ return value;
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+ // REG_EXPAND_SZ keeps %VAR% references verbatim; Windows expands them when it builds a
82
+ // process environment. Reading the raw value means expanding them here too, or an entry
83
+ // like %USERPROFILE%\.local\bin never resolves. Unknown variables are left as written
84
+ // rather than blanked, so a failed expansion cannot silently collapse an entry into a
85
+ // different directory.
86
+ function expandWindowsEnvRefs(value, env = process.env) {
87
+ return value.replace(/%([^%]+)%/g, (whole, name) => {
88
+ const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase());
89
+ const resolved = key === undefined ? undefined : env[key];
90
+ return resolved === undefined ? whole : resolved;
91
+ });
92
+ }
93
+ // Composition is separated from the registry I/O so the ordering contract can be tested
94
+ // without spawning reg.exe: Machine entries precede User entries, because that is the
95
+ // order Windows itself composes a process PATH in, and %VAR% references are expanded
96
+ // before splitting so an expanded value containing a delimiter still splits correctly.
97
+ function composeMachinePath(rawValues, env = process.env) {
98
+ const parts = [];
99
+ for (const raw of rawValues) {
100
+ if (!raw)
101
+ continue;
102
+ for (const entry of expandWindowsEnvRefs(raw, env).split(path_1.default.delimiter)) {
103
+ const trimmed = entry.trim();
104
+ if (trimmed)
105
+ parts.push(trimmed);
106
+ }
107
+ }
108
+ // Not de-duplicated here: every consumer composes this through appendBinDirsToPath,
109
+ // which already collapses repeats. A second dedupe would be duplicated logic.
110
+ return parts.length > 0 ? parts.join(path_1.default.delimiter) : null;
111
+ }
112
+ function defaultResolveMachinePath() {
113
+ if (process.platform !== 'win32')
114
+ return null;
115
+ // Issue #1692 established that a sandboxed test run must never WRITE the real,
116
+ // registry-backed Windows User PATH. Reading it is the same isolation break in the
117
+ // other direction: it lets whatever agent CLIs happen to be installed on the build
118
+ // machine shadow a test's own isolated fixtures, which is the cross-contamination
119
+ // hazard already documented on resolveNpmGlobalBinDirsCached. Tests that need
120
+ // machine-PATH behavior inject it through __setMachinePathResolverForTests.
121
+ if (process.env.FRAIM_TEST_SANDBOX === '1')
122
+ return null;
123
+ const rawValues = WINDOWS_PATH_REGISTRY_KEYS.map(([key, valueName]) => {
124
+ const result = (0, child_process_1.spawnSync)(resolveRegExePath(), ['query', key, '/v', valueName], {
125
+ encoding: 'utf8',
126
+ timeout: MACHINE_PATH_TIMEOUT_MS,
127
+ windowsHide: true,
128
+ });
129
+ if (result.status !== 0 || result.error)
130
+ return null;
131
+ return parseRegQueryPathValue(result.stdout || '');
132
+ });
133
+ return composeMachinePath(rawValues);
134
+ }
135
+ function resolveMachinePathUncached() {
136
+ if (machinePathResolverOverride)
137
+ return machinePathResolverOverride();
138
+ return defaultResolveMachinePath();
139
+ }
140
+ /**
141
+ * The PATH the machine reports, read fresh instead of inherited, memoized for
142
+ * `MACHINE_PATH_CACHE_TTL_MS`. `nowMs` is injectable so a test can prove the entry expires
143
+ * without sleeping.
144
+ */
145
+ function resolveMachinePathCached(nowMs = Date.now()) {
146
+ if (cachedMachinePath !== undefined && nowMs - cachedMachinePathAtMs < MACHINE_PATH_CACHE_TTL_MS) {
147
+ return cachedMachinePath;
148
+ }
149
+ try {
150
+ cachedMachinePath = resolveMachinePathUncached();
151
+ }
152
+ catch {
153
+ cachedMachinePath = null;
154
+ }
155
+ cachedMachinePathAtMs = nowMs;
156
+ return cachedMachinePath;
157
+ }
158
+ function clearMachinePathCache() {
159
+ cachedMachinePath = undefined;
160
+ cachedMachinePathAtMs = 0;
161
+ }
162
+ function setMachinePathResolver(resolver) {
163
+ machinePathResolverOverride = resolver;
164
+ clearMachinePathCache();
165
+ }
@@ -112,6 +112,11 @@ async function installManagedAgent(option, systemPath, deps) {
112
112
  NPM_CONFIG_PREFIX: undefined,
113
113
  });
114
114
  const standardVersion = deps.commandVersion(option.launchCommand, undefined, systemPath);
115
+ // Only look up npm global bin dirs when the CLI was NOT already runnable: that lookup
116
+ // is a blocking npm-prefix subprocess, and on the success path there is nothing to find.
117
+ // (Commit 16cea56ac made this unconditional so the caller could persist the directory onto
118
+ // process.env.PATH; issue #1748 removed that in-process PATH write, so the unconditional
119
+ // lookup has no consumer and only costs the happy path a blocking subprocess.)
115
120
  const npmGlobalBinDirs = standardVersion
116
121
  ? []
117
122
  : (0, managed_agent_paths_1.resolveNpmGlobalBinDirs)(systemPath, {
@@ -123,7 +128,7 @@ async function installManagedAgent(option, systemPath, deps) {
123
128
  ? deps.commandVersion(option.launchCommand, npmGlobalBinDirs, systemPath)
124
129
  : null);
125
130
  if (standardVersionWithNpmBin) {
126
- return { outcome: 'standard', npmGlobalBinDirs };
131
+ return { outcome: 'standard' };
127
132
  }
128
133
  standardInstallError = `${option.label} standard install completed, but the CLI is not runnable from the user PATH.`;
129
134
  }
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.resolveMachinePathCached = exports.expandWindowsEnvRefs = exports.parseRegQueryPathValue = void 0;
6
7
  exports.getManagedNodeRoot = getManagedNodeRoot;
7
8
  exports.getPortableNodeBinPath = getPortableNodeBinPath;
8
9
  exports.getManagedAgentBinDirs = getManagedAgentBinDirs;
@@ -13,10 +14,11 @@ exports.appendBinDirsToPath = appendBinDirsToPath;
13
14
  exports.getNpmGlobalBinDirsFromPrefix = getNpmGlobalBinDirsFromPrefix;
14
15
  exports.resolveNpmGlobalBinDirs = resolveNpmGlobalBinDirs;
15
16
  exports.buildPathWithManagedAgentBins = buildPathWithManagedAgentBins;
16
- exports.appendManagedAgentBinDirsToProcessPath = appendManagedAgentBinDirsToProcessPath;
17
17
  exports.__setLoginShellPathResolverForTests = __setLoginShellPathResolverForTests;
18
18
  exports.resolveLoginShellPathCached = resolveLoginShellPathCached;
19
19
  exports.__clearLoginShellPathCacheForTests = __clearLoginShellPathCacheForTests;
20
+ exports.invalidateMachinePathCache = invalidateMachinePathCache;
21
+ exports.__setMachinePathResolverForTests = __setMachinePathResolverForTests;
20
22
  exports.buildRecoveredSystemPath = buildRecoveredSystemPath;
21
23
  exports.buildRecoveredAgentPath = buildRecoveredAgentPath;
22
24
  exports.versionFromProbeOutput = versionFromProbeOutput;
@@ -24,6 +26,13 @@ const fs_1 = __importDefault(require("fs"));
24
26
  const path_1 = __importDefault(require("path"));
25
27
  const child_process_1 = require("child_process");
26
28
  const script_sync_utils_1 = require("./script-sync-utils");
29
+ const machine_path_1 = require("./machine-path");
30
+ // Re-exported so the machine-PATH surface stays reachable from this module, which is
31
+ // where every other PATH helper already lives.
32
+ var machine_path_2 = require("./machine-path");
33
+ Object.defineProperty(exports, "parseRegQueryPathValue", { enumerable: true, get: function () { return machine_path_2.parseRegQueryPathValue; } });
34
+ Object.defineProperty(exports, "expandWindowsEnvRefs", { enumerable: true, get: function () { return machine_path_2.expandWindowsEnvRefs; } });
35
+ Object.defineProperty(exports, "resolveMachinePathCached", { enumerable: true, get: function () { return machine_path_2.resolveMachinePathCached; } });
27
36
  function getManagedNodeRoot() {
28
37
  return path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
29
38
  }
@@ -185,9 +194,6 @@ function resolveNpmGlobalBinDirs(basePath, env) {
185
194
  function buildPathWithManagedAgentBins(basePath) {
186
195
  return appendBinDirsToPath(stripManagedAgentBinDirsFromPath(basePath), getManagedAgentBinDirs());
187
196
  }
188
- function appendManagedAgentBinDirsToProcessPath() {
189
- process.env.PATH = buildPathWithManagedAgentBins(process.env.PATH);
190
- }
191
197
  // ─── Issue #1256 (slice a): location-agnostic PATH recovery ─────────────────
192
198
  //
193
199
  // A Hub launched from Launchpad/Finder/a Windows shortcut inherits the OS's minimal
@@ -350,17 +356,55 @@ function resolveNpmGlobalBinDirsCached(basePath) {
350
356
  // — resolve against this path for the system-install check, then consult
351
357
  // `getPortableManagedCommandPath()` (never the flat dir) as an explicit,
352
358
  // separate fallback tier.
359
+ // Issue #1748: the win32 machine-PATH read lives in ./machine-path so this file stays
360
+ // under the 500-line architecture threshold. These wrappers exist because changing the
361
+ // machine PATH also invalidates npm-global dirs, which are resolved against the PATH
362
+ // derived from it and cached here. Callers get one call that keeps both caches coherent
363
+ // rather than having to remember to clear a second one.
364
+ function invalidateMachinePathCache() {
365
+ (0, machine_path_1.clearMachinePathCache)();
366
+ cachedNpmGlobalBinDirsForRecovery.clear();
367
+ }
368
+ function __setMachinePathResolverForTests(resolver) {
369
+ (0, machine_path_1.setMachinePathResolver)(resolver);
370
+ cachedNpmGlobalBinDirsForRecovery.clear();
371
+ }
353
372
  function buildRecoveredSystemPath(basePath) {
354
373
  const withoutManaged = stripManagedAgentBinDirsFromPath(basePath);
355
- const withoutProjectBins = stripProjectLocalNodeBinDirs(withoutManaged);
374
+ const inherited = stripProjectLocalNodeBinDirs(withoutManaged);
375
+ // Issue #1748 (win32 only): the PATH the machine reports right now. This process may
376
+ // predate the user's install, so its own inherited PATH is a stale copy of the same
377
+ // registry. Machine entries go FIRST, because entries present in both would otherwise
378
+ // keep their stale relative position and an entry existing only on the machine PATH
379
+ // would land last, which is how resolution picked a different install than the one a
380
+ // fresh terminal resolves.
381
+ //
382
+ // Deliberately NOT applied to the POSIX login-shell tier below, which keeps its
383
+ // original append semantics. The staleness this reorders around is a Windows
384
+ // registry-snapshot problem; on POSIX a GUI-launched process has a minimal inherited
385
+ // PATH that carries no competing entry to out-order, so appending already resolves it.
386
+ // Promoting login-shell entries above inherited ones there would let the login shell
387
+ // silently override a directory a wrapper deliberately prepended for that launch,
388
+ // which is the same class of "wrong binary launched" bug this change exists to fix.
389
+ const machinePath = process.platform === 'win32' ? (0, machine_path_1.resolveMachinePathCached)() : null;
390
+ const machineEntries = machinePath ? machinePath.split(path_1.default.delimiter).filter(Boolean) : [];
391
+ const withMachine = machineEntries.length > 0
392
+ ? appendBinDirsToPath(machineEntries.join(path_1.default.delimiter), inherited.split(path_1.default.delimiter).filter(Boolean))
393
+ : inherited;
356
394
  const loginShellPath = resolveLoginShellPathCached();
357
395
  const loginShellEntries = loginShellPath ? loginShellPath.split(path_1.default.delimiter).filter(Boolean) : [];
358
396
  const withLoginShell = loginShellEntries.length > 0
359
- ? appendBinDirsToPath(withoutProjectBins, loginShellEntries)
360
- : withoutProjectBins;
361
- const withLoginShellClean = stripProjectLocalNodeBinDirs(withLoginShell);
362
- const npmGlobalBinDirs = resolveNpmGlobalBinDirsCached(withLoginShellClean);
363
- return appendBinDirsToPath(withLoginShellClean, npmGlobalBinDirs);
397
+ ? appendBinDirsToPath(withMachine, loginShellEntries)
398
+ : withMachine;
399
+ const cleaned = stripProjectLocalNodeBinDirs(withLoginShell);
400
+ // Strip managed dirs a second time. FRAIM registers its own directory on the machine
401
+ // PATH at install (persistShellPath), so the recovered PATH legitimately contains it,
402
+ // but this helper's contract is "what the real machine provides, excluding FRAIM's
403
+ // bundled copy" - which is what lets callers tell a user install apart from FRAIM's
404
+ // fallback. Without this the machine read would smuggle the managed dir back in.
405
+ const systemOnly = stripManagedAgentBinDirsFromPath(cleaned);
406
+ const npmGlobalBinDirs = resolveNpmGlobalBinDirsCached(systemOnly);
407
+ return appendBinDirsToPath(systemOnly, npmGlobalBinDirs);
364
408
  }
365
409
  function buildRecoveredAgentPath(basePath) {
366
410
  return appendBinDirsToPath(buildRecoveredSystemPath(basePath), getManagedAgentBinDirs());
@@ -89,11 +89,18 @@ function ensureOutputDirs() {
89
89
  fs_1.default.mkdirSync((0, script_sync_utils_1.getUserFraimDir)(), { recursive: true });
90
90
  fs_1.default.mkdirSync(path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'last-install'), { recursive: true });
91
91
  }
92
- // Append the portable Node bin dir to process PATH once at module load so
93
- // every spawnSync call (detection, login probe, change-agent) finds binaries
94
- // installed there without shadowing user/system installs.
92
+ // Issue #1748: this no longer touches process.env.PATH. It used to append FRAIM's
93
+ // managed bin dirs here so spawnSync callers would find them, but desktop-main imports
94
+ // this module, so every Hub inherited them and command resolution could no longer tell
95
+ // a user install from FRAIM's bundled copy. Anything that needs to find a managed
96
+ // binary must route through buildRecoveredAgentPath()/resolveManagedCommand() rather
97
+ // than relying on ambient process.env.PATH. What remains here is shim cleanup only.
95
98
  (function bootstrapFraimNodeBin() {
96
- (0, managed_agent_paths_1.appendManagedAgentBinDirsToProcessPath)();
99
+ // Issue #1748: this used to call appendManagedAgentBinDirsToProcessPath(), putting FRAIM's
100
+ // managed dirs on this process's own PATH. desktop-main imports this module, so every Hub
101
+ // inherited that, and command resolution could no longer tell a user install from FRAIM's
102
+ // bundled copy. Resolution now re-reads the machine PATH instead, and FRAIM's directory is
103
+ // reachable there because persistShellPath registers it with the OS at install time.
97
104
  // Issue #1285 (Implementation Strategy §3): remove any managed-agent shim
98
105
  // orphaned in the legacy flat directory by an older FRAIM version, so it
99
106
  // can no longer shadow the current versioned build.
@@ -914,9 +921,6 @@ class FirstRunSessionService {
914
921
  });
915
922
  const pathWithNpm = (0, managed_agent_paths_1.appendBinDirsToPath)(systemPath, [npm.binDir]);
916
923
  const outcome = await (0, managed_agent_install_1.installManagedAgent)({ label: option.label, installPackage: option.installPackage, launchCommand: option.launchCommand }, pathWithNpm, { runProcess, commandVersion });
917
- if (outcome.outcome === 'standard' && outcome.npmGlobalBinDirs.length > 0) {
918
- process.env.PATH = (0, managed_agent_paths_1.appendBinDirsToPath)(pathWithNpm, outcome.npmGlobalBinDirs);
919
- }
920
924
  this.setAgentInstallStatus(agentId, 'needs-sign-in', `Sign in to ${option.label} to activate it.`);
921
925
  appendInstallLog(outcome.outcome === 'standard' ? `agent-installed-standard ${agentId}` : `agent-installed-managed ${agentId}`);
922
926
  this.persist();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.322",
3
+ "version": "2.0.323",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {