fraim-framework 2.0.196 → 2.0.198

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.
@@ -0,0 +1,232 @@
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.MAX_LOGO_BYTES = exports.ORG_BRAND_FILENAME = void 0;
7
+ exports.resolveBrandColor = resolveBrandColor;
8
+ exports.relativeLuminance = relativeLuminance;
9
+ exports.contrastRatio = contrastRatio;
10
+ exports.hasSufficientContrast = hasSufficientContrast;
11
+ exports.sanitizeSvg = sanitizeSvg;
12
+ exports.sanitizeLogo = sanitizeLogo;
13
+ exports.normalizeBrand = normalizeBrand;
14
+ exports.serializeBrand = serializeBrand;
15
+ exports.parseBrand = parseBrand;
16
+ exports.readBrandFromDir = readBrandFromDir;
17
+ exports.writeBrandToDir = writeBrandToDir;
18
+ /**
19
+ * Org brand store — Issue #744 (Hub cobranding).
20
+ *
21
+ * Deterministic, dependency-free, AI-free logic (architecture-standards §1/§3):
22
+ * the org brand descriptor (company name, color, logo) is stored ALONGSIDE
23
+ * company info, in the same org context storage as `org_context.md`
24
+ * (manager directive, spec R7.4), as a single `org_brand.json` file.
25
+ *
26
+ * Security: a manager-supplied logo is a stored-XSS vector. `sanitizeSvg` uses a
27
+ * strict element/attribute allowlist (not a denylist) and drops scripts, event
28
+ * handlers, external references, styles, and any non-graphical element, so the
29
+ * stored logo is safe to inline in the Hub (spec Compliance: SOC2/ISO27001).
30
+ */
31
+ const fs_1 = __importDefault(require("fs"));
32
+ const path_1 = __importDefault(require("path"));
33
+ /** File name of the brand descriptor, stored next to org_context.md. */
34
+ exports.ORG_BRAND_FILENAME = 'org_brand.json';
35
+ /** Max stored logo size (sanitized SVG markup or data: URI), in bytes. */
36
+ exports.MAX_LOGO_BYTES = 512 * 1024;
37
+ // ── Color ───────────────────────────────────────────────────────────────────
38
+ /**
39
+ * Normalize a hex color to lowercase `#rrggbb`, expanding `#rgb`. Returns null
40
+ * for anything that is not a valid 3- or 6-digit hex (named colors, functions,
41
+ * and injection attempts are rejected).
42
+ */
43
+ function resolveBrandColor(input) {
44
+ if (typeof input !== 'string')
45
+ return null;
46
+ const raw = input.trim().replace(/^#/, '').toLowerCase();
47
+ if (/^[0-9a-f]{3}$/.test(raw)) {
48
+ return '#' + raw.split('').map((c) => c + c).join('');
49
+ }
50
+ if (/^[0-9a-f]{6}$/.test(raw)) {
51
+ return '#' + raw;
52
+ }
53
+ return null;
54
+ }
55
+ function hexToRgb(hex) {
56
+ const norm = resolveBrandColor(hex);
57
+ if (!norm)
58
+ return null;
59
+ const n = norm.slice(1);
60
+ return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
61
+ }
62
+ /** WCAG relative luminance of an sRGB color. */
63
+ function relativeLuminance(hex) {
64
+ const rgb = hexToRgb(hex);
65
+ if (!rgb)
66
+ return 0;
67
+ const [r, g, b] = rgb.map((v) => {
68
+ const s = v / 255;
69
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
70
+ });
71
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
72
+ }
73
+ /** WCAG contrast ratio between two colors (1..21). */
74
+ function contrastRatio(a, b) {
75
+ const la = relativeLuminance(a);
76
+ const lb = relativeLuminance(b);
77
+ const lighter = Math.max(la, lb);
78
+ const darker = Math.min(la, lb);
79
+ return (lighter + 0.05) / (darker + 0.05);
80
+ }
81
+ /**
82
+ * True when `color` is readable as a UI accent against `background`. Uses the
83
+ * WCAG 3:1 threshold for graphical/large UI objects (the accent is used on
84
+ * filled buttons and tab underlines, not as body text). Guards R2.3 so an
85
+ * unreadable brand color never ships; callers fall back to the FRAIM accent.
86
+ */
87
+ function hasSufficientContrast(color, background, min = 3.0) {
88
+ if (!resolveBrandColor(color) || !resolveBrandColor(background))
89
+ return false;
90
+ return contrastRatio(color, background) >= min;
91
+ }
92
+ // ── SVG sanitization (allowlist) ──────────────────────────────────────────────
93
+ /** Only these elements survive sanitization. */
94
+ const ALLOWED_SVG_TAGS = new Set([
95
+ 'svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon',
96
+ 'text', 'tspan', 'defs', 'lineargradient', 'radialgradient', 'stop', 'title', 'desc',
97
+ ]);
98
+ /** Only these attributes survive; everything else (incl. on*, href, style) is dropped. */
99
+ const ALLOWED_SVG_ATTRS = new Set([
100
+ 'viewbox', 'xmlns', 'width', 'height', 'fill', 'fill-rule', 'fill-opacity', 'stroke',
101
+ 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-opacity',
102
+ 'opacity', 'd', 'points', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry',
103
+ 'transform', 'offset', 'stop-color', 'stop-opacity', 'gradientunits', 'gradienttransform',
104
+ 'font-family', 'font-size', 'font-weight', 'text-anchor', 'letter-spacing',
105
+ ]);
106
+ /**
107
+ * Sanitize an SVG string via a strict allowlist. Returns safe SVG markup, or ''
108
+ * if the input is not an <svg> document. Removes: <script>, <foreignObject>,
109
+ * <image>, <use>, <a>, comments/PIs/DOCTYPE, all event handlers, all href /
110
+ * xlink:href, inline styles, and any element or attribute not on the allowlist.
111
+ */
112
+ function sanitizeSvg(input) {
113
+ if (typeof input !== 'string')
114
+ return '';
115
+ // Bound the work up front so a pathological logo can't drive the tag-walk
116
+ // regex into catastrophic backtracking (DoS guard).
117
+ if (input.length > exports.MAX_LOGO_BYTES)
118
+ return '';
119
+ let s = input.trim();
120
+ if (!/^<svg[\s>]/i.test(s))
121
+ return '';
122
+ // Drop comments, CDATA, processing instructions, and DOCTYPE outright.
123
+ s = s.replace(/<!--[\s\S]*?-->/g, '');
124
+ s = s.replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, '');
125
+ s = s.replace(/<\?[\s\S]*?\?>/g, '');
126
+ s = s.replace(/<!DOCTYPE[\s\S]*?>/gi, '');
127
+ // Drop dangerous elements and their entire content.
128
+ s = s.replace(/<(script|style|foreignObject|iframe|use|image|a)\b[\s\S]*?<\/\1\s*>/gi, '');
129
+ // Drop self-closing / unclosed variants of the same dangerous elements.
130
+ s = s.replace(/<(script|style|foreignObject|iframe|use|image|a)\b[^>]*\/?>/gi, '');
131
+ // Walk every remaining tag; drop non-allowlisted elements and attributes.
132
+ s = s.replace(/<\/?([a-zA-Z][\w:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (full, rawName, rawAttrs) => {
133
+ const name = rawName.toLowerCase().replace(/^svg:/, '');
134
+ if (!ALLOWED_SVG_TAGS.has(name))
135
+ return '';
136
+ const isClose = /^<\//.test(full);
137
+ if (isClose)
138
+ return `</${name}>`;
139
+ const selfClose = /\/>$/.test(full);
140
+ const attrs = [];
141
+ const attrRe = /([a-zA-Z_:][\w:.-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
142
+ let m;
143
+ while ((m = attrRe.exec(rawAttrs)) !== null) {
144
+ const attr = m[1].toLowerCase().replace(/^xml:/, '');
145
+ const value = m[3] !== undefined ? m[3] : m[4] || '';
146
+ if (!ALLOWED_SVG_ATTRS.has(attr))
147
+ continue;
148
+ // Defense in depth: never allow url()/javascript:/data: sneaking through a value.
149
+ if (/javascript:|url\s*\(|data:|expression\s*\(|<|>/i.test(value))
150
+ continue;
151
+ attrs.push(`${attr}="${value.replace(/"/g, '&quot;')}"`);
152
+ }
153
+ const attrStr = attrs.length ? ' ' + attrs.join(' ') : '';
154
+ return `<${name}${attrStr}${selfClose ? '/' : ''}>`;
155
+ });
156
+ // If sanitization emptied the root, treat as invalid.
157
+ if (!/^<svg[\s>]/i.test(s.trim()))
158
+ return '';
159
+ return s.trim();
160
+ }
161
+ /** Accept only png/jpeg data URIs (sanitized SVG is handled separately). */
162
+ function isSafeRasterDataUri(s) {
163
+ return /^data:image\/(png|jpe?g);base64,[a-z0-9+/=\s]+$/i.test(s);
164
+ }
165
+ /** Sanitize a logo value: SVG markup -> sanitized SVG; png/jpeg data URI -> kept; else ''. */
166
+ function sanitizeLogo(input) {
167
+ if (typeof input !== 'string' || !input.trim())
168
+ return '';
169
+ const s = input.trim();
170
+ if (/^<svg[\s>]/i.test(s)) {
171
+ const clean = sanitizeSvg(s);
172
+ return clean.length <= exports.MAX_LOGO_BYTES ? clean : '';
173
+ }
174
+ if (isSafeRasterDataUri(s)) {
175
+ return s.length <= exports.MAX_LOGO_BYTES ? s : '';
176
+ }
177
+ return '';
178
+ }
179
+ // ── Serialize / parse ─────────────────────────────────────────────────────────
180
+ /** Normalize + sanitize a brand into its safe stored form. */
181
+ function normalizeBrand(brand) {
182
+ const out = {};
183
+ if (typeof brand.name === 'string' && brand.name.trim()) {
184
+ out.name = brand.name.trim().slice(0, 120);
185
+ }
186
+ const color = resolveBrandColor(brand.color);
187
+ if (color)
188
+ out.color = color;
189
+ const logo = sanitizeLogo(brand.logo);
190
+ if (logo)
191
+ out.logo = logo;
192
+ return out;
193
+ }
194
+ function serializeBrand(brand) {
195
+ return JSON.stringify(normalizeBrand(brand), null, 2);
196
+ }
197
+ /** Parse a brand JSON string into a normalized OrgBrand, or null on any error. */
198
+ function parseBrand(json) {
199
+ if (typeof json !== 'string' || !json.trim())
200
+ return null;
201
+ let obj;
202
+ try {
203
+ obj = JSON.parse(json);
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ if (!obj || typeof obj !== 'object')
209
+ return null;
210
+ const norm = normalizeBrand(obj);
211
+ return Object.keys(norm).length ? norm : null;
212
+ }
213
+ // ── Directory read / write ─────────────────────────────────────────────────────
214
+ /** Read + normalize the brand from `<dir>/org_brand.json`, or null when absent/invalid. */
215
+ function readBrandFromDir(dir) {
216
+ try {
217
+ const file = path_1.default.join(dir, exports.ORG_BRAND_FILENAME);
218
+ if (!fs_1.default.existsSync(file))
219
+ return null;
220
+ return parseBrand(fs_1.default.readFileSync(file, 'utf8'));
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ }
226
+ /** Sanitize + normalize a brand and write it to `<dir>/org_brand.json`. Returns the stored brand. */
227
+ function writeBrandToDir(dir, brand) {
228
+ const normalized = normalizeBrand(brand);
229
+ fs_1.default.mkdirSync(dir, { recursive: true });
230
+ fs_1.default.writeFileSync(path_1.default.join(dir, exports.ORG_BRAND_FILENAME), JSON.stringify(normalized, null, 2), 'utf8');
231
+ return normalized;
232
+ }
@@ -37,6 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.AiHubServer = exports.HostConfigStore = exports.DeploymentStore = void 0;
40
+ exports.configureFraimForHubAgent = configureFraimForHubAgent;
40
41
  exports.findAvailablePort = findAvailablePort;
41
42
  exports.findAvailablePortExcluding = findAvailablePortExcluding;
42
43
  const express_1 = __importDefault(require("express"));
@@ -49,6 +50,7 @@ const child_process_1 = require("child_process");
49
50
  const https_1 = __importDefault(require("https"));
50
51
  const types_1 = require("../first-run/types");
51
52
  const learning_context_builder_1 = require("../local-mcp-server/learning-context-builder");
53
+ const brand_store_1 = require("./brand-store");
52
54
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
53
55
  const catalog_1 = require("./catalog");
54
56
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
@@ -861,6 +863,37 @@ function hubAgentOption(hubId) {
861
863
  const frId = HUB_TO_FIRST_RUN_ID[hubId];
862
864
  return frId ? types_1.FIRST_RUN_AGENT_OPTIONS.find((o) => o.id === frId) : undefined;
863
865
  }
866
+ /**
867
+ * Issue #747: after the Hub installs an agent CLI, run the `add-ide` command for that agent so the
868
+ * FRAIM MCP (plus slash commands / rules) is wired into its config and its first run works.
869
+ * Previously the install only ran `npm install -g` + a version probe, so the agent launched with
870
+ * no `fraim` MCP server. This invokes the same `runAddIDE` the `fraim add-ide` CLI runs — scoped to
871
+ * the just-installed agent — rather than re-implementing the MCP-config write.
872
+ *
873
+ * `runAddIDE` reads the FRAIM key from ~/.fraim/config.json (written by setup/first-run) and
874
+ * `process.exit(1)`s when it is missing. We guard on that here via the exported `loadGlobalConfig`
875
+ * so a missing key degrades to a logged no-op instead of terminating the long-running Hub server.
876
+ * `skipTokenPrompts` keeps the run fully non-interactive.
877
+ */
878
+ async function configureFraimForHubAgent(hubId) {
879
+ const frId = HUB_TO_FIRST_RUN_ID[hubId];
880
+ if (!frId)
881
+ return { configured: false, error: `Unknown hub agent: ${hubId}` };
882
+ try {
883
+ const { runAddIDE, loadGlobalConfig } = await Promise.resolve().then(() => __importStar(require('../cli/commands/add-ide')));
884
+ const config = await loadGlobalConfig();
885
+ if (!config?.fraimKey) {
886
+ return { configured: false, error: 'No FRAIM key in ~/.fraim/config.json; run `fraim setup` first.' };
887
+ }
888
+ // `frId` (e.g. 'claude-code', 'codex', 'gemini-cli', 'copilot-cli') is a valid add-ide
889
+ // `--ide` name/alias; runAddIDE resolves and configures it even when not yet detected.
890
+ await runAddIDE({ ide: frId, skipTokenPrompts: true });
891
+ return { configured: true, ideName: frId };
892
+ }
893
+ catch (e) {
894
+ return { configured: false, error: e instanceof Error ? e.message : String(e) };
895
+ }
896
+ }
864
897
  function hubCommandVersion(command, extraBinDirs) {
865
898
  const executable = process.platform === 'win32' ? 'cmd.exe' : command;
866
899
  const args = process.platform === 'win32'
@@ -1447,6 +1480,9 @@ class AiHubServer {
1447
1480
  // #533: the resolved account email, so the profile card shows the real
1448
1481
  // identity (not a hardcoded placeholder) and the client can display it.
1449
1482
  userEmail: getHubUserEmail(),
1483
+ // #744: the org cobrand identity (name/color/logo) from the org context
1484
+ // storage, or null when unset so the Hub falls back to FRAIM identity.
1485
+ orgBrand: (0, learning_context_builder_1.readOrgBrand)(normalizedProjectPath),
1450
1486
  assignments: { byProject: {}, source: 'client-localStorage' },
1451
1487
  // Issue #538 — source of truth for the "Hire a human manager" UI. Lazy-required
1452
1488
  // (not a top-level import) so the lightweight client CLI paths that import this
@@ -2302,7 +2338,15 @@ class AiHubServer {
2302
2338
  ? path_1.default.resolve(req.query.projectPath)
2303
2339
  : this.projectPath;
2304
2340
  const known = this.knownProjects(projectPath);
2305
- const entry = known.find((project) => project.id === req.params.id);
2341
+ // Match by id, falling back to the canonical folderPath. The client and server
2342
+ // hash a path to an id with different algorithms, and the client mints its own id
2343
+ // for the CURRENT project (tfEnsureCurrentProject), so a delete-by-id can carry an
2344
+ // id the server never assigned — the folderPath is the true project identity (#726).
2345
+ const requestedFolderPath = typeof req.query.folderPath === 'string' && req.query.folderPath.length > 0
2346
+ ? req.query.folderPath
2347
+ : null;
2348
+ const entry = known.find((project) => project.id === req.params.id)
2349
+ || (requestedFolderPath ? known.find((project) => sameDirectoryPath(project.folderPath, requestedFolderPath)) : undefined);
2306
2350
  if (!entry)
2307
2351
  return res.status(404).json({ error: 'Project not found.' });
2308
2352
  // The current projectPath is unconditionally re-injected on every load, so
@@ -2443,6 +2487,45 @@ class AiHubServer {
2443
2487
  // Re-read so the client gets the canonical post-write state (present flips).
2444
2488
  return res.json(readContextFile(projectPath, body.key));
2445
2489
  });
2490
+ // Issue #744 — Hub cobranding.
2491
+ // GET /api/ai-hub/brand → { brand: OrgBrand | null } resolved from the org
2492
+ // context storage (same layering as org_context.md).
2493
+ // POST /api/ai-hub/brand { name?, color?, logo?, projectPath? } → sanitizes
2494
+ // + normalizes (SVG allowlist, hex normalize) and persists org_brand.json to
2495
+ // the org context write location. Loopback-only; write is constrained under
2496
+ // a personalized-employee/ directory (same path-traversal guard as context).
2497
+ this.app.get('/api/ai-hub/brand', (req, res) => {
2498
+ const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2499
+ ? path_1.default.resolve(req.query.projectPath)
2500
+ : this.projectPath;
2501
+ return res.json({ brand: (0, learning_context_builder_1.readOrgBrand)(projectPath) });
2502
+ });
2503
+ this.app.post('/api/ai-hub/brand', (req, res) => {
2504
+ const body = (req.body ?? {});
2505
+ const projectPath = typeof body.projectPath === 'string' && body.projectPath.length > 0
2506
+ ? path_1.default.resolve(body.projectPath)
2507
+ : this.projectPath;
2508
+ const input = {
2509
+ name: typeof body.name === 'string' ? body.name : undefined,
2510
+ color: typeof body.color === 'string' ? body.color : undefined,
2511
+ logo: typeof body.logo === 'string' ? body.logo : undefined,
2512
+ };
2513
+ const dest = path_1.default.resolve((0, learning_context_builder_1.resolveOrgBrandWriteDir)(projectPath));
2514
+ // Path-traversal guard: the write dir must live under a personalized-employee
2515
+ // directory (matches the context write guard; never the managed org cache).
2516
+ if (!dest.split(path_1.default.sep).includes('personalized-employee')) {
2517
+ return res.status(403).json({ error: 'Brand write destination outside personalized-employee.' });
2518
+ }
2519
+ try {
2520
+ // writeBrandToDir sanitizes the logo (SVG allowlist) and normalizes the
2521
+ // color before persisting, so unsafe input never reaches disk.
2522
+ const stored = (0, brand_store_1.writeBrandToDir)(dest, input);
2523
+ return res.json({ brand: stored });
2524
+ }
2525
+ catch (err) {
2526
+ return res.status(500).json({ error: err instanceof Error ? err.message : 'Brand write failed.' });
2527
+ }
2528
+ });
2446
2529
  this.app.post('/api/ai-hub/artifact/open', async (req, res) => {
2447
2530
  const rawPath = typeof req.body?.path === 'string' ? req.body.path : '';
2448
2531
  const projectPath = typeof req.body?.projectPath === 'string' && req.body.projectPath.length > 0
@@ -2478,12 +2561,19 @@ class AiHubServer {
2478
2561
  if (!ver) {
2479
2562
  throw new Error(`${option.label} install completed, but the CLI is not runnable from FRAIM's managed PATH.`);
2480
2563
  }
2564
+ // Issue #747: run `add-ide` for the newly installed agent so the FRAIM MCP is wired in
2565
+ // and its first run works (previously the agent launched with no `fraim` MCP server).
2566
+ const mcp = await configureFraimForHubAgent(hubId);
2567
+ if (!mcp.configured) {
2568
+ console.warn(`[ai-hub] install-agent: FRAIM add-ide did not run for ${option.label}: ${mcp.error || 'unknown reason'}`);
2569
+ }
2481
2570
  return res.json({
2482
2571
  ok: true,
2483
2572
  message: `${option.label} installed successfully.`,
2484
2573
  needsLogin: true,
2485
2574
  loginCommand: option.loginCommand,
2486
2575
  loginHint: `Sign in to ${option.label} to activate it. A terminal window will open — complete sign-in there, then click "Check if Ready".`,
2576
+ fraimConfigured: mcp.configured,
2487
2577
  });
2488
2578
  }
2489
2579
  catch (error) {
@@ -0,0 +1,283 @@
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.FRAIM_PROXY_LOG_FILE = exports.DEFAULT_FRAIM_REMOTE_URL = void 0;
7
+ exports.fingerprintSecret = fingerprintSecret;
8
+ exports.appendLimited = appendLimited;
9
+ exports.sanitizeDiagnosticText = sanitizeDiagnosticText;
10
+ exports.readFileTailUtf8 = readFileTailUtf8;
11
+ exports.buildAgentDebugSteps = buildAgentDebugSteps;
12
+ exports.interpretationForClassification = interpretationForClassification;
13
+ exports.analyzeFraimProxyLog = analyzeFraimProxyLog;
14
+ exports.diagnoseFraimServerConfigConsistency = diagnoseFraimServerConfigConsistency;
15
+ const crypto_1 = __importDefault(require("crypto"));
16
+ const fs_1 = __importDefault(require("fs"));
17
+ exports.DEFAULT_FRAIM_REMOTE_URL = 'https://fraim.wellnessatwork.me';
18
+ exports.FRAIM_PROXY_LOG_FILE = 'fraim-mcp-proxy.log';
19
+ function fingerprintSecret(secret) {
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)
24
+ };
25
+ }
26
+ function normalizeUrl(url) {
27
+ return url.replace(/\/+$/, '');
28
+ }
29
+ function appendLimited(current, chunk, maxChars = 8000) {
30
+ const next = current + chunk;
31
+ return next.length > maxChars ? next.slice(-maxChars) : next;
32
+ }
33
+ function sanitizeDiagnosticText(text, knownSecrets = []) {
34
+ let sanitized = text;
35
+ for (const secret of knownSecrets.filter(Boolean)) {
36
+ sanitized = sanitized.split(secret).join(`${secret.slice(0, 10)}...`);
37
+ }
38
+ return sanitized.replace(/fraim_[A-Za-z0-9_-]+/g, (match) => `${match.slice(0, 10)}...`);
39
+ }
40
+ function readFileTailUtf8(filePath, maxBytes = 256 * 1024) {
41
+ const stat = fs_1.default.statSync(filePath);
42
+ const fileSize = stat.size;
43
+ const bytesToRead = Math.min(fileSize, maxBytes);
44
+ const buffer = Buffer.alloc(bytesToRead);
45
+ const fd = fs_1.default.openSync(filePath, 'r');
46
+ try {
47
+ fs_1.default.readSync(fd, buffer, 0, bytesToRead, Math.max(0, fileSize - bytesToRead));
48
+ }
49
+ finally {
50
+ fs_1.default.closeSync(fd);
51
+ }
52
+ return {
53
+ content: buffer.toString('utf8'),
54
+ bytesRead: bytesToRead,
55
+ fileSize
56
+ };
57
+ }
58
+ function buildAgentDebugSteps(classification) {
59
+ switch (classification) {
60
+ case 'no-log-file':
61
+ return [
62
+ 'Run the MCP client once, then inspect the FRAIM proxy log path from this diagnostic.',
63
+ 'If the log file is still absent, the IDE is not launching the FRAIM stdio proxy.'
64
+ ];
65
+ case 'no-recent-proxy-lines':
66
+ return [
67
+ 'No recent FRAIM proxy lifecycle lines were found. Restart the IDE and trigger one FRAIM tool call.',
68
+ 'Then rerun: fraim doctor --test-mcp --json.'
69
+ ];
70
+ case 'no-log-lines-for-expected-key':
71
+ return [
72
+ 'Recent proxy log lines belong to a different API key than the key doctor resolved.',
73
+ 'Check the IDE MCP config env.FRAIM_API_KEY and regenerate it with: fraim add-ide.'
74
+ ];
75
+ case 'local-proxy-started-not-ready':
76
+ return [
77
+ 'The FRAIM stdio proxy started but did not reach the ready state.',
78
+ 'Inspect stderr and the proxy log for startup errors before changing server settings.'
79
+ ];
80
+ case 'local-proxy-ready-no-initialize':
81
+ return [
82
+ 'The FRAIM stdio proxy was ready, but the IDE MCP client did not send initialize/tools traffic.',
83
+ 'Check whether the IDE loaded the correct MCP settings file, then fully restart the IDE.'
84
+ ];
85
+ case 'stdio-closed-before-request':
86
+ return [
87
+ 'The IDE launched the FRAIM stdio proxy and closed stdin before sending an MCP request.',
88
+ 'Check the MCP server command/args shape and whether the IDE rejected the stdio process during startup.'
89
+ ];
90
+ case 'message-processing-failed':
91
+ return [
92
+ 'The local proxy received malformed or unsupported stdio input.',
93
+ 'Check whether the MCP client is using newline-delimited JSON-RPC stdio, not HTTP/SSE configuration.'
94
+ ];
95
+ case 'remote-request-failed':
96
+ return [
97
+ 'The local proxy did send a request, but the remote server rejected or failed it.',
98
+ 'Use the request id and remote status in details to correlate with production logs.'
99
+ ];
100
+ case 'local-proxy-forwarded-to-remote':
101
+ return [
102
+ 'The local proxy sent MCP traffic to the remote server.',
103
+ 'If the IDE still reports disconnected, inspect the IDE-side MCP/tool-discovery logs after the remote response.'
104
+ ];
105
+ default:
106
+ return [
107
+ 'The proxy log did not match a known failure pattern.',
108
+ 'Collect the last 200 proxy log lines and rerun doctor with --json for structured details.'
109
+ ];
110
+ }
111
+ }
112
+ function interpretationForClassification(classification) {
113
+ switch (classification) {
114
+ case 'no-log-file':
115
+ return 'No FRAIM proxy log exists, so no local proxy startup has been observed on this machine.';
116
+ case 'no-recent-proxy-lines':
117
+ return 'A proxy log exists, but it does not contain usable recent FRAIM lifecycle lines.';
118
+ case 'no-log-lines-for-expected-key':
119
+ return 'The local proxy log has recent activity, but not for the API key doctor resolved for this run.';
120
+ case 'local-proxy-started-not-ready':
121
+ return 'The local proxy appears to have started but did not finish startup.';
122
+ case 'local-proxy-ready-no-initialize':
123
+ return 'The local proxy started successfully, but no MCP initialize/tools request reached it.';
124
+ case 'stdio-closed-before-request':
125
+ return 'The MCP client opened the local proxy process but closed stdio before any request was processed.';
126
+ case 'message-processing-failed':
127
+ return 'The local proxy received input but could not parse/process it as MCP JSON-RPC.';
128
+ case 'remote-request-failed':
129
+ return 'The local proxy reached the remote MCP endpoint, and the failure is now remote/auth/network-side.';
130
+ case 'local-proxy-forwarded-to-remote':
131
+ return 'The local proxy has forwarded MCP traffic to the remote endpoint.';
132
+ default:
133
+ return 'The proxy log did not match a known FRAIM MCP startup pattern.';
134
+ }
135
+ }
136
+ function analyzeFraimProxyLog(logContent, options = {}) {
137
+ const maxLines = options.maxLines ?? 300;
138
+ const allLines = logContent.split(/\r?\n/).map(line => line.trim()).filter(Boolean).slice(-maxLines);
139
+ if (allLines.length === 0) {
140
+ const classification = 'no-recent-proxy-lines';
141
+ return {
142
+ classification,
143
+ interpretation: interpretationForClassification(classification),
144
+ agentDebugSteps: buildAgentDebugSteps(classification),
145
+ matchingLineCount: 0,
146
+ startingSeen: false,
147
+ readySeen: false,
148
+ stdinClosedSeen: false,
149
+ parseErrorCount: 0,
150
+ proxyingCount: 0,
151
+ remoteFailureCount: 0
152
+ };
153
+ }
154
+ const keyLineRegex = /^\S+\s+\[([^\]]+)\]\s+(.*)$/;
155
+ const matchingLines = options.expectedKey
156
+ ? allLines.filter(line => keyLineRegex.exec(line)?.[1] === options.expectedKey)
157
+ : allLines;
158
+ if (options.expectedKey && matchingLines.length === 0) {
159
+ const classification = 'no-log-lines-for-expected-key';
160
+ return {
161
+ classification,
162
+ interpretation: interpretationForClassification(classification),
163
+ agentDebugSteps: buildAgentDebugSteps(classification),
164
+ matchingLineCount: 0,
165
+ startingSeen: false,
166
+ readySeen: false,
167
+ stdinClosedSeen: false,
168
+ parseErrorCount: 0,
169
+ proxyingCount: 0,
170
+ remoteFailureCount: 0
171
+ };
172
+ }
173
+ let startingSeen = false;
174
+ let readySeen = false;
175
+ let stdinClosedSeen = false;
176
+ let parseErrorCount = 0;
177
+ let proxyingCount = 0;
178
+ let remoteFailureCount = 0;
179
+ let lastTimestamp;
180
+ let lastMethod;
181
+ let lastRemoteUrl;
182
+ let lastRemoteStatus;
183
+ for (const line of matchingLines) {
184
+ const timestamp = line.split(/\s+/, 1)[0];
185
+ if (timestamp)
186
+ lastTimestamp = timestamp;
187
+ if (line.includes('FRAIM Local MCP Server starting'))
188
+ startingSeen = true;
189
+ if (line.includes('FRAIM Local MCP Server ready'))
190
+ readySeen = true;
191
+ if (line.includes('Stdin closed'))
192
+ stdinClosedSeen = true;
193
+ if (line.includes('Message processing failed'))
194
+ parseErrorCount += 1;
195
+ const proxyingMatch = line.match(/\[req:([^\]]+)\]\s+Proxying\s+([^\s]+)\s+to\s+([^\s]+)/);
196
+ if (proxyingMatch) {
197
+ proxyingCount += 1;
198
+ lastMethod = proxyingMatch[2];
199
+ lastRemoteUrl = proxyingMatch[3];
200
+ }
201
+ const remoteFailureMatch = line.match(/\[req:[^\]]+\]\s+Remote request failed\s+\(([^)]*)\):/);
202
+ if (remoteFailureMatch) {
203
+ remoteFailureCount += 1;
204
+ lastRemoteStatus = remoteFailureMatch[1];
205
+ }
206
+ }
207
+ let classification = 'unknown';
208
+ if (parseErrorCount > 0) {
209
+ classification = 'message-processing-failed';
210
+ }
211
+ else if (remoteFailureCount > 0) {
212
+ classification = 'remote-request-failed';
213
+ }
214
+ else if (proxyingCount > 0) {
215
+ classification = 'local-proxy-forwarded-to-remote';
216
+ }
217
+ else if (stdinClosedSeen) {
218
+ classification = 'stdio-closed-before-request';
219
+ }
220
+ else if (readySeen) {
221
+ classification = 'local-proxy-ready-no-initialize';
222
+ }
223
+ else if (startingSeen) {
224
+ classification = 'local-proxy-started-not-ready';
225
+ }
226
+ return {
227
+ classification,
228
+ interpretation: interpretationForClassification(classification),
229
+ agentDebugSteps: buildAgentDebugSteps(classification),
230
+ matchingLineCount: matchingLines.length,
231
+ startingSeen,
232
+ readySeen,
233
+ stdinClosedSeen,
234
+ parseErrorCount,
235
+ proxyingCount,
236
+ remoteFailureCount,
237
+ lastTimestamp,
238
+ lastMethod,
239
+ lastRemoteUrl,
240
+ lastRemoteStatus
241
+ };
242
+ }
243
+ function diagnoseFraimServerConfigConsistency(fraimServer, options = {}) {
244
+ const configuredKey = fraimServer?.env?.FRAIM_API_KEY ? String(fraimServer.env.FRAIM_API_KEY) : '';
245
+ const configuredRemoteUrl = String(fraimServer?.env?.FRAIM_REMOTE_URL || exports.DEFAULT_FRAIM_REMOTE_URL);
246
+ const expectedRemoteUrl = options.expectedRemoteUrl || process.env.FRAIM_REMOTE_URL || exports.DEFAULT_FRAIM_REMOTE_URL;
247
+ const apiKeyMatchesExpected = options.expectedApiKey && configuredKey
248
+ ? configuredKey === options.expectedApiKey
249
+ : null;
250
+ const remoteUrlMatchesExpected = normalizeUrl(configuredRemoteUrl) === normalizeUrl(expectedRemoteUrl);
251
+ const isLocalRemote = configuredRemoteUrl.includes('localhost') || configuredRemoteUrl.includes('127.0.0.1');
252
+ const details = {
253
+ apiKeyMatchesExpected,
254
+ remoteUrlMatchesExpected,
255
+ configuredRemoteUrl,
256
+ expectedRemoteUrl,
257
+ configuredKeyFingerprint: configuredKey ? fingerprintSecret(configuredKey) : null,
258
+ expectedKeyFingerprint: options.expectedApiKey ? fingerprintSecret(options.expectedApiKey) : null
259
+ };
260
+ const issues = [];
261
+ if (apiKeyMatchesExpected === false) {
262
+ issues.push('configured FRAIM_API_KEY differs from the key resolved by doctor');
263
+ }
264
+ if (!remoteUrlMatchesExpected) {
265
+ issues.push('configured FRAIM_REMOTE_URL differs from the expected remote URL');
266
+ }
267
+ if (isLocalRemote) {
268
+ issues.push('configured FRAIM_REMOTE_URL points at a local server');
269
+ }
270
+ if (issues.length === 0) {
271
+ return {
272
+ status: 'passed',
273
+ message: 'FRAIM MCP config key and remote URL are consistent',
274
+ details
275
+ };
276
+ }
277
+ return {
278
+ status: 'warning',
279
+ message: `FRAIM MCP config consistency warning: ${issues.join('; ')}`,
280
+ suggestion: 'Regenerate IDE MCP settings with: fraim add-ide',
281
+ details
282
+ };
283
+ }