fraim 2.0.301 → 2.0.302

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/README.md CHANGED
@@ -312,7 +312,7 @@ Use these defaults:
312
312
  - `feature-specification` when the request is still fuzzy or needs clarified requirements, UX, or acceptance criteria.
313
313
  - `technical-design` after the spec is approved and you need the implementation plan, file touchpoints, and risk handling.
314
314
  - `feature-implementation` for code changes, bug fixes, and documentation updates that should be executed and validated.
315
- - `test-execution` when you need reproduction coverage, missing tests, or stronger regression protection before implementation.
315
+ - `test-authoring` when you need reproduction coverage, missing tests, or stronger regression protection before implementation.
316
316
  - `browser-application-validation` or `ui-polish-validation` after user-facing UI changes or when the ask is explicitly browser validation.
317
317
  - `implementation-feature-review` when you need to verify the delivered behavior matches the feature spec.
318
318
  - `implementation-design-review` when you need to verify the code matches the approved technical design.
@@ -45,7 +45,8 @@ function probeVersion(commandPath) {
45
45
  const result = (0, child_process_1.spawnSync)(executable, args, { encoding: 'utf8', timeout: 5000 });
46
46
  if (result.status !== 0 || result.error)
47
47
  return null;
48
- return (result.stdout || result.stderr || '').trim() || null;
48
+ // Issue #1256 (AC-D1/D2): only stdout counts as a version see versionFromProbeOutput.
49
+ return (0, managed_agent_paths_1.versionFromProbeOutput)(result.stdout, result.stderr);
49
50
  }
50
51
  catch {
51
52
  return null;
@@ -86,9 +87,32 @@ async function runAgentCliHealthCheck(cli) {
86
87
  const managedSearchPath = (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH);
87
88
  const managedPath = (0, command_resolution_1.getSystemCommandPath)(cli.command, managedSearchPath);
88
89
  if (!ambientPath && !managedPath) {
90
+ // Issue #1256 (AC-A3): before concluding the CLI is absent, try the same shared
91
+ // location-agnostic recovery the Hub's version probe and check-agent route use. This
92
+ // only changes the answer for a CLI this process's raw/managed PATH cannot see at all
93
+ // (e.g. doctor itself invoked from a PATH-truncated context) — it never overrides a
94
+ // drift verdict computed from a real ambient/managed hit above.
95
+ const recoveredSearchPath = (0, managed_agent_paths_1.buildRecoveredAgentPath)(process.env.PATH);
96
+ const recoveredPath = (0, command_resolution_1.getSystemCommandPath)(cli.command, recoveredSearchPath);
97
+ if (!recoveredPath) {
98
+ return {
99
+ status: 'passed',
100
+ message: `${cli.label} is not installed; nothing to check.`,
101
+ };
102
+ }
103
+ const recoveredVersion = probeVersion(recoveredPath);
104
+ if (!recoveredVersion) {
105
+ return {
106
+ status: 'error',
107
+ message: `${cli.label} is installed at ${recoveredPath} but failed to run (\`${cli.command} --version\` produced no output).`,
108
+ suggestion: `Reinstall ${cli.label}, then run "${cli.command} --version" again to confirm it's fixed.`,
109
+ details: { recoveredPath },
110
+ };
111
+ }
89
112
  return {
90
113
  status: 'passed',
91
- message: `${cli.label} is not installed; nothing to check.`,
114
+ message: `${cli.label} is consistent (${recoveredVersion}).`,
115
+ details: { recoveredPath, recoveredVersion },
92
116
  };
93
117
  }
94
118
  const ambientVersion = ambientPath ? probeVersion(ambientPath) : null;
@@ -3,12 +3,13 @@ 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.expandPath = exports.findIDEByName = exports.getAllSupportedIDEs = exports.detectInstalledIDEs = exports.CLI_PROBE_CONFIGTYPES = exports.IDE_CONFIGS = void 0;
6
+ exports.expandPath = exports.findIDEByName = exports.getAllSupportedIDEs = exports.detectInstalledIDEs = exports.CLI_PROBE_CONFIGTYPES = exports.IDE_CONFIGS = exports.detectCodexDesktop = exports.CODEX_DESKTOP_DOWNLOAD_URL = exports.CODEX_DESKTOP_BUNDLE_ID = void 0;
7
7
  exports.getAdapterConfigType = getAdapterConfigType;
8
8
  const fs_1 = __importDefault(require("fs"));
9
9
  const path_1 = __importDefault(require("path"));
10
10
  const os_1 = __importDefault(require("os"));
11
11
  const child_process_1 = require("child_process");
12
+ const managed_agent_paths_1 = require("../utils/managed-agent-paths");
12
13
  /** Returns the configType to use for adapter file filtering. Falls back to configType when adapterConfigType is not set. */
13
14
  function getAdapterConfigType(ide) {
14
15
  return ide.adapterConfigType ?? ide.configType;
@@ -78,10 +79,12 @@ const guiAppDetect = (configSurfaceCheck, appName, options = {}) => {
78
79
  const availableByVersionProbe = (command) => {
79
80
  if (process.env.FRAIM_DETECT_DISABLE_CLI_PROBES === '1')
80
81
  return false;
82
+ const recoveredPath = (0, managed_agent_paths_1.buildRecoveredAgentPath)(process.env.PATH ?? process.env.Path);
83
+ const env = { ...process.env, PATH: recoveredPath, Path: recoveredPath };
81
84
  const result = process.platform === 'win32'
82
- ? (0, child_process_1.spawnSync)('cmd.exe', ['/d', '/s', '/c', `${command} --version`], { encoding: 'utf8', timeout: 1500 })
83
- : (0, child_process_1.spawnSync)(command, ['--version'], { encoding: 'utf8', timeout: 1500 });
84
- return result.status === 0;
85
+ ? (0, child_process_1.spawnSync)('cmd.exe', ['/d', '/s', '/c', `${command} --version`], { encoding: 'utf8', timeout: 1500, env })
86
+ : (0, child_process_1.spawnSync)(command, ['--version'], { encoding: 'utf8', timeout: 1500, env });
87
+ return result.status === 0 && !result.error && (0, managed_agent_paths_1.versionFromProbeOutput)(result.stdout, result.stderr) !== null;
85
88
  };
86
89
  const detectClaude = () => {
87
90
  const paths = [
@@ -151,6 +154,73 @@ const detectCodexSurface = () => {
151
154
  ];
152
155
  return checkMultiplePaths(paths);
153
156
  };
157
+ // Issue #1478: OpenAI folded the standalone Codex desktop app into the unified
158
+ // ChatGPT desktop app (Codex is now a view inside it). That app has no CLI
159
+ // config folder to detect and, unlike Claude Desktop, no local MCP config
160
+ // file for FRAIM to bootstrap — so it is intentionally NOT an IDE_CONFIGS
161
+ // entry. Adding it there would flow into ~13 other detectInstalledIDEs()
162
+ // consumers (auto-mcp-setup, doctor checks, add-ide, sync, init-project) that
163
+ // all assume every catalog entry is MCP-configurable, producing a false
164
+ // "Unsupported config type" failure for a product that was never meant to
165
+ // have one. Detection here is standalone; callers splice its result directly
166
+ // into the surfaces/rows that need it (see session-service.ts).
167
+ //
168
+ // Detection matches a stable package/bundle identity, not a display name or
169
+ // guessed path — openai/codex#32631 documents the Codex CLI's own equivalent
170
+ // bug (searching for the old name `Codex.app`, broken by the rename to
171
+ // `ChatGPT.app`) as the live precedent for why name/path matching fails.
172
+ exports.CODEX_DESKTOP_BUNDLE_ID = 'com.openai.codex';
173
+ // detectCodexDesktop() runs on every platform, so this row renders for
174
+ // macOS/Windows/Linux users alike — a platform-specific doc page (e.g. the
175
+ // Windows-only install guide) would be wrong for everyone else. Use OpenAI's
176
+ // own universal download page, matching every sibling IDE_CONFIGS entry's
177
+ // convention of linking the vendor's own top-level product/download page,
178
+ // not a platform subpage (e.g. `claude.ai/download`, `cursor.com`).
179
+ exports.CODEX_DESKTOP_DOWNLOAD_URL = 'https://chatgpt.com/download/';
180
+ const detectCodexDesktopMac = () => {
181
+ if (process.env.FRAIM_DETECT_DISABLE_PROCESS_CHECK === '1')
182
+ return false;
183
+ const result = (0, child_process_1.spawnSync)('mdfind', [`kMDItemCFBundleIdentifier == '${exports.CODEX_DESKTOP_BUNDLE_ID}'`], { encoding: 'utf8', timeout: 2000 });
184
+ return result.status === 0 && Boolean((result.stdout || '').trim());
185
+ };
186
+ const detectCodexDesktopWindows = () => {
187
+ if (process.env.FRAIM_DETECT_DISABLE_PROCESS_CHECK === '1')
188
+ return false;
189
+ // The exact PackageFamilyName for the ChatGPT desktop app's MSIX package
190
+ // (Store ProductId 9PLM9XGG6VKS) is not yet confirmed against a real install
191
+ // (spec Open Question #2) — Get-AppxPackage's own `Name` property (a fixed
192
+ // publisher-assigned identifier, distinct from the mutable Start-menu
193
+ // display name) is queried by a name pattern as the best-available proxy.
194
+ const psCmd = `(Get-AppxPackage | Where-Object { $_.Name -like '*ChatGPT*' } | Select-Object -First 1).PackageFamilyName`;
195
+ const result = (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCmd], { encoding: 'utf8', timeout: 3000 });
196
+ return result.status === 0 && Boolean((result.stdout || '').trim());
197
+ };
198
+ const detectCodexDesktopOverride = () => {
199
+ const value = process.env.FRAIM_DETECT_CODEX_DESKTOP_OVERRIDE;
200
+ if (value === '1')
201
+ return true;
202
+ if (value === '0')
203
+ return false;
204
+ return null;
205
+ };
206
+ /**
207
+ * Issue #1478 R1: detects the Codex view inside OpenAI's ChatGPT desktop app,
208
+ * independent of IDE_CONFIGS (see comment above). Linux is not yet
209
+ * implemented — the app is a public preview (Aug 2026) with no confirmed
210
+ * package identity (spec Open Question #1); this is a deliberate, documented
211
+ * scope boundary, not an oversight.
212
+ */
213
+ const detectCodexDesktop = () => {
214
+ const override = detectCodexDesktopOverride();
215
+ if (override !== null)
216
+ return override;
217
+ if (process.platform === 'darwin')
218
+ return detectCodexDesktopMac();
219
+ if (process.platform === 'win32')
220
+ return detectCodexDesktopWindows();
221
+ return false;
222
+ };
223
+ exports.detectCodexDesktop = detectCodexDesktop;
154
224
  const detectGrokSurface = () => {
155
225
  const paths = [
156
226
  '~/.grok',
@@ -14,6 +14,11 @@ exports.getNpmGlobalBinDirsFromPrefix = getNpmGlobalBinDirsFromPrefix;
14
14
  exports.resolveNpmGlobalBinDirs = resolveNpmGlobalBinDirs;
15
15
  exports.buildPathWithManagedAgentBins = buildPathWithManagedAgentBins;
16
16
  exports.appendManagedAgentBinDirsToProcessPath = appendManagedAgentBinDirsToProcessPath;
17
+ exports.__setLoginShellPathResolverForTests = __setLoginShellPathResolverForTests;
18
+ exports.resolveLoginShellPathCached = resolveLoginShellPathCached;
19
+ exports.__clearLoginShellPathCacheForTests = __clearLoginShellPathCacheForTests;
20
+ exports.buildRecoveredAgentPath = buildRecoveredAgentPath;
21
+ exports.versionFromProbeOutput = versionFromProbeOutput;
17
22
  const fs_1 = __importDefault(require("fs"));
18
23
  const path_1 = __importDefault(require("path"));
19
24
  const child_process_1 = require("child_process");
@@ -151,9 +156,15 @@ function buildChildEnv(env) {
151
156
  return childEnv;
152
157
  }
153
158
  function resolveNpmGlobalBinDirs(basePath, env) {
159
+ // Issue #1256: set both PATH and Path when overriding. Node's process.env is a
160
+ // case-insensitive view on win32, but a plain object built from `{...process.env}` can
161
+ // carry both casings as distinct own keys — overriding only `PATH` risks leaving a
162
+ // stale `Path` (with the pre-override value) also present in the child's environment
163
+ // block, which environment-lookup on Windows may prefer over the intended override.
164
+ // Mirrors the same defensive pattern hosts.ts's versionProbeEnv() already uses.
154
165
  const childEnv = buildChildEnv({
155
166
  ...env,
156
- ...(basePath === undefined ? {} : { PATH: basePath }),
167
+ ...(basePath === undefined ? {} : { PATH: basePath, Path: basePath }),
157
168
  });
158
169
  const executable = process.platform === 'win32' ? 'cmd.exe' : 'npm';
159
170
  const args = process.platform === 'win32'
@@ -176,3 +187,174 @@ function buildPathWithManagedAgentBins(basePath) {
176
187
  function appendManagedAgentBinDirsToProcessPath() {
177
188
  process.env.PATH = buildPathWithManagedAgentBins(process.env.PATH);
178
189
  }
190
+ // ─── Issue #1256 (slice a): location-agnostic PATH recovery ─────────────────
191
+ //
192
+ // A Hub launched from Launchpad/Finder/a Windows shortcut inherits the OS's minimal
193
+ // GUI-launch PATH, not the user's shell PATH. `resolveNpmGlobalBinDirs()` above already
194
+ // recovers the npm-global case, but it is structurally blind to anything installed outside
195
+ // npm — Claude Code's native macOS installer (`~/.local/bin/claude`), Homebrew, or a vendor
196
+ // installer like Antigravity's `agy` (no npm package at all, `src/first-run/types.ts`
197
+ // `installPackage: ''`). Per the 2026-08-27 product decision, recovery must be
198
+ // location-agnostic: it finds whatever the user's own login shell would find, not a list of
199
+ // known install roots (which fails the next vendor's convention by construction).
200
+ //
201
+ // Mechanism: spawn the user's login shell non-interactively (`-lc`, explicitly sourcing the
202
+ // common rc files itself rather than asking the shell to behave interactively — see
203
+ // defaultResolveLoginShellPath) and read back `$PATH` between two markers, so any prompt/
204
+ // motd/rc-file noise on stdout cannot be mistaken for the PATH itself. VS Code's own
205
+ // `resolveShellEnv` uses `-ilc`; this repo's compliance requirement (SOC 2 CC6.1 / ISO 27001
206
+ // A.8.19) is stricter and asks for non-interactive, so this achieves the same practical
207
+ // coverage (PATH exports in `~/.bashrc`/`~/.zshrc`) without it. POSIX only — Windows GUI
208
+ // launches already inherit the registry-composed PATH, so this returns null immediately on
209
+ // win32 rather than attempting a login-shell equivalent that doesn't exist.
210
+ const LOGIN_SHELL_PATH_TIMEOUT_MS = 3000;
211
+ function defaultResolveLoginShellPath() {
212
+ if (process.platform === 'win32')
213
+ return null;
214
+ const shell = process.env.SHELL || '/bin/bash';
215
+ const marker = `__fraim_1256_shell_path_${process.pid}__`;
216
+ // `-lc`, not `-ilc`: login + command, deliberately NOT interactive (SOC 2 CC6.1 / ISO 27001
217
+ // A.8.19 — the spec's compliance section requires a non-interactive invocation). A plain
218
+ // `-lc` alone would still miss PATH exports that live in `~/.bashrc`/`~/.zshrc` — bash/zsh
219
+ // source those only for an interactive shell by design, which is what `-i` exists for. This
220
+ // command explicitly sources both common rc files itself (a no-op if a file doesn't exist or
221
+ // its syntax doesn't apply to the running shell — errors are swallowed, never abort the
222
+ // script) instead of asking the shell to behave interactively to get the same effect. That
223
+ // keeps the shell itself non-interactive while still recovering the common real-world case.
224
+ const script = [
225
+ 'for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do [ -f "$rc" ] && . "$rc" >/dev/null 2>&1; done',
226
+ `echo "${marker}"`,
227
+ 'printf \'%s\' "$PATH"',
228
+ 'echo',
229
+ `echo "${marker}"`,
230
+ ].join('; ');
231
+ try {
232
+ const result = (0, child_process_1.spawnSync)(shell, ['-lc', script], {
233
+ encoding: 'utf8',
234
+ timeout: LOGIN_SHELL_PATH_TIMEOUT_MS,
235
+ stdio: ['ignore', 'pipe', 'pipe'],
236
+ });
237
+ // Bounded and best-effort (AC-A5): any failure, non-zero exit, or timeout degrades to
238
+ // "no recovery" rather than throwing or blocking the caller's own PATH resolution.
239
+ if (result.status !== 0 || result.error)
240
+ return null;
241
+ const output = result.stdout || '';
242
+ const firstIdx = output.indexOf(marker);
243
+ if (firstIdx === -1)
244
+ return null;
245
+ const afterFirst = output.slice(firstIdx + marker.length);
246
+ const secondIdx = afterFirst.indexOf(marker);
247
+ const section = (secondIdx === -1 ? afterFirst : afterFirst.slice(0, secondIdx)).trim();
248
+ return section || null;
249
+ }
250
+ catch {
251
+ return null;
252
+ }
253
+ }
254
+ let loginShellPathResolverOverride = null;
255
+ /**
256
+ * Test seam only. Injects a fake login-shell PATH resolver so AC-A2/AC-A6 are provable
257
+ * without a real POSIX shell or real rc files — including on Windows CI, where the real
258
+ * mechanism is a no-op. Pass null to restore the real resolver. Also clears the memoized
259
+ * result so the next call recomputes under the injected resolver.
260
+ */
261
+ function __setLoginShellPathResolverForTests(resolver) {
262
+ loginShellPathResolverOverride = resolver;
263
+ cachedLoginShellPath = undefined;
264
+ cachedNpmGlobalBinDirsForRecovery = undefined;
265
+ }
266
+ let cachedLoginShellPath;
267
+ /**
268
+ * Resolves and memoizes the PATH the user's own login shell would produce. Computed at most
269
+ * once per process (AC-A5: bounded, fixed cost) — this feeds every agent-resolution call, so
270
+ * recomputing per call would multiply one shell spawn by the number of agents probed.
271
+ */
272
+ function resolveLoginShellPathCached() {
273
+ if (cachedLoginShellPath !== undefined)
274
+ return cachedLoginShellPath;
275
+ const resolver = loginShellPathResolverOverride ?? defaultResolveLoginShellPath;
276
+ // AC-A5: a resolver failing must degrade to "no recovery", never propagate. The default
277
+ // resolver already guards its own spawnSync internally; this also protects against an
278
+ // injected test resolver (or any future resolver) that throws instead of returning null.
279
+ try {
280
+ cachedLoginShellPath = resolver();
281
+ }
282
+ catch {
283
+ cachedLoginShellPath = null;
284
+ }
285
+ return cachedLoginShellPath;
286
+ }
287
+ /** Test seam only. Clears the memoized login-shell PATH so the next call recomputes. */
288
+ function __clearLoginShellPathCacheForTests() {
289
+ cachedLoginShellPath = undefined;
290
+ }
291
+ let cachedNpmGlobalBinDirsForRecovery;
292
+ /**
293
+ * Memoized wrapper around resolveNpmGlobalBinDirs(), scoped to buildRecoveredAgentPath()'s
294
+ * own use only — NOT a change to resolveNpmGlobalBinDirs() itself, which fraim doctor and the
295
+ * install route still call directly and must keep getting a fresh, uncached answer (doctor's
296
+ * whole purpose is detecting drift after a `codex update`; a stale cached answer would defeat
297
+ * that).
298
+ *
299
+ * Bug this fixes (issue #1256, AC-A5): buildRecoveredAgentPath() is called once PER AGENT
300
+ * inside detectEmployeesAsync()'s concurrent probe round (hosts.ts). Without this cache,
301
+ * resolveNpmGlobalBinDirs()'s spawnSync (bounded at 5s) re-ran synchronously for every agent,
302
+ * and because each call sits inside a Promise executor that runs synchronously up to its own
303
+ * spawnSync, the "concurrent" probes actually serialized behind it — up to 5 agents × 5s of
304
+ * blocking spawnSync is exactly the event-loop-freezing regression issue #1010 eliminated,
305
+ * reintroduced one layer deeper. Memoizing once per process (like resolveLoginShellPathCached
306
+ * above) restores the bound this repo's own #1010 postmortem requires.
307
+ */
308
+ function resolveNpmGlobalBinDirsCached(basePath) {
309
+ if (cachedNpmGlobalBinDirsForRecovery !== undefined)
310
+ return cachedNpmGlobalBinDirsForRecovery;
311
+ try {
312
+ cachedNpmGlobalBinDirsForRecovery = resolveNpmGlobalBinDirs(basePath);
313
+ }
314
+ catch {
315
+ cachedNpmGlobalBinDirsForRecovery = [];
316
+ }
317
+ return cachedNpmGlobalBinDirsForRecovery;
318
+ }
319
+ /**
320
+ * Issue #1256 (AC-A3): the single shared PATH-recovery helper every agent-resolution call
321
+ * site routes through — the version probe (`hosts.ts`), the install route's existing-version
322
+ * check and `check-agent` (`server.ts`), and `fraim doctor`'s health check
323
+ * (`agent-cli-health-checks.ts`). One implementation means a CLI resolved by any one of these
324
+ * is resolved by all of them; four independent reimplementations is exactly how #1375 shipped
325
+ * a Windows-only PATH bug that stayed invisible on POSIX.
326
+ *
327
+ * Order: strip FRAIM's managed dirs and any project-local `node_modules/.bin` (AC-A4) →
328
+ * layer in the recovered login-shell PATH (AC-A2/AC-A6, location-agnostic by construction) →
329
+ * strip project-local bins again (a hostile/unusual shell rc could reintroduce one) → layer
330
+ * in npm-global bin dirs resolved against that recovered PATH (covers the npm case even where
331
+ * a login shell is unavailable) → append FRAIM's managed agent bins last, unconditionally.
332
+ */
333
+ function buildRecoveredAgentPath(basePath) {
334
+ const withoutManaged = stripManagedAgentBinDirsFromPath(basePath);
335
+ const withoutProjectBins = stripProjectLocalNodeBinDirs(withoutManaged);
336
+ const loginShellPath = resolveLoginShellPathCached();
337
+ const loginShellEntries = loginShellPath ? loginShellPath.split(path_1.default.delimiter).filter(Boolean) : [];
338
+ const withLoginShell = loginShellEntries.length > 0
339
+ ? appendBinDirsToPath(withoutProjectBins, loginShellEntries)
340
+ : withoutProjectBins;
341
+ const withLoginShellClean = stripProjectLocalNodeBinDirs(withLoginShell);
342
+ const npmGlobalBinDirs = resolveNpmGlobalBinDirsCached(withLoginShellClean);
343
+ const withNpmGlobal = appendBinDirsToPath(withLoginShellClean, npmGlobalBinDirs);
344
+ return appendBinDirsToPath(withNpmGlobal, getManagedAgentBinDirs());
345
+ }
346
+ // ─── Issue #1256 (slice d): trust stdout, not stderr, for a version answer ───
347
+ //
348
+ // GitHub Copilot CLI's current failure mode is `--version` exiting 0 while printing an error
349
+ // to stderr. `(stdout || stderr || '').trim()` — the pattern this replaces, previously
350
+ // duplicated in hosts.ts, server.ts, and agent-cli-health-checks.ts — read that stderr text
351
+ // as a version string and reported the agent ready. Only stdout counts as a version. This is
352
+ // the mirror image of #1355's `isResumableBackgroundTaskType`, which fails OPEN on an ABSENT
353
+ // field (silence is a reporting gap, not evidence). stderr output on exit 0 is not silence —
354
+ // it is positive evidence the CLI is broken — so this fails CLOSED. The two rules are
355
+ // consistent: infer nothing from an absent signal, believe an error when you get one.
356
+ function versionFromProbeOutput(stdout, stderr) {
357
+ void stderr; // never trusted as a version string — kept as a parameter so call sites read as before, not silently dropping a value
358
+ const version = (stdout || '').trim();
359
+ return version || null;
360
+ }
@@ -71,8 +71,8 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
71
71
  qasm: {
72
72
  personaKey: 'qasm',
73
73
  bundleId: 'persona-qasm-core',
74
- catalogMetadata: buildCatalogMetadata('qasm', ['test-execution', 'browser-application-validation', 'ui-polish-validation']),
75
- protectedJobs: ['test-execution', 'browser-application-validation', 'ui-polish-validation', 'code-quality-assessment', 'test-quality-assessment', 'user-testing-and-bug-bash', 'broken-windows-detection-and-remediation', 'iterative-quality-improvement', 'accessibility-audit', 'api-testing', 'performance-benchmarking'],
74
+ catalogMetadata: buildCatalogMetadata('qasm', ['test-authoring', 'browser-application-validation', 'ui-polish-validation']),
75
+ protectedJobs: ['test-authoring', 'browser-application-validation', 'ui-polish-validation', 'code-quality-assessment', 'test-quality-assessment', 'user-testing-and-bug-bash', 'broken-windows-detection-and-remediation', 'iterative-quality-improvement', 'accessibility-audit', 'api-testing', 'performance-benchmarking'],
76
76
  protectedAliases: ['qa', 'quality-assurance'],
77
77
  defaultHireMode: 'job',
78
78
  lockCopy: 'Hire QAsm to unlock QA validation for this request.'
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.FIRST_RUN_ROW_IDS = exports.FirstRunSessionService = void 0;
40
+ exports.commandVersion = commandVersion;
40
41
  exports.buildPersistShellPathWindowsCommand = buildPersistShellPathWindowsCommand;
41
42
  exports.buildFirstRunHubLaunchArgs = buildFirstRunHubLaunchArgs;
42
43
  const fs_1 = __importDefault(require("fs"));
@@ -71,18 +72,18 @@ function commandVersion(command, extraBinDirs, basePath) {
71
72
  ? ['/d', '/s', '/c', `${command} --version`]
72
73
  : ['--version'];
73
74
  const pathValue = extraBinDirs && extraBinDirs.length > 0
74
- ? (0, managed_agent_paths_1.appendBinDirsToPath)(basePath ?? process.env.PATH, extraBinDirs)
75
+ ? (0, managed_agent_paths_1.appendBinDirsToPath)(basePath ?? process.env.PATH ?? process.env.Path, extraBinDirs)
75
76
  : basePath;
76
- const env = pathValue === undefined ? undefined : { ...process.env, PATH: pathValue };
77
+ const recoveredPath = (0, managed_agent_paths_1.buildRecoveredAgentPath)(pathValue ?? process.env.PATH ?? process.env.Path);
78
+ const env = { ...process.env, PATH: recoveredPath, Path: recoveredPath };
77
79
  const result = (0, child_process_1.spawnSync)(executable, args, {
78
80
  encoding: 'utf8',
79
81
  timeout: 5000,
80
- ...(env ? { env } : {}),
82
+ env,
81
83
  });
82
- if (result.status !== 0)
84
+ if (result.status !== 0 || result.error)
83
85
  return null;
84
- const text = `${result.stdout || ''}${result.stderr || ''}`.split(/\r?\n/)[0]?.trim() || null;
85
- return text;
86
+ return (0, managed_agent_paths_1.versionFromProbeOutput)(result.stdout, result.stderr);
86
87
  }
87
88
  function ensureOutputDirs() {
88
89
  fs_1.default.mkdirSync((0, script_sync_utils_1.getUserFraimDir)(), { recursive: true });
@@ -259,12 +260,25 @@ function buildRunnableAgentSurfaces() {
259
260
  const cliRunnableTypes = new Set((0, ide_detector_1.detectInstalledIDEs)('cli-runnable').map((ide) => ide.configType));
260
261
  const ides = configSurface.filter((ide) => !ide_detector_1.CLI_PROBE_CONFIGTYPES.has(ide.configType) || cliRunnableTypes.has(ide.configType));
261
262
  const hints = (0, ide_global_integration_1.describeOnboardingInvocationSurfaces)(ides);
262
- return ides.map((ide, index) => ({
263
+ const surfaces = ides.map((ide, index) => ({
263
264
  id: ide.configType,
264
265
  name: ide.name,
265
266
  invocationHint: hints[index] || '/fraim onboard this project',
266
267
  status: 'configured',
267
268
  }));
269
+ // Issue #1478 R1/R1.2: Codex Desktop is detected standalone (not an
270
+ // IDE_CONFIGS entry — see ide-detector.ts) and spliced in here so it flows
271
+ // straight to detectedAgentCount() without ever touching the
272
+ // CLI_PROBE_CONFIGTYPES gate above.
273
+ if ((0, ide_detector_1.detectCodexDesktop)()) {
274
+ surfaces.push({
275
+ id: 'codex-desktop',
276
+ name: 'Codex Desktop',
277
+ invocationHint: 'Codex Desktop: onboard this project',
278
+ status: 'configured',
279
+ });
280
+ }
281
+ return surfaces;
268
282
  }
269
283
  function surfaceForAgent(option) {
270
284
  return {
@@ -523,12 +537,28 @@ class FirstRunSessionService {
523
537
  this.detectRowsOnLoad();
524
538
  this.persist();
525
539
  const detectedNames = new Set((this.state.setupResult?.configuredSurfaces ?? []).map((s) => s.name));
540
+ // Issue #1478 R2: one unified list. `agentOptionId` is set when this
541
+ // product also has a working Set up flow (matched by exact label against
542
+ // FIRST_RUN_AGENT_OPTIONS) — the client renders "Set up" instead of
543
+ // "Download" for those rows, which is what lets the old two-section split
544
+ // ("Supported AI tools and IDEs" + "Hub-ready CLI agents") retire into one.
526
545
  const supportedAgents = ide_detector_1.IDE_CONFIGS
527
546
  .map((ide) => ({
528
547
  name: ide.name,
529
548
  detected: detectedNames.has(ide.name),
530
549
  downloadUrl: ide.downloadUrl ?? null,
550
+ agentOptionId: types_1.FIRST_RUN_AGENT_OPTIONS.find((option) => option.label === ide.name)?.id ?? null,
531
551
  }));
552
+ // Codex Desktop is not an IDE_CONFIGS entry (see ide-detector.ts) but must
553
+ // still appear as a peer row; insert it next to "Codex" per the approved
554
+ // mock's row order.
555
+ const codexIndex = supportedAgents.findIndex((agent) => agent.name === 'Codex');
556
+ supportedAgents.splice(codexIndex === -1 ? supportedAgents.length : codexIndex + 1, 0, {
557
+ name: 'Codex Desktop',
558
+ detected: detectedNames.has('Codex Desktop'),
559
+ downloadUrl: ide_detector_1.CODEX_DESKTOP_DOWNLOAD_URL,
560
+ agentOptionId: null,
561
+ });
532
562
  return {
533
563
  state: this.state,
534
564
  rows: this.state.rows,
@@ -1527,6 +1527,18 @@ class FraimLocalMCPServer {
1527
1527
  if (typeof jobId !== 'string' || !jobId.trim())
1528
1528
  continue;
1529
1529
  const normalizedJobId = jobId.trim();
1530
+ // Issue #1504: a category-qualified jobId (e.g. "ai-employee/product-building/x")
1531
+ // can resolve through getJobOverview -> LocalRegistryResolver.getJob's lenient
1532
+ // local-override/remote fallback, even though every real catalog job id is a bare
1533
+ // filename slug (src/ai-hub/catalog.ts: `id: fileName`) and the Hub UI's
1534
+ // resolveNextJobFromCatalog() only ever matches that bare slug with strict equality
1535
+ // (public/ai-hub/script.js). Reject the path-qualified shape directly, before
1536
+ // trusting the resolver, so this class of malformed jobId cannot pass here and then
1537
+ // silently render as a disabled chip in the Hub.
1538
+ if (normalizedJobId.includes('/') || normalizedJobId.includes('\\')) {
1539
+ errors.push(`evidence.nextJobRecommendations[${i}].jobId must be the bare job slug from the catalog (e.g. "feature-implementation"), not a category-qualified path (got "${normalizedJobId}")`);
1540
+ continue;
1541
+ }
1530
1542
  const job = await mentor.getJobOverview(normalizedJobId);
1531
1543
  if (!job) {
1532
1544
  errors.push(`evidence.nextJobRecommendations[${i}].jobId must reference a known FRAIM job (got "${normalizedJobId}")`);
@@ -198,7 +198,16 @@ class AuthMiddleware {
198
198
  const strictTestAuth = typeof authMode === 'string' && authMode.toLowerCase() === 'strict';
199
199
  const isTestBypass = process.env.NODE_ENV === 'test' && !this.skipTestBypass && !strictTestAuth && apiKey !== 'invalid-key';
200
200
  const isLocalDev = apiKey === 'local-dev' && process.env.NODE_ENV !== 'production';
201
- if (isLocalDev || isTestBypass) {
201
+ let testSeededApiKeyData = null;
202
+ if (isTestBypass) {
203
+ try {
204
+ testSeededApiKeyData = await this.dbService.verifyApiKey(apiKey);
205
+ }
206
+ catch {
207
+ testSeededApiKeyData = null;
208
+ }
209
+ }
210
+ if (isLocalDev || (isTestBypass && !testSeededApiKeyData)) {
202
211
  req.apiKeyData = {
203
212
  key: apiKey,
204
213
  userId: isLocalDev ? 'local-dev-user' : 'test-user',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.301",
3
+ "version": "2.0.302",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -92,12 +92,8 @@
92
92
  return String(name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
93
93
  }
94
94
 
95
- function isAgentReady(opt) {
96
- const installState = state.session && state.session.state && state.session.state.agentInstalls
97
- ? state.session.state.agentInstalls[opt.id]
98
- : null;
99
- if (installState && installState.status === 'ready') return true;
100
- return configuredSurfaces().some((surface) => surface && (surface.id === opt.id || surface.name === opt.label));
95
+ function findAgentOptionById(agentOptionId) {
96
+ return (state.session && state.session.agentOptions || []).find((opt) => opt.id === agentOptionId) || null;
101
97
  }
102
98
 
103
99
  function detectedAgentCount() {
@@ -244,7 +240,7 @@
244
240
 
245
241
  const catalogCopy = document.createElement('p');
246
242
  catalogCopy.className = 'pane-copy';
247
- catalogCopy.textContent = 'FRAIM can configure every supported local agent or IDE it detects. Download links stay available for tools not installed yet.';
243
+ catalogCopy.textContent = 'FRAIM can configure every supported local agent or IDE it detects. "Set up" installs it for you; "Download" opens the developer\'s own install page.';
248
244
  pane.appendChild(catalogCopy);
249
245
 
250
246
  const list = document.createElement('ul');
@@ -271,6 +267,15 @@
271
267
  badge.setAttribute('data-testid', `supported-agent-installed-${slug}`);
272
268
  badge.textContent = 'Installed';
273
269
  actionSpan.appendChild(badge);
270
+ } else if (agent.agentOptionId) {
271
+ // Issue #1478 R2: this product also has a working install/sign-in/check
272
+ // flow — route to it instead of a static Download link so there is no
273
+ // row left that could dead-end.
274
+ const opt = findAgentOptionById(agent.agentOptionId);
275
+ const setupBtn = button('Set up', 'secondary');
276
+ setupBtn.setAttribute('data-testid', `supported-agent-setup-${agent.agentOptionId}`);
277
+ setupBtn.addEventListener('click', () => { if (opt) openAgentModal(opt); });
278
+ actionSpan.appendChild(setupBtn);
274
279
  } else if (agent.downloadUrl) {
275
280
  const link = document.createElement('a');
276
281
  link.className = 'agent-download-link';
@@ -303,52 +308,15 @@
303
308
  });
304
309
  pane.appendChild(rescanBtn);
305
310
 
306
- const hubHeading = document.createElement('h3');
307
- hubHeading.className = 'agent-section-heading';
308
- hubHeading.textContent = 'Hub-ready CLI agents';
309
- pane.appendChild(hubHeading);
310
-
311
- const hubCopy = document.createElement('p');
312
- hubCopy.className = 'pane-copy';
313
- hubCopy.textContent = 'FRAIM Hub runs jobs through CLI agents. Set up at least one of these if you plan to launch jobs in Hub.';
314
- pane.appendChild(hubCopy);
315
-
316
- const grid = document.createElement('div');
317
- grid.className = 'agent-grid';
318
- const agentOptions = state.session.agentOptions || [];
319
- for (const opt of agentOptions) {
320
- const card = document.createElement('div');
321
- card.className = 'user-type-card recruit-card';
322
- card.setAttribute('data-agent-id', opt.id);
323
- card.setAttribute('data-testid', `agent-card-${opt.id}`);
324
- const agentReady = isAgentReady(opt);
325
-
326
- const title = document.createElement('strong');
327
- title.className = 'card-title';
328
- title.textContent = opt.label;
329
-
330
- const desc = document.createElement('p');
331
- desc.className = 'card-desc';
332
- desc.textContent = agentReady
333
- ? 'Already installed and ready for FRAIM.'
334
- : `Available to set up: install, sign in, and verify ${opt.label}.`;
335
-
336
- const action = button(agentReady ? 'Ready' : 'Set up', 'secondary');
337
- action.disabled = agentReady;
338
- action.setAttribute('data-testid', `install-${opt.id}`);
339
- action.addEventListener('click', () => openAgentModal(opt));
340
-
341
- card.appendChild(title);
342
- card.appendChild(desc);
343
- card.appendChild(action);
344
- grid.appendChild(card);
345
- }
346
-
311
+ // Issue #1478 R2: the old "Hub-ready CLI agents" heading/copy/card-grid is
312
+ // retired — every product it used to list (Claude Code, Codex, Gemini CLI,
313
+ // GitHub Copilot CLI) now renders as a "Set up" row in the unified list
314
+ // above via agent.agentOptionId, with the identical install/sign-in/check
315
+ // modal. Bring Your Own Agent still renders, now standalone.
347
316
  const byoa = document.createElement('div');
348
317
  byoa.className = 'user-type-card recruit-card byoa-card';
349
- byoa.innerHTML = '<strong class="card-title">Bring Your Own Agent</strong><p class="card-desc">Use Cursor, Windsurf, Kiro, VS Code, or another supported local AI tool. If you install a new agent later and want FRAIM to use it, run this command from your project.</p><div class="cmd-block">npx fraim add-ide</div>';
350
- grid.appendChild(byoa);
351
- pane.appendChild(grid);
318
+ byoa.innerHTML = '<strong class="card-title">Bring Your Own Agent</strong><p class="card-desc">Use Windsurf, Kiro, or another supported local AI tool not listed above. If you install a new agent later and want FRAIM to use it, run this command from your project.</p><div class="cmd-block">npx fraim add-ide</div>';
319
+ pane.appendChild(byoa);
352
320
 
353
321
  const done = button('Next', 'primary');
354
322
  done.setAttribute('data-testid', 'done-installing-agents');