fraim 2.0.327 → 2.0.328

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.
@@ -11,6 +11,7 @@ exports.resolveSubscriptionNextBillingDateWithInvoiceFallback = resolveSubscript
11
11
  exports.buildCurrentPeriodEndUpdate = buildCurrentPeriodEndUpdate;
12
12
  exports.resolveInvoicePaidNextBillingDate = resolveInvoicePaidNextBillingDate;
13
13
  const stripe_1 = require("../../config/stripe");
14
+ const redact_secrets_1 = require("../../core/utils/redact-secrets");
14
15
  const feature_flags_1 = require("../../config/feature-flags");
15
16
  const email_service_1 = require("../../services/email-service");
16
17
  const dashboard_access_1 = require("../../services/dashboard-access");
@@ -273,7 +274,7 @@ async function handleSubscriptionCreatedOrUpdated(subscription, dbService) {
273
274
  expiresAt: null, // Subscriptions don't have fixed expiration
274
275
  cancelAt: subscription.cancel_at ? new Date(subscription.cancel_at * 1000) : null
275
276
  });
276
- console.log(`✅ Upgraded trial key to paid subscription: ${apiKey.key}`);
277
+ console.log(`✅ Upgraded trial key to paid subscription: ${(0, redact_secrets_1.redactApiKey)(apiKey.key)}`);
277
278
  }
278
279
  else {
279
280
  // Create new API key for subscription (shouldn't happen in normal flow)
@@ -295,7 +296,7 @@ async function handleSubscriptionCreatedOrUpdated(subscription, dbService) {
295
296
  apiCallCount: 0,
296
297
  personaSystemActive: (0, feature_flags_1.isPersonaEntitlementsEnabled)() || undefined
297
298
  });
298
- console.log(`✅ Created new API key for subscription: ${newKey}`);
299
+ console.log(`✅ Created new API key for subscription: ${(0, redact_secrets_1.redactApiKey)(newKey)}`);
299
300
  apiKey = { key: newKey, userId: email };
300
301
  }
301
302
  }
@@ -358,7 +359,7 @@ async function handleSubscriptionDeleted(subscription, dbService) {
358
359
  // Send cancellation email
359
360
  const emailService = new email_service_1.EmailService();
360
361
  await emailService.sendSubscriptionCancelled(apiKey.userId, new Date());
361
- console.log(`✅ API key expired due to subscription cancellation: ${apiKey.key}`);
362
+ console.log(`✅ API key expired due to subscription cancellation: ${(0, redact_secrets_1.redactApiKey)(apiKey.key)}`);
362
363
  }
363
364
  async function handleInvoicePaid(invoice, dbService) {
364
365
  console.log(`✅ Invoice paid: ${invoice.id}`);
@@ -438,7 +439,7 @@ async function handleInvoicePaid(invoice, dbService) {
438
439
  suspendedAt: null,
439
440
  suspensionReason: null
440
441
  });
441
- console.log(`✅ API key restored after payment: ${apiKey.key}`);
442
+ console.log(`✅ API key restored after payment: ${(0, redact_secrets_1.redactApiKey)(apiKey.key)}`);
442
443
  // Send restoration email
443
444
  const emailService = new email_service_1.EmailService();
444
445
  const restorationBillingDate = nextBillingDate || new Date();
@@ -467,7 +468,7 @@ async function handleInvoicePaid(invoice, dbService) {
467
468
  }
468
469
  // Regular renewal
469
470
  {
470
- console.log(`✅ Subscription renewed: ${apiKey.key}`);
471
+ console.log(`✅ Subscription renewed: ${(0, redact_secrets_1.redactApiKey)(apiKey.key)}`);
471
472
  if (nextBillingDate) {
472
473
  await dbService.updateApiKey(apiKey.key, buildCurrentPeriodEndUpdate(nextBillingDate));
473
474
  }
@@ -532,7 +533,7 @@ async function handleInvoicePaymentFailed(invoice, dbService) {
532
533
  suspendedAt: new Date(),
533
534
  suspensionReason: 'payment_failed'
534
535
  });
535
- console.log(`✅ API key suspended due to payment failure: ${apiKey.key}`);
536
+ console.log(`✅ API key suspended due to payment failure: ${(0, redact_secrets_1.redactApiKey)(apiKey.key)}`);
536
537
  // Send payment failure email with 7-day grace period
537
538
  const emailService = new email_service_1.EmailService();
538
539
  const gracePeriodEndsAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
