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.
@@ -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, userLevel) {
189
- if (!userLevel)
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((0, project_fraim_paths_1.getUserFraimDirPath)(), filePath));
192
- return (0, project_fraim_paths_1.getUserFraimDisplayPath)(rel);
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, userLevel) {
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, userLevel),
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, userLevel) {
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, userLevel),
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
- userLevel: layer.base === 'user',
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.userLevel);
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.userLevel);
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.setupScript) {
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 setup script configured; this profile launches the base CLI unchanged.');
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 || !agent.setupScript)
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
- function decorateHostPlanWithConfiguredAgent(plan, agent, agentEnv = {}) {
299
- if (!agent)
300
- return plan;
301
- return {
302
- ...plan,
303
- env: { ...(plan.env || {}), ...agentEnv },
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; } });
@@ -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
- if (!line.includes('seekMentoring'))
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
- return canonicalToolName(rawName) === canonicalName;
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);