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.
- package/dist/src/ai-hub/brand-store.js +232 -0
- package/dist/src/ai-hub/server.js +91 -1
- package/dist/src/cli/doctor/checks/fraim-mcp-diagnostics.js +283 -0
- package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +224 -18
- package/dist/src/cli/utils/org-pack-sync.js +35 -5
- package/dist/src/first-run/types.js +1 -1
- package/dist/src/local-mcp-server/learning-context-builder.js +41 -0
- package/package.json +1 -1
- package/public/ai-hub/index.html +17 -1
- package/public/ai-hub/script.js +441 -21
- package/public/ai-hub/styles.css +67 -0
- package/public/first-run/script.js +98 -25
- package/public/first-run/styles.css +34 -8
|
@@ -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, '"')}"`);
|
|
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
|
-
|
|
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
|
+
}
|