@@ -376,9 +376,18 @@ const runSync = async (options) => {
376
376
  await refreshOrgCache(remoteUrl, apiKey);
377
377
  // 1d. Refresh manager home
378
378
  await refreshManagerCache(remoteUrl, apiKey);
379
- // 1e. Refresh MCP proxy launcher
380
- const { ensureFraimMcpLatestLauncher } = await Promise.resolve().then(() => __importStar(require('../mcp/fraim-mcp-latest-launcher')));
379
+ // 1e. Refresh MCP proxy launcher, and record the version this sync is running as the one
380
+ // the launcher should use next. Issue #1354: the launcher reuses its recorded version inside
381
+ // a freshness window instead of resolving npm on every launch, so without this a published
382
+ // fix reaches an installed machine up to a window late and nothing the user can run shortens
383
+ // that. Writing the record here makes `npx fraim@latest sync` the deliberate way to pull a
384
+ // release through immediately: it resolves latest, runs as latest, and records latest, which
385
+ // the next MCP server spawn picks up at once.
386
+ const { ensureFraimMcpLatestLauncher, recordFraimMcpLauncherVersion } = await Promise.resolve().then(() => __importStar(require('../mcp/fraim-mcp-latest-launcher')));
381
387
  ensureFraimMcpLatestLauncher();
388
+ if (recordFraimMcpLauncherVersion((0, version_utils_1.getFraimVersion)())) {
389
+ console.log(chalk_1.default.green(` MCP launcher will use fraim@${(0, version_utils_1.getFraimVersion)()} on its next launch`));
390
+ }
382
391
  // 1f. Ensure user-level directories and runtime dependencies (Issue #1508:
383
392
  // synced scripts like the DOCX review layer require npm deps such as
384
393
  // adm-zip; a plain sync must install them, not just create directories)
@@ -12,15 +12,14 @@ exports.buildAgentDebugSteps = buildAgentDebugSteps;
12
12
  exports.interpretationForClassification = interpretationForClassification;
13
13
  exports.analyzeFraimProxyLog = analyzeFraimProxyLog;
14
14
  exports.diagnoseFraimServerConfigConsistency = diagnoseFraimServerConfigConsistency;
15
- const crypto_1 = __importDefault(require("crypto"));
16
15
  const fs_1 = __importDefault(require("fs"));
16
+ const redact_secrets_1 = require("../../../core/utils/redact-secrets");
17
17
  exports.DEFAULT_FRAIM_REMOTE_URL = 'https://fraim.wellnessatwork.me';
18
18
  exports.FRAIM_PROXY_LOG_FILE = 'fraim-mcp-proxy.log';
19
19
  function fingerprintSecret(secret) {
20
20
  return {
21
- prefix: secret.slice(0, 10),
22
- length: secret.length,
23
- sha256: crypto_1.default.createHash('sha256').update(secret).digest('hex').slice(0, 16)
21
+ ref: (0, redact_secrets_1.redactApiKey)(secret),
22
+ length: secret.length
24
23
  };
25
24
  }
26
25
  function normalizeUrl(url) {
@@ -33,9 +32,11 @@ function appendLimited(current, chunk, maxChars = 8000) {
33
32
  function sanitizeDiagnosticText(text, knownSecrets = []) {
34
33
  let sanitized = text;
35
34
  for (const secret of knownSecrets.filter(Boolean)) {
36
- sanitized = sanitized.split(secret).join(`${secret.slice(0, 10)}...`);
35
+ sanitized = sanitized.split(secret).join((0, redact_secrets_1.redactApiKey)(secret));
37
36
  }
38
- return sanitized.replace(/fraim_[A-Za-z0-9_-]+/g, (match) => `${match.slice(0, 10)}...`);
37
+ // Issue #1701: a truncated key is still disclosed key material. Substitute the
38
+ // same reference the rest of FRAIM logs so diagnostics stay correlatable.
39
+ return sanitized.replace(/fraim_[A-Za-z0-9_-]+/g, (match) => (0, redact_secrets_1.redactApiKey)(match));
39
40
  }
40
41
  function readFileTailUtf8(filePath, maxBytes = 256 * 1024) {
41
42
  const stat = fs_1.default.statSync(filePath);
@@ -133,7 +134,13 @@ function interpretationForClassification(classification) {
133
134
  return 'The proxy log did not match a known FRAIM MCP startup pattern.';
134
135
  }
135
136
  }
136
- function analyzeFraimProxyLog(logContent, options = {}) {
137
+ function analyzeFraimProxyLog(logContent,
138
+ /**
139
+ * `expectedKeyRef` is the redacted reference the proxy writes in its per-line
140
+ * `[...]` segment (issue #1701), not a raw key. Callers holding a raw key must
141
+ * pass `redactApiKey(key)`.
142
+ */
143
+ options = {}) {
137
144
  const maxLines = options.maxLines ?? 300;
138
145
  const allLines = logContent.split(/\r?\n/).map(line => line.trim()).filter(Boolean).slice(-maxLines);
139
146
  if (allLines.length === 0) {
@@ -152,10 +159,10 @@ function analyzeFraimProxyLog(logContent, options = {}) {
152
159
  };
153
160
  }
154
161
  const keyLineRegex = /^\S+\s+\[([^\]]+)\]\s+(.*)$/;
155
- const matchingLines = options.expectedKey
156
- ? allLines.filter(line => keyLineRegex.exec(line)?.[1] === options.expectedKey)
162
+ const matchingLines = options.expectedKeyRef
163
+ ? allLines.filter(line => keyLineRegex.exec(line)?.[1] === options.expectedKeyRef)
157
164
  : allLines;
158
- if (options.expectedKey && matchingLines.length === 0) {
165
+ if (options.expectedKeyRef && matchingLines.length === 0) {
159
166
  const classification = 'no-log-lines-for-expected-key';
160
167
  return {
161
168
  classification,
@@ -55,6 +55,7 @@ const child_process_1 = require("child_process");
55
55
  const win32_cmd_spawn_1 = require("../../utils/win32-cmd-spawn");
56
56
  const axios_1 = __importDefault(require("axios"));
57
57
  const toml = __importStar(require("toml"));
58
+ const redact_secrets_1 = require("../../../core/utils/redact-secrets");
58
59
  const ide_detector_1 = require("../../setup/ide-detector");
59
60
  const fraim_mcp_latest_launcher_1 = require("../../mcp/fraim-mcp-latest-launcher");
60
61
  const fraim_mcp_diagnostics_1 = require("./fraim-mcp-diagnostics");
@@ -302,7 +303,7 @@ async function testFraimConnectivity() {
302
303
  if (process.env.DEBUG_DOCTOR) {
303
304
  console.log('[DOCTOR DEBUG] Testing FRAIM connectivity:');
304
305
  console.log(' URL:', remoteUrl);
305
- console.log(' API Key:', apiKey.substring(0, 15) + '...');
306
+ console.log(' API Key:', (0, redact_secrets_1.redactApiKey)(apiKey));
306
307
  console.log(' NODE_ENV:', process.env.NODE_ENV || 'not set');
307
308
  }
308
309
  // Warn if testing against localhost (might be in test mode)
@@ -631,7 +632,8 @@ async function testFraimLocalProxyTroubleshooting() {
631
632
  }
632
633
  const logTail = (0, fraim_mcp_diagnostics_1.readFileTailUtf8)(logPath);
633
634
  const analysis = (0, fraim_mcp_diagnostics_1.analyzeFraimProxyLog)(logTail.content, {
634
- expectedKey: keyDiagnostic.apiKey,
635
+ // The proxy tags each line with the key's reference, never the key (issue #1701).
636
+ expectedKeyRef: (0, redact_secrets_1.redactApiKey)(keyDiagnostic.apiKey),
635
637
  maxLines: 300
636
638
  });
637
639
  const errorClassifications = [
@@ -8,12 +8,58 @@ exports.getFraimMcpShimPath = getFraimMcpShimPath;
8
8
  exports.getFraimNpxShimPath = getFraimNpxShimPath;
9
9
  exports.getFraimCliShimPath = getFraimCliShimPath;
10
10
  exports.getPackagedFraimRuntimePath = getPackagedFraimRuntimePath;
11
+ exports.getFraimMcpLauncherStatePath = getFraimMcpLauncherStatePath;
12
+ exports.recordFraimMcpLauncherVersion = recordFraimMcpLauncherVersion;
11
13
  exports.ensureFraimMcpLatestLauncher = ensureFraimMcpLatestLauncher;
12
14
  const fs_1 = __importDefault(require("fs"));
13
15
  const os_1 = __importDefault(require("os"));
14
16
  const path_1 = __importDefault(require("path"));
15
17
  const windows_shim_path_search_1 = require("../../core/utils/windows-shim-path-search");
16
- const LAUNCHER_VERSION = 1;
18
+ const LAUNCHER_VERSION = 2;
19
+ /**
20
+ * Issue #1354: how long a resolved version stays good before the launcher asks npm again.
21
+ *
22
+ * Resolving "latest" is a registry round trip by definition, so a launcher that resolves on
23
+ * every invocation pays that cost on every invocation - and pays a full connection timeout
24
+ * when the registry is unreachable, which is the silent-startup-failure this issue reports.
25
+ * Bounding it to one resolve per window is what makes the offline path cheap: with a fresh
26
+ * record there is no reason to touch the network at all.
27
+ *
28
+ * The tradeoff is explicit: inside the window the launcher runs the last version it resolved,
29
+ * so "always latest" is really "latest as of at most one window ago". 24h is not a new number -
30
+ * it matches `FraimLocalMCPServer.DEFAULT_CONNECT_SYNC_MAX_AGE_MS`, the connect-time catalog
31
+ * freshness window, rather than introducing a second unrelated one.
32
+ */
33
+ const STALENESS_WINDOW_MS = 24 * 60 * 60 * 1000;
34
+ /**
35
+ * How long the resolve may spend talking to the registry before giving up.
36
+ *
37
+ * Paired with `--fetch-retries=0`: the resolve is always optional, because a failure falls back
38
+ * to the recorded version, so the only thing a long wait buys is the difference between a brief
39
+ * pause and an apparent hang at process spawn. 5s is comfortably above a healthy registry round
40
+ * trip (measured at roughly 0.7-1.4s on this machine) and well inside an MCP client's startup
41
+ * window, which is the budget this issue reports blowing.
42
+ */
43
+ const RESOLVE_TIMEOUT_MS = 5000;
44
+ /** Per-version npm caches to keep. Older ones are pruned so the directory stays bounded. */
45
+ const CACHE_GENERATIONS_KEPT = 2;
46
+ /**
47
+ * A per-version cache is only ever a prune candidate once it is this old.
48
+ *
49
+ * The generation count alone is not a safe rule here, because these directories are not inert
50
+ * caches: `runPackage` points `npm_config_cache` at one, and npx installs the package *inside*
51
+ * it at `_npx/<hash>/node_modules`. An MCP stdio server lives as long as its agent session and
52
+ * resolves dynamic imports from that tree for its whole life, so removing the cache of a version
53
+ * still being served would pull the module tree out from under a running process. On Windows it
54
+ * is worse than a clean failure: `rmSync` deletes whatever is not currently open and throws on
55
+ * the rest, leaving a half-gutted tree.
56
+ *
57
+ * An age floor is the cheap correct guard, and matches how this repository already bounds
58
+ * on-disk growth elsewhere (`artifact-retention-cleanup` retires by age in days, not by count).
59
+ * Growth stays bounded - everything past the newest few ages out - while any cache a plausibly
60
+ * live session could still be reading is off limits.
61
+ */
62
+ const CACHE_MIN_AGE_MS = 7 * 24 * 60 * 60 * 1000;
17
63
  const launcherSource = `#!/usr/bin/env node
18
64
  const fs = require('fs');
19
65
  const os = require('os');
@@ -21,6 +67,10 @@ const path = require('path');
21
67
  const { spawnSync } = require('child_process');
22
68
 
23
69
  const LAUNCHER_VERSION = ${LAUNCHER_VERSION};
70
+ const STALENESS_WINDOW_MS = ${STALENESS_WINDOW_MS};
71
+ const RESOLVE_TIMEOUT_MS = ${RESOLVE_TIMEOUT_MS};
72
+ const CACHE_GENERATIONS_KEPT = ${CACHE_GENERATIONS_KEPT};
73
+ const CACHE_MIN_AGE_MS = ${CACHE_MIN_AGE_MS};
24
74
  const USER_DIR = process.env.FRAIM_USER_DIR || path.join(os.homedir(), '.fraim');
25
75
  const STATE_DIR = path.join(USER_DIR, 'mcp-launcher');
26
76
  const STATE_PATH = path.join(STATE_DIR, 'latest.json');
@@ -64,10 +114,14 @@ function writeState(state) {
64
114
  }
65
115
 
66
116
  function resolveLatest(packageName) {
67
- const result = runCommand(npmCommand, ['view', packageName, 'version', '--silent'], {
68
- encoding: 'utf8',
69
- env: process.env
70
- });
117
+ // Fail fast rather than waiting out npm's default retry ladder. This resolve is always
118
+ // optional: a failure falls back to the recorded version, so a long wait buys nothing and is
119
+ // the difference between a brief pause and an apparent hang when the registry is unreachable.
120
+ const result = runCommand(
121
+ npmCommand,
122
+ ['view', packageName, 'version', '--silent', '--fetch-retries=0', '--fetch-timeout=' + RESOLVE_TIMEOUT_MS],
123
+ { encoding: 'utf8', env: process.env }
124
+ );
71
125
 
72
126
  if (result.status !== 0) {
73
127
  return null;
@@ -77,7 +131,47 @@ function resolveLatest(packageName) {
77
131
  return /^\\d+\\.\\d+\\.\\d+/.test(version) ? version : null;
78
132
  }
79
133
 
134
+ /**
135
+ * Whether a record names a version this launcher can run.
136
+ *
137
+ * Deliberately not gated on launcherVersion. The record's shape is what matters and that has
138
+ * not changed, so a record written by an earlier launcher is still a usable fallback. Rejecting
139
+ * one would mean the first launch after a launcher upgrade fails outright when the registry is
140
+ * unreachable, turning a working offline start into an error for no benefit.
141
+ */
142
+ function isUsableState(state) {
143
+ // The version shape is re-checked here, not only where npm answers. This record is the common
144
+ // path now rather than a rare fallback, and its value reaches both an npx --package argument
145
+ // and a cache directory name, so it is validated at the point of use rather than trusted
146
+ // because writeState happened to produce it.
147
+ return Boolean(
148
+ state &&
149
+ state.packageName === 'fraim' &&
150
+ state.binName === 'fraim' &&
151
+ typeof state.version === 'string' &&
152
+ /^\\d+\\.\\d+\\.\\d+/.test(state.version)
153
+ );
154
+ }
155
+
156
+ /** True while the recorded resolve is recent enough to reuse without asking npm. */
157
+ function isFresh(state) {
158
+ if (!isUsableState(state)) return false;
159
+ const recordedAt = Date.parse(state.updatedAt);
160
+ if (!Number.isFinite(recordedAt)) return false;
161
+ const age = Date.now() - recordedAt;
162
+ // A record stamped in the future is a clock change, not freshness.
163
+ return age >= 0 && age < STALENESS_WINDOW_MS;
164
+ }
165
+
80
166
  function resolvePackage() {
167
+ // Read the freshness record before reaching for the network. This ordering is the fix: the
168
+ // record used to be consulted only after the resolve had already failed, so the happy path
169
+ // was gated on npm reachability on every single launch.
170
+ const cached = readState();
171
+ if (isFresh(cached)) {
172
+ return { packageName: 'fraim', binName: 'fraim', version: cached.version, fromCache: true };
173
+ }
174
+
81
175
  const fraimVersion = resolveLatest('fraim');
82
176
  if (fraimVersion) {
83
177
  const state = { packageName: 'fraim', binName: 'fraim', version: fraimVersion, fromCache: false };
@@ -85,8 +179,7 @@ function resolvePackage() {
85
179
  return state;
86
180
  }
87
181
 
88
- const cached = readState();
89
- if (cached && cached.packageName === 'fraim' && cached.binName === 'fraim' && cached.version) {
182
+ if (isUsableState(cached)) {
90
183
  console.error('[fraim-mcp-launcher] Could not resolve npm latest; using cached fraim@' + cached.version + '.');
91
184
  return { packageName: 'fraim', binName: 'fraim', version: cached.version, fromCache: true };
92
185
  }
@@ -95,6 +188,37 @@ function resolvePackage() {
95
188
  process.exit(1);
96
189
  }
97
190
 
191
+ /**
192
+ * Keep the newest few per-version caches and age out the rest. Each entry is a full npm cache
193
+ * for one version (~80MB), plus one retry-<timestamp>-<pid> directory per Windows launch
194
+ * failure, so without a bound the directory grows once per release forever.
195
+ *
196
+ * A directory is removed only when it is BOTH outside the newest CACHE_GENERATIONS_KEPT and
197
+ * older than CACHE_MIN_AGE_MS. The age floor is what keeps this safe: the package is installed
198
+ * inside the cache directory, so a recent one may still be backing a running MCP server.
199
+ *
200
+ * Best effort throughout: a cache that cannot be removed is not a reason to fail the launch.
201
+ */
202
+ function pruneCaches(keepDir, nowMs) {
203
+ try {
204
+ const entries = fs.readdirSync(CACHE_ROOT, { withFileTypes: true })
205
+ .filter(function (entry) { return entry.isDirectory(); })
206
+ .map(function (entry) {
207
+ const full = path.join(CACHE_ROOT, entry.name);
208
+ let mtime = 0;
209
+ try { mtime = fs.statSync(full).mtimeMs; } catch (error) { mtime = 0; }
210
+ return { full: full, mtime: mtime };
211
+ })
212
+ .filter(function (entry) { return entry.full !== keepDir; })
213
+ .sort(function (a, b) { return b.mtime - a.mtime; });
214
+
215
+ for (const entry of entries.slice(Math.max(0, CACHE_GENERATIONS_KEPT - 1))) {
216
+ if (nowMs - entry.mtime < CACHE_MIN_AGE_MS) continue;
217
+ try { fs.rmSync(entry.full, { recursive: true, force: true }); } catch (error) { /* leave it */ }
218
+ }
219
+ } catch (error) { /* nothing to prune */ }
220
+ }
221
+
98
222
  function runPackage(plan, cacheDir) {
99
223
  ensureDir(cacheDir);
100
224
  const env = { ...process.env, npm_config_cache: cacheDir };
@@ -107,6 +231,7 @@ function runPackage(plan, cacheDir) {
107
231
 
108
232
  const plan = resolvePackage();
109
233
  const versionCacheDir = path.join(CACHE_ROOT, plan.packageName + '-' + plan.version.replace(/[^a-zA-Z0-9._-]/g, '_'));
234
+ pruneCaches(versionCacheDir, Date.now());
110
235
  let status = runPackage(plan, versionCacheDir);
111
236
 
112
237
  if (status !== 0 && process.platform === 'win32') {
@@ -132,6 +257,50 @@ function getFraimCliShimPath() {
132
257
  function getPackagedFraimRuntimePath() {
133
258
  return path_1.default.join(process.env.FRAIM_USER_DIR || path_1.default.join(os_1.default.homedir(), '.fraim'), 'bin', 'packaged-fraim-runtime.json');
134
259
  }
260
+ function getFraimMcpLauncherStatePath() {
261
+ return path_1.default.join(process.env.FRAIM_USER_DIR || path_1.default.join(os_1.default.homedir(), '.fraim'), 'mcp-launcher', 'latest.json');
262
+ }
263
+ /**
264
+ * Record `version` as the version the launcher should run, resetting the freshness window.
265
+ *
266
+ * This is the deliberate escape hatch from `STALENESS_WINDOW_MS`. The window is what keeps
267
+ * ordinary launches off the network, but it also means a published fix reaches an installed
268
+ * machine up to a window late. `fraim sync` calls this with the version it is itself running, so
269
+ * `npx fraim@latest sync` resolves latest, runs as latest, and records latest - which the next
270
+ * MCP server spawn then uses immediately instead of waiting out the window.
271
+ *
272
+ * The semantics are "the version you just ran is the version recorded", which is why the caller
273
+ * passes its own version rather than this function consulting the registry. A sync that ran an
274
+ * older package must not claim to have recorded a newer one.
275
+ *
276
+ * The record's shape is duplicated from the generated launcher's `writeState`, deliberately and
277
+ * unavoidably: that script executes standalone from `~/.fraim/bin` with only Node built-ins and
278
+ * cannot import this module. The two writers must stay in step, and
279
+ * `test-1354-mcp-launcher-freshness-window.ts` asserts that a record written here is accepted by
280
+ * the launcher rather than merely that the fields look alike.
281
+ */
282
+ function recordFraimMcpLauncherVersion(version) {
283
+ if (!/^\d+\.\d+\.\d+/.test(version))
284
+ return false;
285
+ const statePath = getFraimMcpLauncherStatePath();
286
+ try {
287
+ fs_1.default.mkdirSync(path_1.default.dirname(statePath), { recursive: true });
288
+ fs_1.default.writeFileSync(statePath, JSON.stringify({
289
+ packageName: 'fraim',
290
+ binName: 'fraim',
291
+ version,
292
+ fromCache: false,
293
+ launcherVersion: LAUNCHER_VERSION,
294
+ updatedAt: new Date().toISOString()
295
+ }, null, 2));
296
+ return true;
297
+ }
298
+ catch {
299
+ // Best effort. A sync that cannot write this record has still synced the catalog, and the
300
+ // launcher resolves normally when the window expires; failing the sync would be worse.
301
+ return false;
302
+ }
303
+ }
135
304
  const windowsFraimMcpShimSource = `@echo off
136
305
  setlocal
137
306
  for %%I in ("%~dp0..") do set "FRAIM_USER_DIR_VALUE=%%~fI"
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.installManagedAgent = installManagedAgent;
7
7
  const fs_1 = __importDefault(require("fs"));
8
+ const persist_shell_path_1 = require("./persist-shell-path");
8
9
  const path_1 = __importDefault(require("path"));
9
10
  const managed_agent_paths_1 = require("./managed-agent-paths");
10
11
  function errorMessage(error) {
@@ -104,7 +105,9 @@ async function runInstallWithStaleCleanupRetry(option, prefix, runInstall) {
104
105
  // check before invoking this — it only covers the install-then-fallback path.
105
106
  async function installManagedAgent(option, systemPath, deps) {
106
107
  let standardInstallError = null;
107
- const prefix = (0, managed_agent_paths_1.getPortableNodeBinPath)();
108
+ const portableBin = (0, managed_agent_paths_1.getPortableNodeBinPath)();
109
+ // npm writes Windows shims at the prefix root, but POSIX executables in prefix/bin.
110
+ const prefix = process.platform === 'win32' ? portableBin : path_1.default.dirname(portableBin);
108
111
  try {
109
112
  await deps.runProcess('npm', ['install', '-g', option.installPackage], {
110
113
  PATH: systemPath,
@@ -145,5 +148,6 @@ async function installManagedAgent(option, systemPath, deps) {
145
148
  if (!ver) {
146
149
  throw new Error(`${option.label} install completed, but the CLI is not runnable from FRAIM's managed PATH. Standard install failure: ${standardInstallError}`);
147
150
  }
151
+ (0, persist_shell_path_1.persistShellPath)();
148
152
  return { outcome: 'managed' };
149
153
  }
@@ -0,0 +1,121 @@
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.buildPersistShellPathWindowsCommand = buildPersistShellPathWindowsCommand;
7
+ exports.persistShellPath = persistShellPath;
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const os_1 = __importDefault(require("os"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const child_process_1 = require("child_process");
12
+ const script_sync_utils_1 = require("./script-sync-utils");
13
+ const managed_agent_paths_1 = require("./managed-agent-paths");
14
+ // Issue #1285: pure command-string builder extracted from persistShellPath()
15
+ // so the generated PowerShell command's directory order (the current
16
+ // versioned dir added before the legacy flat dir, per `bins`' own order —
17
+ // see getManagedAgentBinDirs()) can be asserted directly, without mutating
18
+ // the real registry-backed Windows User PATH in a test.
19
+ function buildPersistShellPathWindowsCommand(bins) {
20
+ const assignments = bins.map((entry, index) => `$bin${index} = '${entry.replace(/'/g, "''")}'`);
21
+ const removals = bins.map((_, index) => `$parts = @($parts | Where-Object { $_ -ne $bin${index} })`);
22
+ const additions = bins.map((_, index) => `$parts += $bin${index}`);
23
+ return [
24
+ `$ErrorActionPreference = 'Stop'`,
25
+ ...assignments,
26
+ `$cur = [Environment]::GetEnvironmentVariable('PATH', 'User')`,
27
+ `$parts = @($cur -split ';' | Where-Object { $_ })`,
28
+ ...removals,
29
+ ...additions,
30
+ `$cur = ($parts | Select-Object -Unique) -join ';'`,
31
+ `[Environment]::SetEnvironmentVariable('PATH', $cur, 'User')`,
32
+ ].join('; ');
33
+ }
34
+ function persistShellPath() {
35
+ const marker = '# FRAIM managed binaries';
36
+ const legacyExportLine = 'export PATH=”$HOME/.fraim/node/bin:$PATH”';
37
+ // The stable CLI shim must outrank old prefix installs already on inherited PATH.
38
+ // Agent bins still follow the user's PATH, preserving system-agent precedence.
39
+ const bins = [path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'bin'), ...(0, managed_agent_paths_1.getManagedAgentBinDirs)()];
40
+ const shellBins = bins.map(bin => {
41
+ const relative = path_1.default.relative((0, script_sync_utils_1.getUserFraimDir)(), bin).split(path_1.default.sep).join('/');
42
+ return '$HOME/.fraim/' + relative.replace(/[\\"$`]/g, '\\$&');
43
+ });
44
+ const exportLine = `export PATH="${shellBins[0]}:$PATH:${shellBins.slice(1).join(':')}"`;
45
+ // Guard against editor-autocorrect curly-quote regression (issue #1688):
46
+ // U+201C/201D/2018/2019 in exportLine would be written verbatim to shell
47
+ // profiles and break PATH resolution for all pre-existing PATH entries.
48
+ if (/[“”‘’]/.test(exportLine)) {
49
+ throw new Error(`persistShellPath: exportLine contains curly quotes. Writing this to a shell profile would corrupt PATH resolution. Check for editor autocorrect in persist-shell-path.ts. Line: ${JSON.stringify(exportLine)}`);
50
+ }
51
+ const stanza = `\n${marker}\n${exportLine}\n`;
52
+ if (process.platform === 'win32') {
53
+ const psCmd = buildPersistShellPathWindowsCommand(bins);
54
+ // Issue #1692: a sandboxed test run (row detection on load, node row
55
+ // success, fraim row success all reach this function) must never write
56
+ // its temp fake-home bin dirs into the real, registry-backed Windows
57
+ // User PATH. FRAIM_TEST_SANDBOX is already the canonical "this process
58
+ // is a sandboxed test run" signal set by every test runner (see
59
+ // getUserFraimDirPath() in project-fraim-paths.ts) rather than a
60
+ // dedicated flag for this call site.
61
+ if (process.env.FRAIM_TEST_SANDBOX !== '1') {
62
+ const result = (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCmd], { encoding: 'utf8', timeout: 10000 });
63
+ if (result.error || result.status !== 0) {
64
+ throw new Error(`Could not register managed binaries on the User PATH: ${result.error?.message || result.stderr}`);
65
+ }
66
+ }
67
+ // Fall through to write POSIX profile files for Git Bash (VS Code
68
+ // integrated terminal, Claude Code, etc.) which do not inherit the
69
+ // Windows user PATH written above.
70
+ }
71
+ // Test runners isolate FRAIM_USER_DIR but may leave os.homedir() unchanged.
72
+ const sandboxDir = process.env.FRAIM_USER_DIR ? path_1.default.resolve(process.env.FRAIM_USER_DIR) : null;
73
+ const profileHome = process.env.FRAIM_TEST_SANDBOX === '1'
74
+ ? (sandboxDir && (path_1.default.basename(sandboxDir) === '.fraim' ? path_1.default.dirname(sandboxDir) : sandboxDir))
75
+ : os_1.default.homedir();
76
+ if (!profileHome)
77
+ return;
78
+ fs_1.default.mkdirSync(profileHome, { recursive: true });
79
+ const profiles = ['.zshrc', '.bash_profile', '.bashrc'].map(name => path_1.default.join(profileHome, name));
80
+ // macOS defaults to zsh since Catalina — create .zshrc if it doesn't exist.
81
+ if (process.platform === 'darwin' && !fs_1.default.existsSync(profiles[0])) {
82
+ fs_1.default.writeFileSync(profiles[0], stanza, 'utf8');
83
+ }
84
+ // On Windows, Git Bash reads .bash_profile (login) and .bashrc (interactive).
85
+ // Neither is created by the OS or Git Bash installer, so create .bashrc if
86
+ // neither exists — otherwise the PATH entry is never written and Git Bash
87
+ // (VS Code integrated terminal, Claude Code) never sees managed node dirs.
88
+ if (process.platform !== 'darwin') {
89
+ const bashProfile = profiles[1]; // .bash_profile
90
+ const bashrc = profiles[2]; // .bashrc
91
+ if (!fs_1.default.existsSync(bashProfile) && !fs_1.default.existsSync(bashrc)) {
92
+ fs_1.default.writeFileSync(bashrc, stanza, 'utf8');
93
+ }
94
+ }
95
+ for (const profile of profiles) {
96
+ // .zshrc is macOS/Linux only; Git Bash on Windows uses .bash_profile/.bashrc.
97
+ if (process.platform === 'win32' && profile.endsWith('.zshrc'))
98
+ continue;
99
+ try {
100
+ if (!fs_1.default.existsSync(profile))
101
+ continue;
102
+ const content = fs_1.default.readFileSync(profile, 'utf8');
103
+ if (content.includes(legacyExportLine)) {
104
+ fs_1.default.writeFileSync(profile, content.replace(legacyExportLine, exportLine), 'utf8');
105
+ continue;
106
+ }
107
+ if (content.includes(marker)) {
108
+ if (!content.includes(exportLine)) {
109
+ // Replace stale runtime directories or an older shim ordering.
110
+ fs_1.default.writeFileSync(profile, content.replace(/export PATH=[\u201D"][^\u201D"]*\.fraim[^\u201D"]*[\u201D"]/, exportLine), 'utf8');
111
+ }
112
+ continue;
113
+ }
114
+ fs_1.default.appendFileSync(profile, stanza, 'utf8');
115
+ }
116
+ catch {
117
+ // Ignore unreadable or missing profiles.
118
+ }
119
+ }
120
+ (0, managed_agent_paths_1.invalidateMachinePathCache)();
121
+ }
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ /**
3
+ * Credential redaction for log sinks (issue #1701, SEC-LEAK `63bc2c102800`).
4
+ *
5
+ * A FRAIM API key is a bearer credential: anything that writes one to a console,
6
+ * a log file, or a structured log record has disclosed it to everyone who can
7
+ * read that sink. Every log statement that wants to identify a caller must go
8
+ * through `redactApiKey` (for a known key) or `apiKeyRefFromRequest` (for an
9
+ * HTTP request) instead of interpolating the key itself.
10
+ *
11
+ * The reference is an unsalted SHA-256 prefix rather than a last-four suffix:
12
+ * - it leaks no key material at all, where a suffix leaks 16 bits;
13
+ * - it is stable, so the same caller correlates across the server log, the MCP
14
+ * error log, and the local proxy's own log on the user's machine;
15
+ * - it is unsalted deliberately, because the local proxy runs in a different
16
+ * process from the server and must derive the same reference without sharing
17
+ * a secret. Keys carry 128 bits of entropy (`generateApiKey` in
18
+ * `src/fraim/db-service.ts`), so the digest is not brute-forceable.
19
+ *
20
+ * Fingerprinting every value, including one an unauthenticated caller supplied,
21
+ * also removes CRLF log-forging: no caller-controlled bytes reach a log line.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.NO_CREDENTIAL_REF = void 0;
25
+ exports.redactApiKey = redactApiKey;
26
+ exports.redactSecretsInText = redactSecretsInText;
27
+ exports.apiKeyRefFromRequest = apiKeyRefFromRequest;
28
+ const crypto_1 = require("crypto");
29
+ /** Prefix every FRAIM-issued API key carries. */
30
+ const FRAIM_KEY_PREFIX = 'fraim_';
31
+ /** Hex characters kept from the digest. 48 bits: collision-free at any realistic key population. */
32
+ const REFERENCE_HEX_CHARS = 12;
33
+ /** What a log line shows when the request carried no credential at all. */
34
+ exports.NO_CREDENTIAL_REF = 'key_none';
35
+ /**
36
+ * A FRAIM key embedded in free-form text. Matched loosely on the character class
37
+ * so a truncated, uppercased, or future-format key is still caught.
38
+ */
39
+ const EMBEDDED_FRAIM_KEY = /fraim_[A-Za-z0-9]{16,}/g;
40
+ /**
41
+ * Non-reversible, stable reference for an API key, safe to write to any log sink.
42
+ * Returns `NO_CREDENTIAL_REF` when there is no credential to reference.
43
+ */
44
+ function redactApiKey(value) {
45
+ if (typeof value !== 'string')
46
+ return exports.NO_CREDENTIAL_REF;
47
+ const trimmed = value.trim();
48
+ if (trimmed.length === 0)
49
+ return exports.NO_CREDENTIAL_REF;
50
+ const digest = (0, crypto_1.createHash)('sha256').update(trimmed, 'utf8').digest('hex');
51
+ return `key_${digest.slice(0, REFERENCE_HEX_CHARS)}`;
52
+ }
53
+ /**
54
+ * Replace every credential-shaped value embedded in free-form text with its
55
+ * reference. Use on text that is logged but not authored here: request bodies,
56
+ * error messages, and stack traces. Idempotent.
57
+ */
58
+ function redactSecretsInText(text) {
59
+ if (typeof text !== 'string' || !text.includes(FRAIM_KEY_PREFIX))
60
+ return text;
61
+ return text.replace(EMBEDDED_FRAIM_KEY, (match) => redactApiKey(match));
62
+ }
63
+ /**
64
+ * Reference for whichever credential a request carried, in precedence order:
65
+ * the value auth resolved, then the `x-api-key` header, then the `api-key` query
66
+ * parameter. The authenticated value wins so a forged header cannot rewrite the
67
+ * identity a log line reports.
68
+ *
69
+ * Shared by the request logger and the MCP error handler so both report the same
70
+ * reference for the same caller.
71
+ */
72
+ function apiKeyRefFromRequest(req) {
73
+ if (!req)
74
+ return exports.NO_CREDENTIAL_REF;
75
+ const fromContext = req.apiKeyData?.key;
76
+ if (typeof fromContext === 'string' && fromContext.trim().length > 0) {
77
+ return redactApiKey(fromContext);
78
+ }
79
+ const headerValue = req.headers?.['x-api-key'];
80
+ const fromHeader = Array.isArray(headerValue) ? headerValue[0] : headerValue;
81
+ if (typeof fromHeader === 'string' && fromHeader.trim().length > 0) {
82
+ return redactApiKey(fromHeader);
83
+ }
84
+ const fromQuery = req.query?.['api-key'];
85
+ if (typeof fromQuery === 'string' && fromQuery.trim().length > 0) {
86
+ return redactApiKey(fromQuery);
87
+ }
88
+ return exports.NO_CREDENTIAL_REF;
89
+ }
@@ -38,7 +38,6 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.FIRST_RUN_ROW_IDS = exports.FirstRunSessionService = void 0;
40
40
  exports.commandVersion = commandVersion;
41
- exports.buildPersistShellPathWindowsCommand = buildPersistShellPathWindowsCommand;
42
41
  exports.buildFirstRunHubLaunchArgs = buildFirstRunHubLaunchArgs;
43
42
  const fs_1 = __importDefault(require("fs"));
44
43
  const os_1 = __importDefault(require("os"));
@@ -52,6 +51,7 @@ const setup_1 = require("../cli/commands/setup");
52
51
  const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launcher");
53
52
  const script_sync_utils_1 = require("../cli/utils/script-sync-utils");
54
53
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
54
+ const persist_shell_path_1 = require("../cli/utils/persist-shell-path");
55
55
  const managed_agent_install_1 = require("../cli/utils/managed-agent-install");
56
56
  const managed_node_runtime_1 = require("../cli/utils/managed-node-runtime");
57
57
  const types_1 = require("./types");
@@ -109,120 +109,6 @@ function ensureOutputDirs() {
109
109
  appendInstallLog(`orphaned-managed-shims-removed ${removedShims.join(', ')}`);
110
110
  }
111
111
  })();
112
- // Issue #1285: pure command-string builder extracted from persistShellPath()
113
- // so the generated PowerShell command's directory order (the current
114
- // versioned dir added before the legacy flat dir, per `bins`' own order —
115
- // see getManagedAgentBinDirs()) can be asserted directly, without mutating
116
- // the real registry-backed Windows User PATH in a test.
117
- function buildPersistShellPathWindowsCommand(bins) {
118
- const assignments = bins.map((entry, index) => `$bin${index} = '${entry.replace(/'/g, "''")}'`);
119
- const removals = bins.map((_, index) => `$parts = @($parts | Where-Object { $_ -ne $bin${index} })`);
120
- const additions = bins.map((_, index) => `$parts += $bin${index}`);
121
- return [
122
- ...assignments,
123
- `$cur = [Environment]::GetEnvironmentVariable('PATH', 'User')`,
124
- `$parts = @($cur -split ';' | Where-Object { $_ })`,
125
- ...removals,
126
- ...additions,
127
- `$cur = ($parts | Select-Object -Unique) -join ';'`,
128
- `[Environment]::SetEnvironmentVariable('PATH', $cur, 'User')`,
129
- ].join('; ');
130
- }
131
- function persistShellPath(includePackagedFraimBin = false) {
132
- const marker = '# FRAIM managed binaries';
133
- const legacyExportLine = 'export PATH=”$HOME/.fraim/node/bin:$PATH”';
134
- // On Windows/Git Bash: add both the flat dir (bare POSIX agent scripts npm
135
- // generates) and the versioned node subdir (node.exe). $HOME in Git Bash
136
- // resolves to the Windows home directory, matching the registry paths.
137
- let exportLine;
138
- if (process.platform === 'win32') {
139
- const portableNodeBin = (0, managed_agent_paths_1.getPortableNodeBinPath)();
140
- const nodeRoot = (0, managed_agent_paths_1.getManagedNodeRoot)();
141
- const fraimBinEntry = includePackagedFraimBin ? ':$HOME/.fraim/bin' : '';
142
- // Versioned dir first (has fresh installs, matches getManagedAgentBinDirs()
143
- // order), flat dir second (retains bare POSIX agent scripts as fallback).
144
- exportLine = portableNodeBin !== nodeRoot
145
- ? `export PATH="$PATH:$HOME/.fraim/node/${path_1.default.basename(portableNodeBin)}:$HOME/.fraim/node${fraimBinEntry}"`
146
- : `export PATH="$PATH:$HOME/.fraim/node${fraimBinEntry}"`;
147
- }
148
- else {
149
- exportLine = includePackagedFraimBin
150
- ? 'export PATH="$PATH:$HOME/.fraim/bin:$HOME/.fraim/node/bin"'
151
- : 'export PATH="$PATH:$HOME/.fraim/node/bin"';
152
- }
153
- // Guard against editor-autocorrect curly-quote regression (issue #1688):
154
- // U+201C/201D/2018/2019 in exportLine would be written verbatim to shell
155
- // profiles and break PATH resolution for all pre-existing PATH entries.
156
- if (/[“”‘’]/.test(exportLine)) {
157
- throw new Error(`persistShellPath: exportLine contains curly quotes. Writing this to a shell profile would corrupt PATH resolution. Check for editor autocorrect in session-service.ts. Line: ${JSON.stringify(exportLine)}`);
158
- }
159
- const stanza = `\n${marker}\n${exportLine}\n`;
160
- if (process.platform === 'win32') {
161
- const bins = includePackagedFraimBin
162
- ? [path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'bin'), ...(0, managed_agent_paths_1.getManagedAgentBinDirs)()]
163
- : (0, managed_agent_paths_1.getManagedAgentBinDirs)();
164
- const psCmd = buildPersistShellPathWindowsCommand(bins);
165
- // Issue #1692: a sandboxed test run (row detection on load, node row
166
- // success, fraim row success all reach this function) must never write
167
- // its temp fake-home bin dirs into the real, registry-backed Windows
168
- // User PATH. FRAIM_TEST_SANDBOX is already the canonical "this process
169
- // is a sandboxed test run" signal set by every test runner (see
170
- // getUserFraimDirPath() in project-fraim-paths.ts) rather than a
171
- // dedicated flag for this call site.
172
- if (process.env.FRAIM_TEST_SANDBOX !== '1') {
173
- (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', psCmd], { encoding: 'utf8' });
174
- }
175
- // Fall through to write POSIX profile files for Git Bash (VS Code
176
- // integrated terminal, Claude Code, etc.) which do not inherit the
177
- // Windows user PATH written above.
178
- }
179
- const profiles = [
180
- path_1.default.join(os_1.default.homedir(), '.zshrc'),
181
- path_1.default.join(os_1.default.homedir(), '.bash_profile'),
182
- path_1.default.join(os_1.default.homedir(), '.bashrc'),
183
- ];
184
- // macOS defaults to zsh since Catalina — create .zshrc if it doesn't exist.
185
- if (process.platform === 'darwin' && !fs_1.default.existsSync(profiles[0])) {
186
- fs_1.default.writeFileSync(profiles[0], stanza, 'utf8');
187
- }
188
- // On Windows, Git Bash reads .bash_profile (login) and .bashrc (interactive).
189
- // Neither is created by the OS or Git Bash installer, so create .bashrc if
190
- // neither exists — otherwise the PATH entry is never written and Git Bash
191
- // (VS Code integrated terminal, Claude Code) never sees managed node dirs.
192
- if (process.platform === 'win32') {
193
- const bashProfile = profiles[1]; // .bash_profile
194
- const bashrc = profiles[2]; // .bashrc
195
- if (!fs_1.default.existsSync(bashProfile) && !fs_1.default.existsSync(bashrc)) {
196
- fs_1.default.writeFileSync(bashrc, stanza, 'utf8');
197
- }
198
- }
199
- for (const profile of profiles) {
200
- // .zshrc is macOS/Linux only; Git Bash on Windows uses .bash_profile/.bashrc.
201
- if (process.platform === 'win32' && profile.endsWith('.zshrc'))
202
- continue;
203
- try {
204
- if (!fs_1.default.existsSync(profile))
205
- continue;
206
- const content = fs_1.default.readFileSync(profile, 'utf8');
207
- if (content.includes(legacyExportLine)) {
208
- fs_1.default.writeFileSync(profile, content.replace(legacyExportLine, exportLine), 'utf8');
209
- continue;
210
- }
211
- if (content.includes(marker)) {
212
- if (!content.includes(exportLine)) {
213
- // Stale export line: versioned dir changed, includePackagedFraimBin
214
- // toggled, or platform-specific line was never written. Replace it.
215
- fs_1.default.writeFileSync(profile, content.replace(/export PATH=[\u201D"][^\u201D"]*\.fraim[^\u201D"]*[\u201D"]/, exportLine), 'utf8');
216
- }
217
- continue;
218
- }
219
- fs_1.default.appendFileSync(profile, stanza, 'utf8');
220
- }
221
- catch {
222
- // Ignore unreadable or missing profiles.
223
- }
224
- }
225
- }
226
112
  function getNextPromptPath() {
227
113
  return path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'last-install', 'next-prompt.txt');
228
114
  }
@@ -426,7 +312,7 @@ class FirstRunSessionService {
426
312
  fraimRow.status = 'pending';
427
313
  fraimRow.verb = "we'll configure your IDEs";
428
314
  }
429
- persistShellPath(this.embeddedDesktop);
315
+ (0, persist_shell_path_1.persistShellPath)();
430
316
  }
431
317
  else {
432
318
  fraimRow.status = 'pending';
@@ -676,7 +562,7 @@ class FirstRunSessionService {
676
562
  this.persist();
677
563
  return this.respond('FRAIM installer repair is required.', false);
678
564
  }
679
- persistShellPath(this.embeddedDesktop);
565
+ (0, persist_shell_path_1.persistShellPath)();
680
566
  row.status = 'ok';
681
567
  row.verb = 'Node and FRAIM launchers ready';
682
568
  this.persist();
@@ -686,7 +572,7 @@ class FirstRunSessionService {
686
572
  // If node is missing here something is severely wrong; fall through to error.
687
573
  const ver = commandVersion('node');
688
574
  if (ver) {
689
- persistShellPath(this.embeddedDesktop);
575
+ (0, persist_shell_path_1.persistShellPath)();
690
576
  row.status = 'ok';
691
577
  row.verb = `${ver} installed`;
692
578
  this.persist();
@@ -718,7 +604,7 @@ class FirstRunSessionService {
718
604
  this.persist();
719
605
  return this.respond('git not found — continuing without it. Install git later if you plan to do code delivery work.', true);
720
606
  }
721
- async runFraimRow(forceConfigure = false) {
607
+ async runFraimRow() {
722
608
  const row = this.getRow('fraim');
723
609
  if (this.fakeMode) {
724
610
  row.status = 'ok';
@@ -747,21 +633,9 @@ class FirstRunSessionService {
747
633
  if (this.embeddedDesktop && (!fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)()) || !fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)()))) {
748
634
  throw new Error('Stable FRAIM launchers are missing. Re-run the native installer in repair mode.');
749
635
  }
750
- else if (!this.embeddedDesktop && !forceConfigure && !commandVersion('fraim')) {
751
- const prefix = path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
752
- fs_1.default.mkdirSync(prefix, { recursive: true });
753
- row.streamOutput = 'Installing FRAIM on this machine...';
754
- this.persist();
755
- await runProcess('npm', ['install', '-g', 'fraim@latest'], { npm_config_prefix: prefix });
756
- }
757
- else if (forceConfigure && !commandVersion('fraim')) {
758
- const prefix = path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
759
- fs_1.default.mkdirSync(prefix, { recursive: true });
760
- row.streamOutput = 'Installing FRAIM on this machine...';
761
- this.persist();
762
- await runProcess('npm', ['install', '-g', 'fraim@latest'], { npm_config_prefix: prefix });
763
- }
764
- persistShellPath(this.embeddedDesktop);
636
+ if (!this.embeddedDesktop)
637
+ (0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
638
+ (0, persist_shell_path_1.persistShellPath)();
765
639
  const detectedIDEs = (0, ide_detector_1.detectInstalledIDEs)();
766
640
  if (detectedIDEs.length > 0) {
767
641
  await (0, auto_mcp_setup_1.autoConfigureMCP)(this.key, detectedIDEs.map((ide) => ide.name));
@@ -1009,7 +883,7 @@ class FirstRunSessionService {
1009
883
  fraimRow.verb = 'configuring FRAIM for local AI agents';
1010
884
  this.persist();
1011
885
  appendInstallLog(`agent-recruiting-complete ready=${readyCount}`);
1012
- return this.runFraimRow(true);
886
+ return this.runFraimRow();
1013
887
  }
1014
888
  openTerminalWithCommand(command) {
1015
889
  if (process.platform === 'win32') {
@@ -62,6 +62,7 @@ const crypto_1 = require("crypto");
62
62
  const axios_1 = __importDefault(require("axios"));
63
63
  const provider_utils_1 = require("../core/utils/provider-utils");
64
64
  const object_utils_1 = require("../core/utils/object-utils");
65
+ const redact_secrets_1 = require("../core/utils/redact-secrets");
65
66
  const pack_home_1 = require("../cli/utils/pack-home");
66
67
  const local_registry_resolver_1 = require("../core/utils/local-registry-resolver");
67
68
  const ai_mentor_1 = require("../core/ai-mentor");
@@ -553,7 +554,7 @@ class FraimLocalMCPServer {
553
554
  }
554
555
  this.log('🚀 FRAIM Local MCP Server starting... [DEBUG-PROXY-V3]');
555
556
  this.log(`📡 Remote server: ${this.remoteUrl}`);
556
- this.log(`🔑 API key: ${this.apiKey.substring(0, 10)}...`);
557
+ this.log(`🔑 API key: ${(0, redact_secrets_1.redactApiKey)(this.apiKey)}`);
557
558
  this.log(`Local MCP version: ${this.localVersion}`);
558
559
  this.log(`🔍 DEBUG BUILD: Machine detection v2 active`);
559
560
  // Initialize usage collector
@@ -626,23 +627,35 @@ class FraimLocalMCPServer {
626
627
  this.log(`Could not persist userEmail: ${e instanceof Error ? e.message : String(e)}`);
627
628
  }
628
629
  }
630
+ /**
631
+ * Non-reversible reference for this proxy's key (issue #1701). Every line the
632
+ * proxy writes carries it, and the proxy mirrors those lines into a temp file
633
+ * that survives the process, so the key itself must never appear here. The
634
+ * reference matches the one the server logs for the same key, so a user's local
635
+ * log still correlates with the server-side log.
636
+ */
637
+ keyRef() {
638
+ return this.apiKey ? (0, redact_secrets_1.redactApiKey)(this.apiKey) : 'MISSING_API_KEY';
639
+ }
629
640
  log(message) {
630
641
  // Log to stderr (stdout is reserved for MCP protocol)
631
- const key = this.apiKey || 'MISSING_API_KEY';
632
- console.error(`[FRAIM key:${key}] ${message}`);
642
+ const keyRef = this.keyRef();
643
+ // Messages are assembled from remote responses and caught errors, either of
644
+ // which can carry a credential this proxy did not author.
645
+ const safeMessage = (0, redact_secrets_1.redactSecretsInText)(message);
646
+ console.error(`[FRAIM key:${keyRef}] ${safeMessage}`);
633
647
  // Also log to file for debugging
634
648
  try {
635
649
  const fs = require('fs');
636
650
  const logFile = require('path').join(require('os').tmpdir(), 'fraim-mcp-proxy.log');
637
- fs.appendFileSync(logFile, `${new Date().toISOString()} [${key}] ${message}\n`);
651
+ fs.appendFileSync(logFile, `${new Date().toISOString()} [${keyRef}] ${safeMessage}\n`);
638
652
  }
639
653
  catch (e) {
640
654
  // Ignore file logging errors
641
655
  }
642
656
  }
643
657
  logError(message) {
644
- const key = this.apiKey || 'MISSING_API_KEY';
645
- console.error(`[FRAIM ERROR key:${key}] ${message}`);
658
+ console.error(`[FRAIM ERROR key:${this.keyRef()}] ${(0, redact_secrets_1.redactSecretsInText)(message)}`);
646
659
  }
647
660
  detectLocalVersion() {
648
661
  const candidates = [
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AuthMiddleware = void 0;
4
+ const redact_secrets_1 = require("../core/utils/redact-secrets");
4
5
  const fs_1 = require("fs");
5
6
  const path_1 = require("path");
6
7
  const cookie_service_1 = require("../services/cookie-service");
@@ -219,7 +220,7 @@ class AuthMiddleware {
219
220
  try {
220
221
  const apiKeyData = await this.dbService.verifyApiKey(apiKey);
221
222
  if (!apiKeyData) {
222
- console.error(`❌ FRAIM AUTH: Invalid API key: ${apiKey}`);
223
+ console.error(`❌ FRAIM AUTH: Invalid API key: ${(0, redact_secrets_1.redactApiKey)(apiKey)}`);
223
224
  setMcpAuthenticateChallenge();
224
225
  return res.status(401).json({
225
226
  jsonrpc: '2.0',
@@ -230,7 +231,7 @@ class AuthMiddleware {
230
231
  // Check if key is suspended
231
232
  if (apiKeyData.status === 'suspended') {
232
233
  const reason = apiKeyData.suspensionReason || 'payment failure';
233
- console.error(`❌ FRAIM AUTH: Suspended API key: ${apiKey} (reason: ${reason})`);
234
+ console.error(`❌ FRAIM AUTH: Suspended API key: ${(0, redact_secrets_1.redactApiKey)(apiKey)} (reason: ${reason})`);
234
235
  return res.status(402).json({
235
236
  jsonrpc: '2.0',
236
237
  error: {
@@ -255,7 +256,7 @@ class AuthMiddleware {
255
256
  const message = tier === 'trial'
256
257
  ? 'Your 14-day free trial has expired. Please upgrade to continue using FRAIM.'
257
258
  : 'Your subscription has expired. Please renew to continue using FRAIM.';
258
- console.error(`❌ FRAIM AUTH: Expired API key: ${apiKey} (tier: ${tier})`);
259
+ console.error(`❌ FRAIM AUTH: Expired API key: ${(0, redact_secrets_1.redactApiKey)(apiKey)} (tier: ${tier})`);
259
260
  return res.status(402).json({
260
261
  jsonrpc: '2.0',
261
262
  error: {
@@ -35,38 +35,21 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.requestLogger = requestLogger;
37
37
  const crypto_1 = require("crypto");
38
+ const redact_secrets_1 = require("../core/utils/redact-secrets");
38
39
  const fs = __importStar(require("fs"));
39
40
  const path = __importStar(require("path"));
40
41
  const LOG_FILE = path.join(process.cwd(), 'server-direct.log');
41
- /**
42
- * Extracts API key from request for logging purposes
43
- */
44
- function getApiKeyForLogging(req) {
45
- const fromContext = req.apiKeyData?.key;
46
- if (typeof fromContext === 'string' && fromContext.trim().length > 0) {
47
- return fromContext;
48
- }
49
- const fromHeader = Array.isArray(req.headers['x-api-key'])
50
- ? req.headers['x-api-key'][0]
51
- : req.headers['x-api-key'];
52
- if (typeof fromHeader === 'string' && fromHeader.trim().length > 0) {
53
- return fromHeader;
54
- }
55
- const fromQuery = req.query?.['api-key'];
56
- if (typeof fromQuery === 'string' && fromQuery.trim().length > 0) {
57
- return fromQuery;
58
- }
59
- return 'ANONYMOUS';
60
- }
61
42
  function getBodyForLogging(req) {
62
43
  if (req.path === '/api/ai-hub/brand' && req.body && typeof req.body === 'object') {
63
44
  const body = { ...req.body };
64
45
  if (typeof body.logo === 'string' && body.logo.length > 0) {
65
46
  body.logo = `[redacted logo payload, ${body.logo.length} chars]`;
66
47
  }
67
- return JSON.stringify(body);
48
+ return (0, redact_secrets_1.redactSecretsInText)(JSON.stringify(body));
68
49
  }
69
- return JSON.stringify(req.body);
50
+ // Bodies are logged verbatim; a caller that puts a credential in one must not
51
+ // have it land in the log in the clear (issue #1701).
52
+ return (0, redact_secrets_1.redactSecretsInText)(JSON.stringify(req.body));
70
53
  }
71
54
  /**
72
55
  * Middleware to log all requests
@@ -118,8 +101,8 @@ function requestLogger(req, res, next) {
118
101
  res.on('finish', () => {
119
102
  const duration = Date.now() - start;
120
103
  const status = res.statusCode;
121
- const keyStr = getApiKeyForLogging(req);
122
- const logMsg = `[req:${requestId}] [local:${localMcpVersion}] ${keyStr} END ${method} ${path} ${status} ${duration}ms`;
104
+ const keyRef = (0, redact_secrets_1.apiKeyRefFromRequest)(req);
105
+ const logMsg = `[req:${requestId}] [local:${localMcpVersion}] ${keyRef} END ${method} ${path} ${status} ${duration}ms`;
123
106
  logEntry(logMsg);
124
107
  });
125
108
  next();
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TelemetryMiddleware = void 0;
4
+ const redact_secrets_1 = require("../core/utils/redact-secrets");
4
5
  class TelemetryMiddleware {
5
6
  constructor(sessionManager) {
6
7
  this.sessionManager = sessionManager;
@@ -125,7 +126,7 @@ After successful fraim_connect, all other FRAIM tools will work. This is require
125
126
  id: req.body?.id || null
126
127
  });
127
128
  }
128
- console.log(`Telemetry active for ${apiKey}`);
129
+ console.log(`Telemetry active for ${(0, redact_secrets_1.redactApiKey)(apiKey)}`);
129
130
  return next();
130
131
  }
131
132
  catch (error) {
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SessionManager = void 0;
4
+ const redact_secrets_1 = require("../core/utils/redact-secrets");
4
5
  class SessionManager {
5
6
  constructor(dbService) {
6
7
  this.sessions = new Map();
@@ -42,7 +43,7 @@ class SessionManager {
42
43
  : await this.dbService.getActiveSessionByApiKey(apiKey);
43
44
  if (dbSession) {
44
45
  sessionKey = this.getSessionKey(apiKey, dbSession.sessionId);
45
- console.log(`Re-hydrating session for API Key: ${apiKey}, sessionId: ${dbSession.sessionId}`);
46
+ console.log(`Re-hydrating session for API Key: ${(0, redact_secrets_1.redactApiKey)(apiKey)}, sessionId: ${dbSession.sessionId}`);
46
47
  session = {
47
48
  sessionId: dbSession.sessionId,
48
49
  lastWrite: now,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.327",
3
+ "version": "2.0.328",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {