fraim-hub 2.0.351 → 2.0.352

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ const node_fs_1 = __importDefault(require("node:fs"));
40
+ const node_path_1 = __importDefault(require("node:path"));
41
+ const node_child_process_1 = require("node:child_process");
42
+ const node_string_decoder_1 = require("node:string_decoder");
43
+ function report(stage, value = '') {
44
+ const control = process.env.FRAIM_CONFIGURED_CONTROL;
45
+ if (control)
46
+ node_fs_1.default.writeFileSync(node_path_1.default.join(control, stage), value, { mode: 0o600 });
47
+ }
48
+ /** Quote a native argument, then protect both cmd parsing passes of a %* forwarding wrapper. */
49
+ function forwardingCmdArgument(value) {
50
+ let quoted = '"';
51
+ let slashes = 0;
52
+ for (const character of value) {
53
+ if (character === '\\') {
54
+ slashes++;
55
+ continue;
56
+ }
57
+ quoted += '\\'.repeat(character === '"' ? slashes * 2 + 1 : slashes) + character;
58
+ slashes = 0;
59
+ }
60
+ quoted += '\\'.repeat(slashes * 2) + '"';
61
+ return escapeCmd(escapeCmd(quoted));
62
+ }
63
+ function escapeCmd(value) { return value.replace(/([()\][%!^"`<>&|;, *?])/g, '^$1'); }
64
+ function cleanEnvironment() {
65
+ const env = { ...process.env };
66
+ const electronMode = env.FRAIM_CONFIGURED_ELECTRON_MODE;
67
+ for (const key of Object.keys(env))
68
+ if (key.startsWith('FRAIM_CONFIGURED_'))
69
+ delete env[key];
70
+ if (electronMode)
71
+ env.ELECTRON_RUN_AS_NODE = electronMode;
72
+ else
73
+ delete env.ELECTRON_RUN_AS_NODE;
74
+ return env;
75
+ }
76
+ async function main() {
77
+ const mode = process.argv[2];
78
+ if (mode === '--fail') {
79
+ report('failure', ['preparation', 'resolution', 'invocation', 'migration'].includes(process.argv[3]) ? process.argv[3] : 'invocation');
80
+ return;
81
+ }
82
+ const spec = JSON.parse(process.env.FRAIM_CONFIGURED_SPEC || '{}');
83
+ if (mode === '--shell-data') {
84
+ const shellPath = (value) => process.platform === 'win32' ? value.replace(/\\/g, '/') : value;
85
+ const fields = [shellPath(spec.target), spec.envPrefixText, shellPath(spec.setupScript?.command || ''), ...(spec.setupScript?.args || [])];
86
+ process.stdout.write(fields.join('\0') + '\0');
87
+ return;
88
+ }
89
+ const env = cleanEnvironment();
90
+ let browser = { args: [] };
91
+ if (spec.browser && env.FRAIM_BROWSER_CDP_ENDPOINT) {
92
+ // Loaded only in the prepared child: CODEX_HOME and auth now belong to the selected account.
93
+ const { sharedBrowserHostConfig } = await Promise.resolve().then(() => __importStar(require('./hosts')));
94
+ browser = sharedBrowserHostConfig(spec.hostId, env);
95
+ if (spec.hostId === 'codex' && !browser.env?.CODEX_HOME)
96
+ throw new Error('Browser home preparation failed');
97
+ }
98
+ const args = spec.hostId === 'codex'
99
+ ? [...spec.args, ...spec.extraArgs, ...browser.args]
100
+ : [...spec.args, ...browser.args, ...spec.extraArgs];
101
+ Object.assign(env, browser.env);
102
+ if (mode === '--prepare-shell') {
103
+ report('ready');
104
+ const updates = { ...browser.env, ELECTRON_RUN_AS_NODE: env.ELECTRON_RUN_AS_NODE || '' };
105
+ process.stdout.write(JSON.stringify({ args, env: updates }));
106
+ return;
107
+ }
108
+ let target = process.env.FRAIM_CONFIGURED_TARGET;
109
+ if (target && process.platform === 'win32' && spec.runner === 'bash' && !node_fs_1.default.existsSync(target) && node_fs_1.default.existsSync(`${target}.exe`))
110
+ target += '.exe';
111
+ if (!target || !node_fs_1.default.existsSync(target)) {
112
+ report('failure', 'resolution');
113
+ process.exitCode = 71;
114
+ return;
115
+ }
116
+ let command = target;
117
+ let commandArgs = args;
118
+ let windowsVerbatimArguments = false;
119
+ let shellTransport = false;
120
+ if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(target)) {
121
+ command = env.ComSpec || env.COMSPEC || 'cmd.exe';
122
+ commandArgs = ['/d', '/v:off', '/s', '/c', `"${[escapeCmd(target), ...args.map(forwardingCmdArgument)].join(' ')}"`];
123
+ windowsVerbatimArguments = true;
124
+ shellTransport = true;
125
+ }
126
+ else if (process.platform === 'win32' && spec.runner === 'bash' && !/\.(exe|com)$/i.test(target)) {
127
+ command = 'bash';
128
+ // MSYS parses apostrophes in separately supplied Windows argv. Transport one
129
+ // POSIX command string whose every data token is single-quoted for Bash.
130
+ const quote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
131
+ commandArgs = ['--noprofile', '--norc', '-c', `exec ${[target.replace(/\\/g, '/'), ...args].map(quote).join(' ')}`];
132
+ shellTransport = true;
133
+ }
134
+ const child = (0, node_child_process_1.spawn)(command, commandArgs, { env, cwd: process.cwd(), stdio: ['inherit', 'inherit', shellTransport ? 'pipe' : 'inherit'], windowsHide: true, windowsVerbatimArguments });
135
+ if (child.stderr) {
136
+ // A selected script can disappear between resolution and the shell opening it.
137
+ // Preserve diagnostics while withholding the selected account's local path.
138
+ const names = [...new Set([target, target.replace(/\\/g, '/'), target.replace(/\//g, '\\')])];
139
+ const pattern = new RegExp(names.map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'gi');
140
+ const decoder = new node_string_decoder_1.StringDecoder('utf8');
141
+ let pending = '';
142
+ const publish = (text) => process.stderr.write(text.replace(pattern, '[configured target]'));
143
+ child.stderr.on('data', chunk => {
144
+ pending += decoder.write(chunk);
145
+ let boundary;
146
+ while ((boundary = pending.indexOf('\n')) >= 0) {
147
+ publish(pending.slice(0, boundary + 1));
148
+ pending = pending.slice(boundary + 1);
149
+ }
150
+ if (pending.length > 65536 + target.length) {
151
+ let boundary = 65536;
152
+ pattern.lastIndex = 0;
153
+ let match;
154
+ while ((match = pattern.exec(pending)) && match.index < boundary) {
155
+ if (match.index + match[0].length > boundary) {
156
+ boundary = match.index;
157
+ break;
158
+ }
159
+ }
160
+ publish(pending.slice(0, boundary));
161
+ pending = pending.slice(boundary);
162
+ }
163
+ });
164
+ child.stderr.once('end', () => publish(pending + decoder.end()));
165
+ }
166
+ child.once('spawn', () => report('ready'));
167
+ child.once('error', () => { report('failure', 'invocation'); process.exitCode = 72; });
168
+ child.once('close', (code, signal) => { process.exitCode = signal ? 73 : code ?? 72; });
169
+ }
170
+ if (require.main === module) {
171
+ main().catch(() => { report('failure', 'preparation'); process.exitCode = 70; });
172
+ }
@@ -1,116 +1,196 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CONFIGURED_MIGRATION = exports.CONFIGURED_TROUBLESHOOTING = void 0;
6
4
  exports.parseConfiguredCommand = parseConfiguredCommand;
7
5
  exports.describeConfiguredCommandInvocationProblem = describeConfiguredCommandInvocationProblem;
8
- exports.__setCommandResolutionProbeForTests = __setCommandResolutionProbeForTests;
9
- exports.probeCommandResolvesNonInteractively = probeCommandResolvesNonInteractively;
10
6
  exports.commandContainsLiteralSecret = commandContainsLiteralSecret;
11
7
  exports.decorateHostPlanWithConfiguredAgent = decorateHostPlanWithConfiguredAgent;
12
- const fs_1 = __importDefault(require("fs"));
13
- const path_1 = __importDefault(require("path"));
14
- const child_process_1 = require("child_process");
15
- const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
16
- const BASH_ENV_PREFIX = /^([A-Za-z_][A-Za-z0-9_]*)=(?:\$\([^)]*\)|"[^"]*"|'[^']*'|\S*)\s+/;
17
- // The value may be a parenthesized PowerShell expression followed by a
18
- // property-access chain (e.g. `(Get-Content ... | ConvertFrom-Json).api_key`),
19
- // a quoted string, or a bare token — always terminated by the statement `;`.
20
- const POWERSHELL_ENV_PREFIX = /^\$env:[A-Za-z_][A-Za-z0-9_]*\s*=\s*(?:\([^)]*\)(?:\.[A-Za-z_][A-Za-z0-9_]*)*|"[^"]*"|'[^']*'|\S+?)\s*;\s*/;
21
- function parseConfiguredCommand(runner, commandText) {
22
- let rest = String(commandText || '').trim();
23
- const prefixPattern = runner === 'powershell' ? POWERSHELL_ENV_PREFIX : BASH_ENV_PREFIX;
24
- let prefixEnd = 0;
25
- while (true) {
26
- const match = rest.slice(prefixEnd).match(prefixPattern);
27
- if (!match)
28
- break;
29
- prefixEnd += match[0].length;
30
- }
31
- const envPrefixText = rest.slice(0, prefixEnd).trim();
32
- const invocation = rest.slice(prefixEnd).trim();
33
- const tokens = tokenizeShellLike(invocation);
34
- return { envPrefixText, extraArgs: tokens.slice(1), invocationHead: tokens[0] || '' };
35
- }
36
- // Reviewer finding (PR #1209, Round 3): parseConfiguredCommand's leniency is
37
- // intentional for *parsing* (it never throws), but that leniency means a
38
- // structurally wrong command — most dangerously a shell built-in like `.`
39
- // (source) or `source` at the head, meant to load a setup script into the
40
- // current shell — parses "successfully" into a garbage invocationHead that
41
- // decorateHostPlanWithConfiguredAgent then silently discards, appending only
42
- // the path/args that followed it as bogus positional arguments to the real
43
- // agent binary instead of ever sourcing the script. Since the invocation
44
- // head is always discarded and replaced with Hub's own known base binary,
45
- // the only way to catch this class of error is to check, at readiness time,
46
- // that what the user typed there actually was that same base binary — not
47
- // to attempt to execute or otherwise interpret it.
48
- function describeConfiguredCommandInvocationProblem(parsed, baseHostId, agentLabel = 'Configured agent') {
49
- if (!parsed.invocationHead) {
50
- return `${agentLabel} command has no invocation after the environment prefix. Run the create-hub-configured-agent job to repair this profile with an environment prefix followed by "${baseHostId}".`;
51
- }
52
- if (parsed.invocationHead !== baseHostId) {
53
- return `Configured agent "${agentLabel}" invokes "${parsed.invocationHead}", but Hub launches the "${baseHostId}" base CLI and cannot apply that wrapper's environment. Run the create-hub-configured-agent job to repair this profile with an environment prefix followed by "${baseHostId}".`;
8
+ exports.CONFIGURED_TROUBLESHOOTING = 'Run troubleshoot-fraim with another working agent or in your IDE if this agent cannot start. Share only the profile ID, base agent and error category; do not share commands, environment values or credentials.';
9
+ exports.CONFIGURED_MIGRATION = 'This profile needs migration: Hub launches the base CLI after setup-only preparation. Use troubleshoot-fraim, then create-hub-configured-agent to extract and preserve the wrapper or profile function\'s account/environment setup. Do not replace its name and lose account selection. ' + exports.CONFIGURED_TROUBLESHOOTING;
10
+ const unsupportedSyntax = () => new Error('Configured command uses unsupported shell syntax. Use environment assignments followed by the base CLI and supported settings. ' + exports.CONFIGURED_MIGRATION);
11
+ // Explicit settings prevent alternate CLI spellings (attached short options,
12
+ // clusters, positionals and subcommands) from taking over Hub's session/protocol.
13
+ // Values are consumed with their option, never scanned as independent flags.
14
+ const SETTINGS = {
15
+ claude: ['--model', '-m', '--effort', '--permission-mode', '--allowedTools', '--disallowedTools', '--tools', '--add-dir', '--betas', '--settings', '--setting-sources', '--fallback-model', '--max-budget-usd'],
16
+ codex: ['--model', '-m', '--profile', '-p', '--config', '-c', '--sandbox'],
17
+ gemini: ['--model', '-m', '--approval-mode', '--allowed-tools', '--extensions', '--include-directories'],
18
+ copilot: ['--model', '--reasoning-effort', '--allow-tool', '--deny-tool', '--add-dir'],
19
+ };
20
+ // Prefixes are trusted user preparation, but only assignments belong here. Scan
21
+ // balanced quotes/subexpressions so a semicolon inside a secret-file expression
22
+ // cannot become an invocation boundary, and never evaluate an entire command.
23
+ function prefixValueEnd(text, start, runner) {
24
+ let quote = '';
25
+ let depth = 0;
26
+ let i = start;
27
+ for (; i < text.length; i++) {
28
+ const char = text[i];
29
+ if (quote) {
30
+ if (char === quote) {
31
+ if (runner === 'powershell' && quote === "'" && text[i + 1] === "'") {
32
+ i++;
33
+ continue;
34
+ }
35
+ quote = '';
36
+ }
37
+ else if ((runner === 'powershell' && char === '`' && quote === '"') || (runner === 'bash' && char === '\\' && quote === '"')) {
38
+ if (++i >= text.length)
39
+ throw unsupportedSyntax();
40
+ }
41
+ continue;
42
+ }
43
+ if (char === "'" || char === '"') {
44
+ quote = char;
45
+ continue;
46
+ }
47
+ if ((runner === 'powershell' && char === '`') || (runner === 'bash' && char === '\\')) {
48
+ if (++i >= text.length)
49
+ throw unsupportedSyntax();
50
+ continue;
51
+ }
52
+ if (char === '(') {
53
+ if (runner === 'bash' && depth === 0 && text[i - 1] !== '$')
54
+ throw unsupportedSyntax();
55
+ depth++;
56
+ }
57
+ else if (char === ')') {
58
+ if (depth === 0)
59
+ throw unsupportedSyntax();
60
+ depth--;
61
+ }
62
+ else if (depth === 0) {
63
+ if (runner === 'powershell' && char === ';')
64
+ break;
65
+ if (/\s/.test(char)) {
66
+ if (runner === 'powershell') {
67
+ const afterPlus = text[i - 1] === '+';
68
+ while (/\s/.test(text[i] || '') && i < text.length)
69
+ i++;
70
+ if (text[i] === '+')
71
+ continue;
72
+ if (afterPlus && text[i] && text[i] !== ';') {
73
+ i--;
74
+ continue;
75
+ }
76
+ if (text[i] !== ';')
77
+ throw unsupportedSyntax();
78
+ }
79
+ break;
80
+ }
81
+ if (/[;&|<>`{}]/.test(char))
82
+ throw unsupportedSyntax();
83
+ }
54
84
  }
55
- return null;
56
- }
57
- let commandResolutionProbeOverride = null;
58
- function __setCommandResolutionProbeForTests(stub) {
59
- commandResolutionProbeOverride = stub;
85
+ if (quote || depth !== 0 || (i === start && runner === 'powershell'))
86
+ throw unsupportedSyntax();
87
+ if (runner === 'powershell' && text[i] !== ';')
88
+ throw unsupportedSyntax();
89
+ return i;
60
90
  }
61
- const PROBE_CACHE_TTL_MS = 60_000;
62
- const NON_INTERACTIVE_PROBE_TIMEOUT_MS = 3_000;
63
- const probeCache = new Map();
64
- // Probes whether commandName is resolvable in the non-interactive shell context
65
- // that Hub actually uses to launch configured agents (bash --noprofile --norc,
66
- // or powershell -NoProfile). Commands defined only as profile functions or
67
- // aliases are unavailable in that context and would cause silent launch failure.
68
- // Results are cached for PROBE_CACHE_TTL_MS to avoid repeated spawnSync calls
69
- // on frequent availability checks.
70
- function probeCommandResolvesNonInteractively(runner, commandName) {
71
- if (commandResolutionProbeOverride !== null)
72
- return commandResolutionProbeOverride(runner, commandName);
73
- const key = `${runner}\x00${commandName}`;
74
- const cached = probeCache.get(key);
75
- if (cached && Date.now() - cached.at < PROBE_CACHE_TTL_MS)
76
- return cached.result;
77
- let result;
78
- if (runner === 'powershell') {
79
- if (process.platform !== 'win32') {
80
- result = null;
91
+ function tokenizeInvocation(text, runner) {
92
+ const tokens = [];
93
+ let value = '';
94
+ let quote = '';
95
+ let started = false;
96
+ for (let i = 0; i < text.length; i++) {
97
+ const char = text[i];
98
+ if (!quote && /\s/.test(char)) {
99
+ if (started)
100
+ tokens.push(value);
101
+ value = '';
102
+ started = false;
103
+ continue;
81
104
  }
82
- else {
83
- const r = (0, child_process_1.spawnSync)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `Get-Command -Name ${JSON.stringify(commandName)} -ErrorAction Stop`], { encoding: 'utf8', timeout: NON_INTERACTIVE_PROBE_TIMEOUT_MS, windowsHide: true });
84
- result = r.error || r.status !== 0
85
- ? `Command '${commandName}' not found in non-interactive PowerShell (powershell -NoProfile). ` +
86
- `This command may be a PowerShell profile function or alias that is unavailable without a profile. ` +
87
- `Create a .cmd wrapper on PATH that calls the real executable directly.`
88
- : null;
105
+ started = true;
106
+ if (quote === "'") {
107
+ if (char === "'") {
108
+ if (runner === 'powershell' && text[i + 1] === "'") {
109
+ value += "'";
110
+ i++;
111
+ }
112
+ else
113
+ quote = '';
114
+ }
115
+ else
116
+ value += char;
117
+ continue;
118
+ }
119
+ if (char === '"') {
120
+ quote = quote ? '' : '"';
121
+ continue;
89
122
  }
123
+ if (!quote && char === "'") {
124
+ quote = "'";
125
+ continue;
126
+ }
127
+ if ((runner === 'powershell' && char === '`') || (runner === 'bash' && char === '\\')) {
128
+ const next = text[++i];
129
+ if (next === undefined)
130
+ throw unsupportedSyntax();
131
+ if (runner === 'bash' && quote && !/["$`\\]/.test(next))
132
+ value += '\\';
133
+ if (runner === 'powershell' && /[0abefnrtuv]/i.test(next))
134
+ throw unsupportedSyntax();
135
+ value += next;
136
+ continue;
137
+ }
138
+ // Expanding variables, globbing, expressions and shell operators in the
139
+ // target/arguments would require evaluating a second command language.
140
+ // Reject those shapes rather than silently changing their meaning.
141
+ if (char === '$' || char === '`' || (!quote && /[;&|<>(){}*?\[\]#@~]/.test(char)))
142
+ throw unsupportedSyntax();
143
+ value += char;
90
144
  }
91
- else {
92
- const r = (0, child_process_1.spawnSync)('bash', ['--noprofile', '--norc', '-c', `command -v ${commandName}`], {
93
- encoding: 'utf8',
94
- timeout: NON_INTERACTIVE_PROBE_TIMEOUT_MS,
95
- windowsHide: true,
96
- });
97
- result = r.error || r.status !== 0
98
- ? `Command '${commandName}' not found in non-interactive bash (bash --noprofile --norc). ` +
99
- `This command may be defined only in a shell profile. ` +
100
- `Add the command to a PATH location accessible without sourcing any profile.`
101
- : null;
145
+ if (quote)
146
+ throw unsupportedSyntax();
147
+ if (started)
148
+ tokens.push(value);
149
+ return tokens;
150
+ }
151
+ function parseConfiguredCommand(runner, commandText) {
152
+ const text = String(commandText || '').trim();
153
+ if (/[\r\n\0]/.test(text))
154
+ throw unsupportedSyntax();
155
+ let prefixEnd = 0;
156
+ while (prefixEnd < text.length) {
157
+ const assignment = text.slice(prefixEnd).match(runner === 'powershell'
158
+ ? /^\$env:[A-Za-z_][A-Za-z0-9_]*\s*=\s*/i
159
+ : /^[A-Za-z_][A-Za-z0-9_]*=/);
160
+ if (!assignment)
161
+ break;
162
+ const valueEnd = prefixValueEnd(text, prefixEnd + assignment[0].length, runner);
163
+ prefixEnd = valueEnd + (runner === 'powershell' ? 1 : 0);
164
+ while (/\s/.test(text[prefixEnd] || '') && prefixEnd < text.length)
165
+ prefixEnd++;
102
166
  }
103
- probeCache.set(key, { result, at: Date.now() });
104
- return result;
167
+ let invocation = text.slice(prefixEnd).trim();
168
+ if (runner === 'powershell' && invocation.startsWith('&'))
169
+ invocation = invocation.slice(1).trimStart();
170
+ const tokens = tokenizeInvocation(invocation, runner);
171
+ return { envPrefixText: text.slice(0, prefixEnd).trim(), invocationHead: tokens[0] || '', extraArgs: tokens.slice(1) };
105
172
  }
106
- function tokenizeShellLike(text) {
107
- const tokens = [];
108
- const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
109
- let match;
110
- while ((match = re.exec(text)) !== null) {
111
- tokens.push(match[1] ?? match[2] ?? match[3]);
173
+ function describeConfiguredCommandInvocationProblem(parsed, baseHostId) {
174
+ if (!parsed.invocationHead)
175
+ return 'Configured command has no invocation after its environment prefix.';
176
+ if (parsed.invocationHead !== baseHostId)
177
+ return exports.CONFIGURED_MIGRATION;
178
+ const settings = SETTINGS[baseHostId] || [];
179
+ for (let i = 0; i < parsed.extraArgs.length; i++) {
180
+ const argument = parsed.extraArgs[i];
181
+ const option = argument.split('=')[0];
182
+ if (!settings.includes(option)) {
183
+ return 'Configured command contains an unsupported setting or conflicts with Hub protocol or session arguments. Hub supplies prompt, output and resume options, including short and attached forms. ' + exports.CONFIGURED_TROUBLESHOOTING;
184
+ }
185
+ if (argument.includes('=')) {
186
+ if (!argument.slice(argument.indexOf('=') + 1))
187
+ return 'Configured setting requires a value. ' + exports.CONFIGURED_TROUBLESHOOTING;
188
+ }
189
+ else if (!parsed.extraArgs[++i] || parsed.extraArgs[i].startsWith('-')) {
190
+ return 'Configured setting requires a value. ' + exports.CONFIGURED_TROUBLESHOOTING;
191
+ }
112
192
  }
113
- return tokens;
193
+ return null;
114
194
  }
115
195
  // Issue #1201 (R2.3): UX safety net only — flags what pattern-matches as a
116
196
  // literal high-entropy secret pasted into the command field. Not a substitute
@@ -129,76 +209,38 @@ function commandContainsLiteralSecret(commandText) {
129
209
  }
130
210
  return false;
131
211
  }
132
- // The env-prefix assignment is evaluated as a bash "temporary environment"
133
- // directly attached to the `exec` command on the same line (`VAR=$(...) exec
134
- // "$@"`), not as a separate statement before it. A bare `VAR=value` on its
135
- // own line only sets a local shell variable — it is NOT exported, so it
136
- // would silently vanish from the real binary's environment once `exec`
137
- // replaces this process image. Attaching it to the same command line is
138
- // exactly what a terminal user's `VAR=$(...) codex ...` already does.
139
- const BASH_LAUNCH_WRAPPER = [
140
- '#!/usr/bin/env bash',
141
- 'set -e',
142
- 'if [ -n "$FRAIM_CONFIGURED_AGENT_ENV_PREFIX" ]; then',
143
- ' eval "$FRAIM_CONFIGURED_AGENT_ENV_PREFIX exec \\"\\$@\\""',
144
- 'else',
145
- ' exec "$@"',
146
- 'fi',
147
- '',
148
- ].join('\n');
149
- const POWERSHELL_LAUNCH_WRAPPER = [
150
- '$ErrorActionPreference = "Stop"',
151
- 'if ($env:FRAIM_CONFIGURED_AGENT_ENV_PREFIX) {',
152
- ' Invoke-Expression $env:FRAIM_CONFIGURED_AGENT_ENV_PREFIX',
153
- '}',
154
- '$exe = $args[0]',
155
- 'if ($args.Length -gt 1) { $rest = $args[1..($args.Length - 1)] } else { $rest = @() }',
156
- '& $exe @rest',
157
- 'exit $LASTEXITCODE',
158
- '',
159
- ].join('\r\n');
160
- // Writes the small, fixed wrapper script that lets a configured-agent's
161
- // env-prefix (which may contain `$(...)`/`(...)` substitutions) resolve in a
162
- // real shell at spawn time, then `exec`s Hub's own required invocation with
163
- // the parsed extra CLI args appended. Content-checked so it self-heals like
164
- // any other FRAIM-generated artifact (issue #1206 precedent) rather than
165
- // silently going stale if regenerated with different logic.
166
- //
167
- // Security review (issue #1201): written under the per-user FRAIM home
168
- // (`getUserFraimDirPath()`), NOT `os.tmpdir()`. A predictable filename in a
169
- // shared, world-writable OS temp directory is a symlink-race target on
170
- // multi-user machines — another local user could pre-place a symlink at the
171
- // predictable path, and `fs.writeFileSync` would follow it and overwrite an
172
- // arbitrary file the *victim* user can write to. The per-user FRAIM home is
173
- // not shared across OS accounts, closing that class of attack.
174
- function ensureConfiguredAgentLaunchWrapper(runner) {
175
- const content = runner === 'bash' ? BASH_LAUNCH_WRAPPER : POWERSHELL_LAUNCH_WRAPPER;
176
- const filePath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), runner === 'bash' ? 'hub-configured-agent-launch.sh' : 'hub-configured-agent-launch.ps1');
177
- const existing = fs_1.default.existsSync(filePath) ? fs_1.default.readFileSync(filePath, 'utf8') : null;
178
- if (existing !== content) {
179
- fs_1.default.writeFileSync(filePath, content, { encoding: 'utf8', mode: 0o755 });
180
- }
181
- return filePath;
182
- }
183
212
  function decorateHostPlanWithConfiguredAgent(plan, agent, agentEnv = {}) {
184
213
  if (!agent)
185
214
  return plan;
215
+ if (!agent.enabled)
216
+ throw new Error('Configured agent is disabled. Enable this profile before starting it.');
186
217
  const withEnv = { ...plan, env: { ...(plan.env || {}), ...agentEnv } };
187
- if (!agent.command)
188
- return withEnv;
189
- const parsed = parseConfiguredCommand(agent.command.runner, agent.command.command);
190
- if (!parsed.envPrefixText && parsed.extraArgs.length === 0)
218
+ if (!agent.command && !agent.setupScript)
191
219
  return withEnv;
192
- const wrapperPath = ensureConfiguredAgentLaunchWrapper(agent.command.runner);
193
- const realInvocation = [plan.command, ...plan.args, ...parsed.extraArgs];
194
- const decoratedEnv = { ...withEnv.env, FRAIM_CONFIGURED_AGENT_ENV_PREFIX: parsed.envPrefixText };
195
- if (agent.command.runner === 'bash') {
196
- return { ...withEnv, command: 'bash', args: [wrapperPath, ...realInvocation], env: decoratedEnv };
220
+ if (agent.command && agent.setupScript && agent.command.runner !== agent.setupScript.runner) {
221
+ throw new Error('Configured command and setup script use different runners. Use the same runner for both preparation and launch.');
197
222
  }
223
+ const runner = agent.command?.runner || agent.setupScript.runner;
224
+ const parsed = agent.command
225
+ ? parseConfiguredCommand(runner, agent.command.command)
226
+ : { invocationHead: agent.baseHostId, envPrefixText: '', extraArgs: [] };
227
+ const problem = describeConfiguredCommandInvocationProblem(parsed, agent.baseHostId);
228
+ if (problem)
229
+ throw new Error(problem);
230
+ if (agent.setupScript && !agent.setupScript.command.trim())
231
+ throw new Error('Configured setup script is empty.');
198
232
  return {
199
233
  ...withEnv,
200
- command: 'powershell.exe',
201
- args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', wrapperPath, ...realInvocation],
202
- env: decoratedEnv,
234
+ configuredInvocation: {
235
+ configuredAgentId: agent.id,
236
+ runner,
237
+ // The adapter owns the executable; settings never select a launch wrapper.
238
+ // Resolve it only after preparation, against that session's PATH.
239
+ target: plan.command,
240
+ envPrefixText: parsed.envPrefixText,
241
+ extraArgs: parsed.extraArgs,
242
+ ...(agent.setupScript ? { setupScript: { ...agent.setupScript, args: [...(agent.setupScript.args || [])] } } : {}),
243
+ timeoutMs: agent.setupScript?.timeoutMs || agent.command?.timeoutMs || 30_000,
244
+ },
203
245
  };
204
246
  }