fraim-hub 2.0.275 → 2.0.277
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/catalog.js +33 -35
- package/dist/src/ai-hub/configured-agent-command.js +152 -0
- package/dist/src/ai-hub/configured-agents.js +65 -13
- package/dist/src/ai-hub/hosts.js +104 -4
- package/dist/src/ai-hub/hub-latest-version.js +35 -6
- package/dist/src/ai-hub/manager-turns.js +12 -0
- package/dist/src/ai-hub/server.js +117 -60
- package/dist/src/cli/setup/ide-global-integration.js +24 -15
- package/dist/src/cli/utils/pack-home.js +40 -0
- package/package.json +2 -2
- package/public/ai-hub/index.html +19 -0
- package/public/ai-hub/script.js +334 -67
- package/public/ai-hub/styles.css +99 -5
|
@@ -15,6 +15,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
15
15
|
const path_1 = __importDefault(require("path"));
|
|
16
16
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
17
17
|
const resolve_phase_edge_1 = require("../core/resolve-phase-edge");
|
|
18
|
+
const pack_home_1 = require("../cli/utils/pack-home");
|
|
18
19
|
// Directories scanned for employee jobs at runtime, in lowest-to-highest
|
|
19
20
|
// precedence order. Later entries win on {categoryId, jobId} collision.
|
|
20
21
|
//
|
|
@@ -24,27 +25,12 @@ const resolve_phase_edge_1 = require("../core/resolve-phase-edge");
|
|
|
24
25
|
// - <project>/fraim/ai-employee/jobs/<category>/ - synced baseline
|
|
25
26
|
// - <project>/fraim/personalized-employee/jobs/<category>/ - taught/customized override
|
|
26
27
|
// Only the personalized-employee layer is "personalized" (issue #566).
|
|
27
|
-
// Issue #1002: the machine-level layers are added here so a capability authored
|
|
28
|
-
// at the manager level is DISCOVERABLE, not merely resolvable by exact name.
|
|
29
|
-
// Order matters and mirrors LocalRegistryResolver.resolveFile precedence
|
|
30
|
-
// inverted, because later entries win on a {categoryId, jobId} collision:
|
|
31
|
-
// synced baseline < company cache < manager synced cache < manager local < project
|
|
32
|
-
// A job that resolves from the manager level must therefore also DISPLAY as the
|
|
33
|
-
// manager's, otherwise the rail would show a baseline job the runtime never runs.
|
|
34
28
|
const EMPLOYEE_JOB_LAYERS = [
|
|
35
29
|
{ segments: ['ai-employee', 'jobs'], personalized: false },
|
|
36
|
-
{ base: 'user', segments: ['org', 'jobs'], personalized: true, scope: 'org' },
|
|
37
|
-
{ base: 'user', segments: ['manager', 'jobs'], personalized: true, scope: 'manager' },
|
|
38
|
-
{ base: 'user', segments: ['personalized-employee', 'jobs'], personalized: true, scope: 'manager' },
|
|
39
|
-
{ segments: ['personalized-employee', 'jobs'], personalized: true, scope: 'project' },
|
|
40
30
|
];
|
|
41
31
|
// Manager templates use the matching layer model.
|
|
42
32
|
const MANAGER_JOB_LAYERS = [
|
|
43
33
|
{ segments: ['ai-manager', 'jobs'], personalized: false },
|
|
44
|
-
{ base: 'user', segments: ['org', 'manager-jobs'], personalized: true, scope: 'org' },
|
|
45
|
-
{ base: 'user', segments: ['manager', 'manager-jobs'], personalized: true, scope: 'manager' },
|
|
46
|
-
{ base: 'user', segments: ['personalized-employee', 'manager-jobs'], personalized: true, scope: 'manager' },
|
|
47
|
-
{ segments: ['personalized-employee', 'manager-jobs'], personalized: true, scope: 'project' },
|
|
48
34
|
];
|
|
49
35
|
const REGISTRY_EMPLOYEE_JOB_LAYERS = [
|
|
50
36
|
{ base: 'project', segments: ['registry', 'jobs', 'ai-employee'], personalized: false },
|
|
@@ -108,7 +94,6 @@ function resolveExtendedStubPath(projectPath, extendsValue) {
|
|
|
108
94
|
path_1.default.join(projectPath, 'fraim', 'ai-manager', 'jobs'),
|
|
109
95
|
path_1.default.join(projectPath, 'registry', 'jobs', 'ai-employee'),
|
|
110
96
|
path_1.default.join(projectPath, 'registry', 'jobs', 'ai-manager'),
|
|
111
|
-
path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-employee', 'jobs'),
|
|
112
97
|
];
|
|
113
98
|
for (const root of candidateRoots) {
|
|
114
99
|
const candidate = path_1.default.join(root, `${relative}.md`);
|
|
@@ -185,13 +170,13 @@ function readMarkdownFileNames(dirPath) {
|
|
|
185
170
|
* as `~/.fraim/...` rather than as a long `../../..` walk out of the project,
|
|
186
171
|
* which is what path.relative would otherwise produce for a user-level layer.
|
|
187
172
|
*/
|
|
188
|
-
function stubDisplayPath(filePath, projectPath,
|
|
189
|
-
if (!
|
|
173
|
+
function stubDisplayPath(filePath, projectPath, displayRoot, displayPrefix) {
|
|
174
|
+
if (!displayRoot || !displayPrefix)
|
|
190
175
|
return toPosix(path_1.default.relative(projectPath, filePath));
|
|
191
|
-
const rel = toPosix(path_1.default.relative(
|
|
192
|
-
return (
|
|
176
|
+
const rel = toPosix(path_1.default.relative(displayRoot, filePath));
|
|
177
|
+
return rel ? `${displayPrefix.replace(/\/$/, '')}/${rel}` : displayPrefix;
|
|
193
178
|
}
|
|
194
|
-
function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personalized, scope,
|
|
179
|
+
function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personalized, scope, displayRoot, displayPrefix) {
|
|
195
180
|
const content = fs_1.default.readFileSync(filePath, 'utf8');
|
|
196
181
|
const fileName = path_1.default.basename(filePath, '.md');
|
|
197
182
|
const frontmatter = readJobFrontmatter(filePath);
|
|
@@ -206,12 +191,12 @@ function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personal
|
|
|
206
191
|
categoryLabel,
|
|
207
192
|
intent,
|
|
208
193
|
outcome,
|
|
209
|
-
stubPath: stubDisplayPath(filePath, projectPath,
|
|
194
|
+
stubPath: stubDisplayPath(filePath, projectPath, displayRoot, displayPrefix),
|
|
210
195
|
personalized: !!personalized,
|
|
211
196
|
...(scope ? { scope } : {}),
|
|
212
197
|
};
|
|
213
198
|
}
|
|
214
|
-
function parseManagerStub(filePath, groupId, groupLabel, projectPath, scope,
|
|
199
|
+
function parseManagerStub(filePath, groupId, groupLabel, projectPath, scope, displayRoot, displayPrefix) {
|
|
215
200
|
const content = fs_1.default.readFileSync(filePath, 'utf8');
|
|
216
201
|
const fileName = path_1.default.basename(filePath, '.md');
|
|
217
202
|
const frontmatter = readJobFrontmatter(filePath);
|
|
@@ -223,7 +208,7 @@ function parseManagerStub(filePath, groupId, groupLabel, projectPath, scope, use
|
|
|
223
208
|
groupId,
|
|
224
209
|
groupLabel,
|
|
225
210
|
intent,
|
|
226
|
-
stubPath: stubDisplayPath(filePath, projectPath,
|
|
211
|
+
stubPath: stubDisplayPath(filePath, projectPath, displayRoot, displayPrefix),
|
|
227
212
|
...(scope ? { scope } : {}),
|
|
228
213
|
};
|
|
229
214
|
}
|
|
@@ -262,10 +247,10 @@ function summarizeProject(projectPath) {
|
|
|
262
247
|
};
|
|
263
248
|
}
|
|
264
249
|
function resolveLayerRoot(projectPath, layer) {
|
|
250
|
+
if (layer.root)
|
|
251
|
+
return layer.root;
|
|
265
252
|
if (layer.base === 'project')
|
|
266
253
|
return path_1.default.join(projectPath, ...layer.segments);
|
|
267
|
-
if (layer.base === 'user')
|
|
268
|
-
return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), ...layer.segments);
|
|
269
254
|
return path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), ...layer.segments);
|
|
270
255
|
}
|
|
271
256
|
function discoverLayers(projectPath, layers) {
|
|
@@ -279,26 +264,37 @@ function discoverLayers(projectPath, layers) {
|
|
|
279
264
|
categoryDir: path_1.default.join(layerRoot, categoryName),
|
|
280
265
|
personalized: layer.personalized,
|
|
281
266
|
scope: layer.scope,
|
|
282
|
-
|
|
267
|
+
displayRoot: layer.displayRoot,
|
|
268
|
+
displayPrefix: layer.displayPrefix,
|
|
283
269
|
});
|
|
284
270
|
}
|
|
285
271
|
}
|
|
286
272
|
return out;
|
|
287
273
|
}
|
|
274
|
+
function personalizedLayerSegments(projectPath, capabilityDir) {
|
|
275
|
+
return (0, pack_home_1.personalizedCapabilityReadRoots)(projectPath, capabilityDir).map((root) => ({
|
|
276
|
+
root: root.capabilityRoot,
|
|
277
|
+
segments: [],
|
|
278
|
+
personalized: true,
|
|
279
|
+
scope: root.scope,
|
|
280
|
+
displayRoot: root.displayRoot,
|
|
281
|
+
displayPrefix: root.displayPrefix,
|
|
282
|
+
}));
|
|
283
|
+
}
|
|
288
284
|
function discoverEmployeeJobs(projectPath, options = {}) {
|
|
289
285
|
const project = summarizeProject(projectPath);
|
|
290
286
|
if (!project.exists || (!project.hasFraim && !options.includeRegistry))
|
|
291
287
|
return [];
|
|
292
288
|
const layers = discoverLayers(projectPath, options.includeRegistry
|
|
293
|
-
? [...REGISTRY_EMPLOYEE_JOB_LAYERS, ...EMPLOYEE_JOB_LAYERS]
|
|
294
|
-
: EMPLOYEE_JOB_LAYERS);
|
|
289
|
+
? [...REGISTRY_EMPLOYEE_JOB_LAYERS, ...EMPLOYEE_JOB_LAYERS, ...personalizedLayerSegments(projectPath, 'jobs')]
|
|
290
|
+
: [...EMPLOYEE_JOB_LAYERS, ...personalizedLayerSegments(projectPath, 'jobs')]);
|
|
295
291
|
// Group by categoryId so all layers contribute to the same labelled category.
|
|
296
292
|
const jobsByKey = new Map();
|
|
297
293
|
for (const layer of layers) {
|
|
298
294
|
const categoryLabel = humanizeName(layer.categoryId);
|
|
299
295
|
for (const fileName of readMarkdownFileNames(layer.categoryDir)) {
|
|
300
296
|
const filePath = path_1.default.join(layer.categoryDir, fileName);
|
|
301
|
-
const job = parseJobStub(filePath, layer.categoryId, categoryLabel, projectPath, layer.personalized, layer.scope, layer.
|
|
297
|
+
const job = parseJobStub(filePath, layer.categoryId, categoryLabel, projectPath, layer.personalized, layer.scope, layer.displayRoot, layer.displayPrefix);
|
|
302
298
|
// Later layers override earlier layers on {category, jobId} collision —
|
|
303
299
|
// personalized-employee wins over the synced ai-employee baseline. The
|
|
304
300
|
// winning job carries its own layer's `personalized` flag (issue #566).
|
|
@@ -317,14 +313,14 @@ function discoverManagerTemplates(projectPath, options = {}) {
|
|
|
317
313
|
if (!project.exists || (!project.hasFraim && !options.includeRegistry))
|
|
318
314
|
return [];
|
|
319
315
|
const layers = discoverLayers(projectPath, options.includeRegistry
|
|
320
|
-
? [...REGISTRY_MANAGER_JOB_LAYERS, ...MANAGER_JOB_LAYERS]
|
|
321
|
-
: MANAGER_JOB_LAYERS);
|
|
316
|
+
? [...REGISTRY_MANAGER_JOB_LAYERS, ...MANAGER_JOB_LAYERS, ...personalizedLayerSegments(projectPath, 'manager-jobs')]
|
|
317
|
+
: [...MANAGER_JOB_LAYERS, ...personalizedLayerSegments(projectPath, 'manager-jobs')]);
|
|
322
318
|
const templatesByKey = new Map();
|
|
323
319
|
for (const layer of layers) {
|
|
324
320
|
const groupLabel = humanizeName(layer.categoryId);
|
|
325
321
|
for (const fileName of readMarkdownFileNames(layer.categoryDir)) {
|
|
326
322
|
const filePath = path_1.default.join(layer.categoryDir, fileName);
|
|
327
|
-
const template = parseManagerStub(filePath, layer.categoryId, groupLabel, projectPath, layer.scope, layer.
|
|
323
|
+
const template = parseManagerStub(filePath, layer.categoryId, groupLabel, projectPath, layer.scope, layer.displayRoot, layer.displayPrefix);
|
|
328
324
|
templatesByKey.set(`${template.groupId}::${template.id}`, template);
|
|
329
325
|
}
|
|
330
326
|
}
|
|
@@ -407,6 +403,8 @@ function findJobStubPath(projectPath, jobId) {
|
|
|
407
403
|
const layers = discoverLayers(projectPath, [
|
|
408
404
|
...EMPLOYEE_JOB_LAYERS,
|
|
409
405
|
...MANAGER_JOB_LAYERS,
|
|
406
|
+
...personalizedLayerSegments(projectPath, 'jobs'),
|
|
407
|
+
...personalizedLayerSegments(projectPath, 'manager-jobs'),
|
|
410
408
|
]);
|
|
411
409
|
let resolved = null;
|
|
412
410
|
for (const layer of layers) {
|
|
@@ -520,8 +518,8 @@ function getAiHubCategories(projectPath, options = {}) {
|
|
|
520
518
|
return [];
|
|
521
519
|
// A category is any directory found at any layer; deduplicate by id.
|
|
522
520
|
const layers = discoverLayers(projectPath, options.includeRegistry
|
|
523
|
-
? [...REGISTRY_EMPLOYEE_JOB_LAYERS, ...EMPLOYEE_JOB_LAYERS]
|
|
524
|
-
: EMPLOYEE_JOB_LAYERS);
|
|
521
|
+
? [...REGISTRY_EMPLOYEE_JOB_LAYERS, ...EMPLOYEE_JOB_LAYERS, ...personalizedLayerSegments(projectPath, 'jobs')]
|
|
522
|
+
: [...EMPLOYEE_JOB_LAYERS, ...personalizedLayerSegments(projectPath, 'jobs')]);
|
|
525
523
|
const seen = new Map();
|
|
526
524
|
for (const layer of layers) {
|
|
527
525
|
if (!seen.has(layer.categoryId)) {
|
|
@@ -0,0 +1,152 @@
|
|
|
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.parseConfiguredCommand = parseConfiguredCommand;
|
|
7
|
+
exports.describeConfiguredCommandInvocationProblem = describeConfiguredCommandInvocationProblem;
|
|
8
|
+
exports.commandContainsLiteralSecret = commandContainsLiteralSecret;
|
|
9
|
+
exports.decorateHostPlanWithConfiguredAgent = decorateHostPlanWithConfiguredAgent;
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
13
|
+
const BASH_ENV_PREFIX = /^([A-Za-z_][A-Za-z0-9_]*)=(?:\$\([^)]*\)|"[^"]*"|'[^']*'|\S*)\s+/;
|
|
14
|
+
// The value may be a parenthesized PowerShell expression followed by a
|
|
15
|
+
// property-access chain (e.g. `(Get-Content ... | ConvertFrom-Json).api_key`),
|
|
16
|
+
// a quoted string, or a bare token — always terminated by the statement `;`.
|
|
17
|
+
const POWERSHELL_ENV_PREFIX = /^\$env:[A-Za-z_][A-Za-z0-9_]*\s*=\s*(?:\([^)]*\)(?:\.[A-Za-z_][A-Za-z0-9_]*)*|"[^"]*"|'[^']*'|\S+?)\s*;\s*/;
|
|
18
|
+
function parseConfiguredCommand(runner, commandText) {
|
|
19
|
+
let rest = String(commandText || '').trim();
|
|
20
|
+
const prefixPattern = runner === 'powershell' ? POWERSHELL_ENV_PREFIX : BASH_ENV_PREFIX;
|
|
21
|
+
let prefixEnd = 0;
|
|
22
|
+
while (true) {
|
|
23
|
+
const match = rest.slice(prefixEnd).match(prefixPattern);
|
|
24
|
+
if (!match)
|
|
25
|
+
break;
|
|
26
|
+
prefixEnd += match[0].length;
|
|
27
|
+
}
|
|
28
|
+
const envPrefixText = rest.slice(0, prefixEnd).trim();
|
|
29
|
+
const invocation = rest.slice(prefixEnd).trim();
|
|
30
|
+
const tokens = tokenizeShellLike(invocation);
|
|
31
|
+
return { envPrefixText, extraArgs: tokens.slice(1), invocationHead: tokens[0] || '' };
|
|
32
|
+
}
|
|
33
|
+
// Reviewer finding (PR #1209, Round 3): parseConfiguredCommand's leniency is
|
|
34
|
+
// intentional for *parsing* (it never throws), but that leniency means a
|
|
35
|
+
// structurally wrong command — most dangerously a shell built-in like `.`
|
|
36
|
+
// (source) or `source` at the head, meant to load a setup script into the
|
|
37
|
+
// current shell — parses "successfully" into a garbage invocationHead that
|
|
38
|
+
// decorateHostPlanWithConfiguredAgent then silently discards, appending only
|
|
39
|
+
// the path/args that followed it as bogus positional arguments to the real
|
|
40
|
+
// agent binary instead of ever sourcing the script. Since the invocation
|
|
41
|
+
// head is always discarded and replaced with Hub's own known base binary,
|
|
42
|
+
// the only way to catch this class of error is to check, at readiness time,
|
|
43
|
+
// that what the user typed there actually was that same base binary — not
|
|
44
|
+
// to attempt to execute or otherwise interpret it.
|
|
45
|
+
function describeConfiguredCommandInvocationProblem(parsed, baseHostId) {
|
|
46
|
+
if (!parsed.invocationHead) {
|
|
47
|
+
return 'Command has no invocation to run after the environment prefix.';
|
|
48
|
+
}
|
|
49
|
+
if (parsed.invocationHead !== baseHostId) {
|
|
50
|
+
return `Command must invoke "${baseHostId}" (found "${parsed.invocationHead}"). A setup script should be registered as a setup script, not composed into this field — see hub-configured-agent-setup.md.`;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
function tokenizeShellLike(text) {
|
|
55
|
+
const tokens = [];
|
|
56
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
57
|
+
let match;
|
|
58
|
+
while ((match = re.exec(text)) !== null) {
|
|
59
|
+
tokens.push(match[1] ?? match[2] ?? match[3]);
|
|
60
|
+
}
|
|
61
|
+
return tokens;
|
|
62
|
+
}
|
|
63
|
+
// Issue #1201 (R2.3): UX safety net only — flags what pattern-matches as a
|
|
64
|
+
// literal high-entropy secret pasted into the command field. Not a substitute
|
|
65
|
+
// for the routing skill never generating such a command in the first place.
|
|
66
|
+
const LITERAL_SECRET_TOKEN = /^[A-Za-z0-9+/_.=-]{20,}$/;
|
|
67
|
+
function commandContainsLiteralSecret(commandText) {
|
|
68
|
+
for (const token of String(commandText || '').split(/\s+/)) {
|
|
69
|
+
const eq = token.indexOf('=');
|
|
70
|
+
if (eq <= 0)
|
|
71
|
+
continue;
|
|
72
|
+
const value = token.slice(eq + 1);
|
|
73
|
+
if (!value || value.startsWith('$') || value.startsWith('`') || value.startsWith('('))
|
|
74
|
+
continue;
|
|
75
|
+
if (LITERAL_SECRET_TOKEN.test(value))
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
// The env-prefix assignment is evaluated as a bash "temporary environment"
|
|
81
|
+
// directly attached to the `exec` command on the same line (`VAR=$(...) exec
|
|
82
|
+
// "$@"`), not as a separate statement before it. A bare `VAR=value` on its
|
|
83
|
+
// own line only sets a local shell variable — it is NOT exported, so it
|
|
84
|
+
// would silently vanish from the real binary's environment once `exec`
|
|
85
|
+
// replaces this process image. Attaching it to the same command line is
|
|
86
|
+
// exactly what a terminal user's `VAR=$(...) codex ...` already does.
|
|
87
|
+
const BASH_LAUNCH_WRAPPER = [
|
|
88
|
+
'#!/usr/bin/env bash',
|
|
89
|
+
'set -e',
|
|
90
|
+
'if [ -n "$FRAIM_CONFIGURED_AGENT_ENV_PREFIX" ]; then',
|
|
91
|
+
' eval "$FRAIM_CONFIGURED_AGENT_ENV_PREFIX exec \\"\\$@\\""',
|
|
92
|
+
'else',
|
|
93
|
+
' exec "$@"',
|
|
94
|
+
'fi',
|
|
95
|
+
'',
|
|
96
|
+
].join('\n');
|
|
97
|
+
const POWERSHELL_LAUNCH_WRAPPER = [
|
|
98
|
+
'$ErrorActionPreference = "Stop"',
|
|
99
|
+
'if ($env:FRAIM_CONFIGURED_AGENT_ENV_PREFIX) {',
|
|
100
|
+
' Invoke-Expression $env:FRAIM_CONFIGURED_AGENT_ENV_PREFIX',
|
|
101
|
+
'}',
|
|
102
|
+
'$exe = $args[0]',
|
|
103
|
+
'if ($args.Length -gt 1) { $rest = $args[1..($args.Length - 1)] } else { $rest = @() }',
|
|
104
|
+
'& $exe @rest',
|
|
105
|
+
'exit $LASTEXITCODE',
|
|
106
|
+
'',
|
|
107
|
+
].join('\r\n');
|
|
108
|
+
// Writes the small, fixed wrapper script that lets a configured-agent's
|
|
109
|
+
// env-prefix (which may contain `$(...)`/`(...)` substitutions) resolve in a
|
|
110
|
+
// real shell at spawn time, then `exec`s Hub's own required invocation with
|
|
111
|
+
// the parsed extra CLI args appended. Content-checked so it self-heals like
|
|
112
|
+
// any other FRAIM-generated artifact (issue #1206 precedent) rather than
|
|
113
|
+
// silently going stale if regenerated with different logic.
|
|
114
|
+
//
|
|
115
|
+
// Security review (issue #1201): written under the per-user FRAIM home
|
|
116
|
+
// (`getUserFraimDirPath()`), NOT `os.tmpdir()`. A predictable filename in a
|
|
117
|
+
// shared, world-writable OS temp directory is a symlink-race target on
|
|
118
|
+
// multi-user machines — another local user could pre-place a symlink at the
|
|
119
|
+
// predictable path, and `fs.writeFileSync` would follow it and overwrite an
|
|
120
|
+
// arbitrary file the *victim* user can write to. The per-user FRAIM home is
|
|
121
|
+
// not shared across OS accounts, closing that class of attack.
|
|
122
|
+
function ensureConfiguredAgentLaunchWrapper(runner) {
|
|
123
|
+
const content = runner === 'bash' ? BASH_LAUNCH_WRAPPER : POWERSHELL_LAUNCH_WRAPPER;
|
|
124
|
+
const filePath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), runner === 'bash' ? 'hub-configured-agent-launch.sh' : 'hub-configured-agent-launch.ps1');
|
|
125
|
+
const existing = fs_1.default.existsSync(filePath) ? fs_1.default.readFileSync(filePath, 'utf8') : null;
|
|
126
|
+
if (existing !== content) {
|
|
127
|
+
fs_1.default.writeFileSync(filePath, content, { encoding: 'utf8', mode: 0o755 });
|
|
128
|
+
}
|
|
129
|
+
return filePath;
|
|
130
|
+
}
|
|
131
|
+
function decorateHostPlanWithConfiguredAgent(plan, agent, agentEnv = {}) {
|
|
132
|
+
if (!agent)
|
|
133
|
+
return plan;
|
|
134
|
+
const withEnv = { ...plan, env: { ...(plan.env || {}), ...agentEnv } };
|
|
135
|
+
if (!agent.command)
|
|
136
|
+
return withEnv;
|
|
137
|
+
const parsed = parseConfiguredCommand(agent.command.runner, agent.command.command);
|
|
138
|
+
if (!parsed.envPrefixText && parsed.extraArgs.length === 0)
|
|
139
|
+
return withEnv;
|
|
140
|
+
const wrapperPath = ensureConfiguredAgentLaunchWrapper(agent.command.runner);
|
|
141
|
+
const realInvocation = [plan.command, ...plan.args, ...parsed.extraArgs];
|
|
142
|
+
const decoratedEnv = { ...withEnv.env, FRAIM_CONFIGURED_AGENT_ENV_PREFIX: parsed.envPrefixText };
|
|
143
|
+
if (agent.command.runner === 'bash') {
|
|
144
|
+
return { ...withEnv, command: 'bash', args: [wrapperPath, ...realInvocation], env: decoratedEnv };
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
...withEnv,
|
|
148
|
+
command: 'powershell.exe',
|
|
149
|
+
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', wrapperPath, ...realInvocation],
|
|
150
|
+
env: decoratedEnv,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -3,18 +3,18 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.AiHubConfiguredAgentStore = void 0;
|
|
6
|
+
exports.describeConfiguredCommandInvocationProblem = exports.decorateHostPlanWithConfiguredAgent = exports.commandContainsLiteralSecret = exports.parseConfiguredCommand = exports.AiHubConfiguredAgentStore = void 0;
|
|
7
7
|
exports.synthesizeDefaultConfiguredAgents = synthesizeDefaultConfiguredAgents;
|
|
8
8
|
exports.checkConfiguredAgentAvailability = checkConfiguredAgentAvailability;
|
|
9
9
|
exports.checkConfiguredAgentReadiness = checkConfiguredAgentReadiness;
|
|
10
10
|
exports.projectConfiguredAgent = projectConfiguredAgent;
|
|
11
11
|
exports.resolveConfiguredAgentForHost = resolveConfiguredAgentForHost;
|
|
12
12
|
exports.resolveConfiguredAgentEnv = resolveConfiguredAgentEnv;
|
|
13
|
-
exports.decorateHostPlanWithConfiguredAgent = decorateHostPlanWithConfiguredAgent;
|
|
14
13
|
const fs_1 = __importDefault(require("fs"));
|
|
15
14
|
const path_1 = __importDefault(require("path"));
|
|
16
15
|
const child_process_1 = require("child_process");
|
|
17
16
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
17
|
+
const configured_agent_command_1 = require("./configured-agent-command");
|
|
18
18
|
const VALID_HOSTS = ['codex', 'claude', 'gemini', 'copilot'];
|
|
19
19
|
const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
20
20
|
function nowIso() {
|
|
@@ -48,6 +48,9 @@ function normalizeAgent(input) {
|
|
|
48
48
|
const setupScript = normalizeSetupScript(input.setupScript);
|
|
49
49
|
if (input.setupScript && !setupScript)
|
|
50
50
|
return { ok: false, error: 'Setup script command is required.' };
|
|
51
|
+
const command = normalizeCommand(input.command);
|
|
52
|
+
if (input.command && !command)
|
|
53
|
+
return { ok: false, error: 'Command is required.' };
|
|
51
54
|
const createdAt = input.createdAt || nowIso();
|
|
52
55
|
return {
|
|
53
56
|
ok: true,
|
|
@@ -57,6 +60,7 @@ function normalizeAgent(input) {
|
|
|
57
60
|
...(input.description && String(input.description).trim() ? { description: String(input.description).trim() } : {}),
|
|
58
61
|
baseHostId,
|
|
59
62
|
enabled: input.enabled !== false,
|
|
63
|
+
...(command ? { command } : {}),
|
|
60
64
|
...(setupScript ? { setupScript } : {}),
|
|
61
65
|
createdAt,
|
|
62
66
|
updatedAt: nowIso(),
|
|
@@ -77,6 +81,24 @@ function normalizeSetupScript(value) {
|
|
|
77
81
|
timeoutMs: typeof value.timeoutMs === 'number' && value.timeoutMs > 0 ? Math.min(value.timeoutMs, 120_000) : 30_000,
|
|
78
82
|
};
|
|
79
83
|
}
|
|
84
|
+
// Issue #1201 (R2.1): the single-text-field setup surface. `runner` is an
|
|
85
|
+
// internal execution detail — callers that omit it get the current platform's
|
|
86
|
+
// default shell so the UI never has to ask the user to pick one.
|
|
87
|
+
function normalizeCommand(value) {
|
|
88
|
+
if (!value)
|
|
89
|
+
return null;
|
|
90
|
+
const command = String(value.command || '').trim();
|
|
91
|
+
if (!command)
|
|
92
|
+
return null;
|
|
93
|
+
const runner = value.runner === 'bash' || value.runner === 'powershell'
|
|
94
|
+
? value.runner
|
|
95
|
+
: (process.platform === 'win32' ? 'powershell' : 'bash');
|
|
96
|
+
return {
|
|
97
|
+
runner,
|
|
98
|
+
command,
|
|
99
|
+
timeoutMs: typeof value.timeoutMs === 'number' && value.timeoutMs > 0 ? Math.min(value.timeoutMs, 120_000) : 30_000,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
80
102
|
function synthesizeDefaultConfiguredAgents(employees) {
|
|
81
103
|
const timestamp = '1970-01-01T00:00:00.000Z';
|
|
82
104
|
return employees
|
|
@@ -152,7 +174,12 @@ function checkConfiguredAgentAvailability(agent, employees, env = process.env) {
|
|
|
152
174
|
reasons.push('Configured agent is disabled.');
|
|
153
175
|
if (!employee?.available)
|
|
154
176
|
reasons.push(`${employee?.label || agent.baseHostId} is not available on this machine.`);
|
|
155
|
-
if (agent.
|
|
177
|
+
if (agent.command) {
|
|
178
|
+
if (!agent.command.command.trim()) {
|
|
179
|
+
reasons.push('Command is empty.');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else if (agent.setupScript) {
|
|
156
183
|
const command = agent.setupScript.command;
|
|
157
184
|
const isPathLike = /[\\/]/.test(command) || /^[A-Za-z]:[\\/]/.test(command);
|
|
158
185
|
if (isPathLike && !fs_1.default.existsSync(command)) {
|
|
@@ -160,13 +187,35 @@ function checkConfiguredAgentAvailability(agent, employees, env = process.env) {
|
|
|
160
187
|
}
|
|
161
188
|
}
|
|
162
189
|
else if (!agent.id.endsWith('-default')) {
|
|
163
|
-
warnings.push('No
|
|
190
|
+
warnings.push('No command configured; this profile launches the base CLI unchanged.');
|
|
164
191
|
}
|
|
165
192
|
return { id: agent.id, label: agent.label, baseHostId: agent.baseHostId, enabled: agent.enabled, available: reasons.length === 0, reasons, warnings };
|
|
166
193
|
}
|
|
167
194
|
function checkConfiguredAgentReadiness(agent, employees, env = process.env) {
|
|
168
195
|
const check = checkConfiguredAgentAvailability(agent, employees, env);
|
|
169
|
-
if (!check.available
|
|
196
|
+
if (!check.available)
|
|
197
|
+
return check;
|
|
198
|
+
if (agent.command) {
|
|
199
|
+
try {
|
|
200
|
+
const parsed = (0, configured_agent_command_1.parseConfiguredCommand)(agent.command.runner, agent.command.command);
|
|
201
|
+
const problem = (0, configured_agent_command_1.describeConfiguredCommandInvocationProblem)(parsed, agent.baseHostId);
|
|
202
|
+
if (problem) {
|
|
203
|
+
return { ...check, available: false, reasons: [...check.reasons, problem] };
|
|
204
|
+
}
|
|
205
|
+
return check;
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
return {
|
|
209
|
+
...check,
|
|
210
|
+
available: false,
|
|
211
|
+
reasons: [
|
|
212
|
+
...check.reasons,
|
|
213
|
+
error instanceof Error ? error.message : 'Configured agent command could not be parsed.',
|
|
214
|
+
],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!agent.setupScript)
|
|
170
219
|
return check;
|
|
171
220
|
try {
|
|
172
221
|
resolveConfiguredAgentEnv(agent);
|
|
@@ -191,6 +240,7 @@ function projectConfiguredAgent(agent, employees) {
|
|
|
191
240
|
...(agent.description ? { description: agent.description } : {}),
|
|
192
241
|
baseHostId: agent.baseHostId,
|
|
193
242
|
enabled: agent.enabled,
|
|
243
|
+
...(agent.command ? { command: { ...agent.command } } : {}),
|
|
194
244
|
...(agent.setupScript ? { setupScript: { ...agent.setupScript, args: [...(agent.setupScript.args || [])] } } : {}),
|
|
195
245
|
createdAt: agent.createdAt,
|
|
196
246
|
updatedAt: agent.updatedAt,
|
|
@@ -295,11 +345,13 @@ function filterSafeEnv(env) {
|
|
|
295
345
|
}
|
|
296
346
|
return safe;
|
|
297
347
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
348
|
+
// Issue #1201: command parsing, secret detection, and launch decoration for
|
|
349
|
+
// the single-command configured-agent surface live in their own module
|
|
350
|
+
// (kept a cohesive, separately-testable unit rather than growing this file
|
|
351
|
+
// past the 500-line quality threshold) — re-exported here so existing
|
|
352
|
+
// callers importing from `./configured-agents` are unaffected.
|
|
353
|
+
var configured_agent_command_2 = require("./configured-agent-command");
|
|
354
|
+
Object.defineProperty(exports, "parseConfiguredCommand", { enumerable: true, get: function () { return configured_agent_command_2.parseConfiguredCommand; } });
|
|
355
|
+
Object.defineProperty(exports, "commandContainsLiteralSecret", { enumerable: true, get: function () { return configured_agent_command_2.commandContainsLiteralSecret; } });
|
|
356
|
+
Object.defineProperty(exports, "decorateHostPlanWithConfiguredAgent", { enumerable: true, get: function () { return configured_agent_command_2.decorateHostPlanWithConfiguredAgent; } });
|
|
357
|
+
Object.defineProperty(exports, "describeConfiguredCommandInvocationProblem", { enumerable: true, get: function () { return configured_agent_command_2.describeConfiguredCommandInvocationProblem; } });
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -47,7 +47,10 @@ const pack_home_1 = require("../cli/utils/pack-home");
|
|
|
47
47
|
// whose item.type === 'mcp_tool_call' and item.tool === 'seekMentoring'
|
|
48
48
|
// (Codex separates the MCP server name from the tool name).
|
|
49
49
|
function parseSeekMentoringSignal(line) {
|
|
50
|
-
|
|
50
|
+
// Issue #1264: case-insensitive so a host that flattens the tool name to
|
|
51
|
+
// all-lowercase (e.g. "fraim_seekmentoring") still reaches the JSON parse
|
|
52
|
+
// and isFraimTool checks below instead of being dropped by this fast gate.
|
|
53
|
+
if (!line.toLowerCase().includes('seekmentoring'))
|
|
51
54
|
return null;
|
|
52
55
|
let parsed;
|
|
53
56
|
try {
|
|
@@ -301,6 +304,68 @@ function parseAgentIdentitySignal(line) {
|
|
|
301
304
|
}
|
|
302
305
|
return null;
|
|
303
306
|
}
|
|
307
|
+
// Issue #1264 (R3): defense-in-depth diagnostic. A tool call that carries a
|
|
308
|
+
// submit-phase reviewHandoff (reviewRequired: true) under a name isFraimTool
|
|
309
|
+
// cannot resolve to 'seekMentoring' — even after the flattened-lowercase
|
|
310
|
+
// matching above — means the Hub is about to silently drop a real review
|
|
311
|
+
// handoff, the #1264/#1265 failure mode for a naming shape not yet
|
|
312
|
+
// anticipated. Returns a human-readable diagnostic surfaced via the existing
|
|
313
|
+
// `hostError` channel (already rendered, non-droppable) instead of inventing
|
|
314
|
+
// new UI. Scoped to lines that already carry a `reviewHandoff` key so it
|
|
315
|
+
// never fires for submits that legitimately have none.
|
|
316
|
+
function detectUnrecognizedReviewHandoffCall(line) {
|
|
317
|
+
if (!line.toLowerCase().includes('reviewhandoff'))
|
|
318
|
+
return null;
|
|
319
|
+
let parsed;
|
|
320
|
+
try {
|
|
321
|
+
parsed = JSON.parse(line);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
327
|
+
return null;
|
|
328
|
+
const obj = parsed;
|
|
329
|
+
const check = (rawName, rawArgs) => {
|
|
330
|
+
if (isFraimTool(rawName, 'seekMentoring'))
|
|
331
|
+
return null;
|
|
332
|
+
const args = normalizeToolArgs(rawArgs);
|
|
333
|
+
if (!args)
|
|
334
|
+
return null;
|
|
335
|
+
const reviewHandoff = extractReviewHandoffFromArgs(args);
|
|
336
|
+
if (!reviewHandoff || !reviewHandoff.reviewRequired)
|
|
337
|
+
return null;
|
|
338
|
+
const name = typeof rawName === 'string' ? rawName : String(rawName);
|
|
339
|
+
return `FRAIM review handoff detected in host output under an unrecognized tool name ("${name}") — the Hub could not project it onto this conversation. File a FRAIM issue naming this exact tool name so recognition can be extended.`;
|
|
340
|
+
};
|
|
341
|
+
if ((obj.type === 'item.started' || obj.type === 'item.completed') && typeof obj.item === 'object' && obj.item !== null) {
|
|
342
|
+
const item = obj.item;
|
|
343
|
+
if (item.type === 'mcp_tool_call') {
|
|
344
|
+
const hit = check(item.tool, item.arguments);
|
|
345
|
+
if (hit)
|
|
346
|
+
return hit;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const candidates = [obj];
|
|
350
|
+
if (Array.isArray(obj.content))
|
|
351
|
+
candidates.push(...obj.content);
|
|
352
|
+
if (typeof obj.message === 'object' && obj.message !== null) {
|
|
353
|
+
const msg = obj.message;
|
|
354
|
+
if (Array.isArray(msg.content))
|
|
355
|
+
candidates.push(...msg.content);
|
|
356
|
+
}
|
|
357
|
+
for (const candidate of candidates) {
|
|
358
|
+
if (typeof candidate !== 'object' || candidate === null)
|
|
359
|
+
continue;
|
|
360
|
+
const c = candidate;
|
|
361
|
+
if (c.type !== 'tool_use' && c.type !== 'function_call')
|
|
362
|
+
continue;
|
|
363
|
+
const hit = check(readToolName(c), c.input || c.arguments || c.parameters);
|
|
364
|
+
if (hit)
|
|
365
|
+
return hit;
|
|
366
|
+
}
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
304
369
|
function readAgentFromArgs(args) {
|
|
305
370
|
if (!args || typeof args.agent !== 'object' || args.agent === null)
|
|
306
371
|
return null;
|
|
@@ -335,7 +400,27 @@ function canonicalToolName(rawName) {
|
|
|
335
400
|
return byDoubleUnderscore.split(/[./:]/).filter(Boolean).pop() || null;
|
|
336
401
|
}
|
|
337
402
|
function isFraimTool(rawName, canonicalName) {
|
|
338
|
-
|
|
403
|
+
if (canonicalToolName(rawName) === canonicalName)
|
|
404
|
+
return true;
|
|
405
|
+
return isFlattenedFraimToolName(rawName, canonicalName);
|
|
406
|
+
}
|
|
407
|
+
// Issue #1264: the "Codex Azure Script" configured agent (MCP server
|
|
408
|
+
// codex_apps) flattens tool identity into a single lowercase, separator-less
|
|
409
|
+
// token instead of a qualifier syntax canonicalToolName's __/./\//: split can
|
|
410
|
+
// find (e.g. "fraim_seekmentoring", "fraim_get_fraim_job", "fraim_fraim_connect"
|
|
411
|
+
// — confirmed verbatim in the 2026-08-21 event log). Match that exact flattened
|
|
412
|
+
// shape, plus the bare lowercase form, case-insensitively — scoped only to the
|
|
413
|
+
// three known canonical FRAIM tool identities so an unrelated tool name is
|
|
414
|
+
// never accepted (#982's case-sensitivity intent is preserved for everything
|
|
415
|
+
// that isn't a legitimate flattened/qualified form of a known FRAIM tool).
|
|
416
|
+
function isFlattenedFraimToolName(rawName, canonicalName) {
|
|
417
|
+
if (typeof rawName !== 'string')
|
|
418
|
+
return false;
|
|
419
|
+
const trimmedLower = rawName.trim().toLowerCase();
|
|
420
|
+
if (!trimmedLower)
|
|
421
|
+
return false;
|
|
422
|
+
const lowerCanonical = canonicalName.toLowerCase();
|
|
423
|
+
return trimmedLower === lowerCanonical || trimmedLower === `fraim_${lowerCanonical}`;
|
|
339
424
|
}
|
|
340
425
|
function readFraimJobFromArgs(rawArgs) {
|
|
341
426
|
const args = normalizeToolArgs(rawArgs);
|
|
@@ -1534,8 +1619,11 @@ function parseHostLine(hostId, line) {
|
|
|
1534
1619
|
const fraimJob = parseFraimJobLoadSignal(trimmed);
|
|
1535
1620
|
const usage = parseUsageSignal(trimmed);
|
|
1536
1621
|
const agentIdentity = parseAgentIdentitySignal(trimmed);
|
|
1622
|
+
// Issue #1264 (R3): only worth checking when seekMentoring itself did not
|
|
1623
|
+
// already resolve — a recognized call has nothing to diagnose.
|
|
1624
|
+
const unrecognizedReviewHandoffError = seekMentoring ? null : detectUnrecognizedReviewHandoffCall(trimmed);
|
|
1537
1625
|
const withSignal = (event) => {
|
|
1538
|
-
if (!seekMentoring && !fraimJob && !usage && !agentIdentity)
|
|
1626
|
+
if (!seekMentoring && !fraimJob && !usage && !agentIdentity && !unrecognizedReviewHandoffError)
|
|
1539
1627
|
return event;
|
|
1540
1628
|
return {
|
|
1541
1629
|
...event,
|
|
@@ -1543,6 +1631,7 @@ function parseHostLine(hostId, line) {
|
|
|
1543
1631
|
...(fraimJob ? { fraimJob } : {}),
|
|
1544
1632
|
...(usage ? { usage } : {}),
|
|
1545
1633
|
...(agentIdentity ? { agentIdentity } : {}),
|
|
1634
|
+
...(unrecognizedReviewHandoffError && !event.hostError ? { hostError: unrecognizedReviewHandoffError } : {}),
|
|
1546
1635
|
};
|
|
1547
1636
|
};
|
|
1548
1637
|
if (hostId === 'codex') {
|
|
@@ -1655,7 +1744,7 @@ function parseHostLine(hostId, line) {
|
|
|
1655
1744
|
source: hostId,
|
|
1656
1745
|
backgroundTasks: parsed.tasks
|
|
1657
1746
|
.filter((t) => typeof t.task_id === 'string')
|
|
1658
|
-
.map((t) => ({ taskId: t.task_id, description: t.description })),
|
|
1747
|
+
.map((t) => ({ taskId: t.task_id, description: t.description, taskType: t.task_type })),
|
|
1659
1748
|
},
|
|
1660
1749
|
});
|
|
1661
1750
|
}
|
|
@@ -2209,6 +2298,17 @@ class ScriptedHostRuntime {
|
|
|
2209
2298
|
agentIdentity: { agentName, agentModel },
|
|
2210
2299
|
}, 'stdout');
|
|
2211
2300
|
}
|
|
2301
|
+
// Test API — feed a raw stdout line through the REAL parseHostLine parser
|
|
2302
|
+
// instead of a hand-built ParsedHostEvent. Issue #1264: lets a test prove
|
|
2303
|
+
// the actual tool-call-name recognition (not just the projection logic)
|
|
2304
|
+
// handles a given raw shape, e.g. a flattened-lowercase tool name.
|
|
2305
|
+
emitRawLine(runId, hostId, line) {
|
|
2306
|
+
const target = this.resolveSession(runId);
|
|
2307
|
+
if (!target)
|
|
2308
|
+
return;
|
|
2309
|
+
const parsed = parseHostLine(hostId, line);
|
|
2310
|
+
target.handlers.onEvent({ ...parsed, sessionId: target.sessionId }, 'stdout');
|
|
2311
|
+
}
|
|
2212
2312
|
// Test API — emit a message from the employee (appears in the thread as an employee bubble).
|
|
2213
2313
|
emitEmployeeMessage(runId, text) {
|
|
2214
2314
|
const target = this.resolveSession(runId);
|