/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ import { ApprovalMode, runImageOperation, STREAM_FIRST_RESPONSE_TIMEOUT_CAMEL_CASE_KEY, STREAM_FIRST_RESPONSE_TIMEOUT_SETTING_KEY, STREAM_IDLE_TIMEOUT_CAMEL_CASE_KEY, STREAM_IDLE_TIMEOUT_SETTING_KEY, type Config, type ImageOperationBackend, } from '@vybestack/llxprt-code-core'; import { setOsKeyringDisabledBySetting } from '@vybestack/llxprt-code-storage'; import { DebugLogger } from '@vybestack/llxprt-code-telemetry'; import { ProfileManager } from '@vybestack/llxprt-code-settings'; import type { EphemeralSettings, SettingsService, } from '@vybestack/llxprt-code-settings'; import { getCliRuntimeContext, setCliRuntimeContext, applyCliSetArguments, } from '@vybestack/llxprt-code-providers/runtime.js'; import type { ProviderManager } from '@vybestack/llxprt-code-providers'; import { createCodexImageBackendResolver } from '@vybestack/llxprt-code-providers'; import { preflightAgentActivation } from '@vybestack/llxprt-code-agents'; import { createOAuthSettingsAdapter } from '../auth/oauth-settings-adapter.js'; import { READ_ONLY_TOOL_NAMES, EDIT_TOOL_NAME, normalizeToolNameForPolicy, buildNormalizedToolSet, } from './toolGovernance.js'; import { applyProfileToRuntime } from './profileRuntimeApplication.js'; import { createBootstrapResult, resolveForegroundRuntimeId, type BootstrapRuntimeState, type BootstrapProfileArgs, } from './profileBootstrap.js'; import type { CliArgs } from './cliArgParser.js'; import type { Settings } from './settings.js'; import type { ProfileLoadResult } from './profileResolution.js'; import type { ProviderModelResult } from './providerModelResolver.js'; const logger = new DebugLogger('llxprt:config:postConfigRuntime'); // ─── DTOs ─────────────────────────────────────────────────────────────────── export interface PostConfigInput { readonly config: Config; readonly runtimeState: BootstrapRuntimeState; readonly bootstrapArgs: BootstrapProfileArgs; readonly argv: CliArgs; readonly settings: Settings; readonly profileSettingsWithTools: Settings & EphemeralSettings; readonly profileLoadResult: ProfileLoadResult; readonly providerModelResult: ProviderModelResult; readonly defaultDisabledTools: readonly string[]; readonly runtimeOverrides: { settingsService?: SettingsService }; readonly approvalMode: ApprovalMode; readonly interactive: boolean; } // ─── Narrowed per-function input types ─────────────────────────────────────── /** Fields consumed by setupRuntimeContext (steps 10-11). */ type SetupRuntimeContextInput = Pick< PostConfigInput, 'config' | 'runtimeState' | 'profileSettingsWithTools' | 'runtimeOverrides' >; /** Fields consumed by reapplyCliOverrides (step 14). */ type ReapplyCliOverridesInput = Pick< PostConfigInput, 'config' | 'runtimeState' | 'bootstrapArgs' | 'argv' | 'runtimeOverrides' >; /** Fields consumed by applyToolPolicies (step 15). */ type ApplyToolPoliciesInput = Pick< PostConfigInput, | 'config' | 'argv' | 'profileSettingsWithTools' | 'approvalMode' | 'interactive' >; // ─── Sub-functions ──────────────────────────────────────────────────────────── // ─── Stream timeout settings application ─────────────────────────────────── /** * Profile/runtime settings input that may carry either the camelCase Settings * key or the canonical hyphenated ephemeral key. The hyphenated keys are NOT * part of the public JSON Settings schema (they would falsely advertise * themselves as JSON properties); they are modeled locally here because * profiles and runtime code historically set them as ephemerals directly. */ export type StreamTimeoutSettingsInput = Settings & { readonly 'stream-idle-timeout-ms'?: unknown; readonly 'stream-first-response-timeout-ms'?: unknown; }; /** * Generic applicator for a camel/hyphenated timeout setting pair. Reads both * the camelCase Settings key and the canonical hyphenated ephemeral key from * the supplied settings object and pushes whichever are defined onto Config * ephemerals. The hyphenated key is applied second so it wins the resolver's * priority order (canonical before alias). */ function applyStreamTimeoutSettingPair( config: Pick, settings: StreamTimeoutSettingsInput, camelKey: keyof Settings, canonicalKey: 'stream-idle-timeout-ms' | 'stream-first-response-timeout-ms', ): void { const camelValue = settings[camelKey]; if (camelValue !== undefined) { config.setEphemeralSetting(camelKey, camelValue); } const canonicalValue = settings[canonicalKey]; if (canonicalValue !== undefined) { config.setEphemeralSetting(canonicalKey, canonicalValue); } } export function applyStreamIdleTimeoutSettings( config: Pick, settings: StreamTimeoutSettingsInput, ): void { applyStreamTimeoutSettingPair( config, settings, STREAM_IDLE_TIMEOUT_CAMEL_CASE_KEY, STREAM_IDLE_TIMEOUT_SETTING_KEY, ); } export function applyStreamFirstResponseTimeoutSettings( config: Pick, settings: StreamTimeoutSettingsInput, ): void { applyStreamTimeoutSettingPair( config, settings, STREAM_FIRST_RESPONSE_TIMEOUT_CAMEL_CASE_KEY, STREAM_FIRST_RESPONSE_TIMEOUT_SETTING_KEY, ); } interface ProfileEphemeralSettingsInput { readonly config: Pick; readonly bootstrapArgs: Pick; readonly argv: Pick; readonly settings: StreamTimeoutSettingsInput; // profileSettingsWithTools carries both the public JSON settings and the // ephemeral tool-governance keys (e.g. 'tools.allowed') that // applyGlobalAndProfileEphemeralSettings forwards to applyToolPolicies. readonly profileSettingsWithTools: StreamTimeoutSettingsInput & EphemeralSettings; readonly profileLoadResult: Pick; } export function applyGlobalAndProfileEphemeralSettings( input: ProfileEphemeralSettingsInput, ): void { const { config, bootstrapArgs, argv, settings, profileSettingsWithTools, profileLoadResult, } = input; // Global settings must apply even when --provider suppresses profile values. applyStreamIdleTimeoutSettings(config, settings); applyStreamFirstResponseTimeoutSettings(config, settings); const profileToLoad = profileLoadResult.profileToLoad; const shouldApplyProfileSettings = (profileToLoad !== undefined && profileToLoad !== '') || bootstrapArgs.profileJson !== null; if (!shouldApplyProfileSettings || argv.provider !== undefined) { return; } applyStreamIdleTimeoutSettings(config, profileSettingsWithTools); applyStreamFirstResponseTimeoutSettings(config, profileSettingsWithTools); const ephemeralKeys = [ 'auth-key', 'auth-keyfile', 'context-limit', 'compression-threshold', 'base-url', 'tool-format', 'api-version', 'custom-headers', 'socket-timeout', 'shell-replacement', 'authOnly', ]; for (const key of ephemeralKeys) { const value = (profileSettingsWithTools as Record)[key]; if (value !== undefined) { config.setEphemeralSetting(key, value); } } } function getSettingsService( input: Pick, ): SettingsService { return ( input.runtimeOverrides.settingsService ?? (input.runtimeState.runtime.settingsService as SettingsService) ); } /** * Reads a `disabled` flag from a hooks settings object, returning null when * the container is absent or does not define the property. */ function readDisabledFlag( container: { disabled?: unknown } | undefined, ): unknown { if (container && 'disabled' in container) { return container.disabled; } return null; } /** * Step 10: Set CLI runtime context. * Step 11: Re-register provider infrastructure (conditional, dynamic import). * This is the SECOND call to registerCliProviderInfrastructure — the first * happened inside prepareRuntimeForProfile() (step 2). */ async function setupRuntimeContext( input: SetupRuntimeContextInput, ): Promise { const { config, runtimeState } = input; const settingsService = getSettingsService(input); const bootstrapRuntimeId = runtimeState.runtime.runtimeId ?? resolveForegroundRuntimeId(); const baseBootstrapMetadata = { ...(runtimeState.runtime.metadata ?? {}), stage: 'post-config', }; // Set disabled hooks from hooksConfig (post-migration target) with // hooks.disabled fallback for unmigrated settings const hooksConfig = input.profileSettingsWithTools.hooksConfig as | { disabled?: unknown } | undefined; const hooksLegacy = input.profileSettingsWithTools.hooks as | { disabled?: unknown } | undefined; const disabledHooks = readDisabledFlag(hooksConfig) ?? readDisabledFlag(hooksLegacy); if (Array.isArray(disabledHooks)) { config.setDisabledHooks(disabledHooks as string[]); } const profileManager = new ProfileManager(); setCliRuntimeContext(settingsService, config, { runtimeId: bootstrapRuntimeId, metadata: baseBootstrapMetadata, profileManager, }); // The early profile runtime has no Config, so its bus cannot carry the // resolved policy. Recompose once Config exists and adopt that final runtime. const { assembleCliProviderRuntime } = await import( '@vybestack/llxprt-code-providers/runtime.js' ); const finalRuntime = assembleCliProviderRuntime({ settingsService, config, runtimeId: bootstrapRuntimeId, metadata: baseBootstrapMetadata, oauthSettings: createOAuthSettingsAdapter(), }); runtimeState.providerManager = finalRuntime.providerManager as ProviderManager; runtimeState.oauthManager = finalRuntime.oauthManager; runtimeState.runtimeMessageBus = finalRuntime.runtimeMessageBus; config.setProviderManager(finalRuntime.providerManager); config.setRuntimeMessageBus(finalRuntime.runtimeMessageBus); // Associate the exact assembled OAuthManager with the Config's runtime bundle // (#2378 Finding 3). fromConfig adopts THIS manager by reference, so the // OAuthManager the Agent sees is the exact one assembled on the same bus — no // second OAuthManager is constructed or looked up. config.setRuntimeOAuthManager(finalRuntime.oauthManager); // Wire the Codex image backend resolver. resolveBackend is called lazily // (when the model invokes generate_image), so even though the tool registry // was already created during config.initialize(), the lazy closure reads // this resolver at invocation time. const imageBackendResolver = createCodexImageBackendResolver({ oauthManager: finalRuntime.oauthManager, getActiveProvider: () => runtimeState.providerManager.getActiveProvider(), }); config.setImageBackendResolver(imageBackendResolver); // Wire the common image-operation runner so `/image` and direct CLI image // mode converge on the SAME service as the generate_image tool. The runner // is bound to the workspace root and the image backend resolver; it owns // request normalization, output/input path validation, provider dispatch, // atomic write, and the normalized result. config.setRunImageOperation((input) => runImageOperation( { prompt: input.prompt, outputPath: input.outputPath, ...(input.inputPaths !== undefined ? { inputPaths: input.inputPaths } : {}), ...(input.signal !== undefined ? { signal: input.signal } : {}), }, { workspaceRoot: config.getTargetDir(), resolveBackend: () => { const backend = imageBackendResolver(); if (backend === null) { return null; } return backend as ImageOperationBackend | null; }, }, ), ); logger.debug( () => `[bootstrap] Runtime context set, runtimeId=${bootstrapRuntimeId}`, ); } /** * Steps 12-13: Apply profile snapshot to runtime, then switch active provider. */ async function activateProviderAndProfile( input: PostConfigInput, ): Promise { const { bootstrapArgs, argv, profileLoadResult, providerModelResult } = input; const profileApplicationResult = await applyProfileToRuntime({ loadedProfile: profileLoadResult.loadedProfile, profileToLoad: profileLoadResult.profileToLoad ?? undefined, bootstrapArgs, argv, finalModel: providerModelResult.model, finalProvider: providerModelResult.provider, profileWarnings: [...profileLoadResult.profileWarnings], }); const finalProvider = profileApplicationResult.resolvedFinalProvider; const runtimeContext = getCliRuntimeContext(); const bootstrapResult = createBootstrapResult({ runtime: runtimeContext, providerManager: input.runtimeState.providerManager, oauthManager: input.runtimeState.oauthManager, bootstrapArgs, profileApplication: { providerName: profileApplicationResult.resolvedProviderAfterProfile ?? finalProvider ?? null, modelName: profileApplicationResult.resolvedModelAfterProfile ?? providerModelResult.model, ...(profileApplicationResult.resolvedBaseUrlAfterProfile ? { baseUrl: profileApplicationResult.resolvedBaseUrlAfterProfile } : {}), warnings: [...profileApplicationResult.profileWarnings], }, }); // Store bootstrap args on config ( input.config as Config & { _bootstrapArgs?: BootstrapProfileArgs } )._bootstrapArgs = bootstrapArgs; if (bootstrapResult.profile.warnings.length > 0) { for (const warning of bootstrapResult.profile.warnings) { logger.warn(() => `[bootstrap] ${warning}`); } } if ( finalProvider !== undefined && !profileApplicationResult.appliedFromLoadedProfile ) { try { // The preflight's authMode 'none' path swallows the provider-switch error // internally (safeActivateProvider does not throw) and surfaces it via // result.switchError. The surrounding try/catch remains necessary because // the 'none' path also calls applyRuntimeProviderOverrides (file I/O for // auth-keyfile resolution) and applyModelAndParams, which can still throw. // The CLI routes this declarative provider switch through the public // agent-bootstrap preflight (#2378) rather than the runtime activation // primitive directly. const activationResult = await preflightAgentActivation(input.config, { provider: finalProvider, authMode: 'none', }); if (activationResult.switchError !== undefined) { logger.warn( () => `[bootstrap] Failed to switch active provider to ${finalProvider}: ${activationResult.switchError}`, ); } } catch (error) { logger.warn( () => `[bootstrap] Failed to switch active provider to ${finalProvider}: ${ error instanceof Error ? error.message : String(error) }`, ); } } return finalProvider; } /** * Returns true when any provider key/keyfile/base-url/set override was passed * on the CLI and therefore needs to be reapplied after a provider switch. */ function isNonEmptyString(value: string | null): boolean { return value !== null && value.length > 0; } function hasCliArgumentOverrides(args: BootstrapProfileArgs): boolean { const hasSetOverrides = args.setOverrides !== null && args.setOverrides.length > 0; return ( isNonEmptyString(args.keyOverride) || isNonEmptyString(args.keyfileOverride) || isNonEmptyString(args.baseurlOverride) || hasSetOverrides ); } /** * Step 14: Reapply CLI model override + CLI arg overrides after provider switch. * The provider switch clears ephemerals, so we reapply CLI args here. */ async function reapplyCliOverrides( input: ReapplyCliOverridesInput, finalProvider: string | undefined, ): Promise { const { config, bootstrapArgs, argv } = input; const settingsService = getSettingsService(input); const cliModelOverride = (() => { if (typeof argv.model === 'string') { const trimmed = argv.model.trim(); if (trimmed.length > 0) return trimmed; } if (typeof bootstrapArgs.modelOverride === 'string') { const trimmed = bootstrapArgs.modelOverride.trim(); if (trimmed.length > 0) return trimmed; } return undefined; })(); if (cliModelOverride) { if (finalProvider !== undefined) { settingsService.setProviderSetting( finalProvider, 'model', cliModelOverride, ); } config.setModel(cliModelOverride); (config as Config & { _cliModelOverride?: string })._cliModelOverride = cliModelOverride; logger.debug( () => `[bootstrap] Re-applied CLI model override '${cliModelOverride}' after provider activation`, ); } if (hasCliArgumentOverrides(bootstrapArgs)) { const { applyCliArgumentOverrides } = await import( '@vybestack/llxprt-code-providers/runtime.js' ); await applyCliArgumentOverrides( { key: argv.key, keyfile: argv.keyfile, baseurl: argv.baseurl, set: argv.set, }, bootstrapArgs, ); } } /** * Step 15: Apply tool governance policy (ephemeral settings for allowed/excluded tools). */ function applyToolPolicies(input: ApplyToolPoliciesInput): void { const { config, argv, profileSettingsWithTools, approvalMode, interactive } = input; const explicitAllowedTools = buildNormalizedToolSet( argv.allowedTools && argv.allowedTools.length > 0 ? argv.allowedTools : (profileSettingsWithTools.allowedTools ?? []), ); const rawProfileAllowedTools = profileSettingsWithTools['tools.allowed']; const profileAllowedExplicit = Array.isArray(rawProfileAllowedTools); const profileAllowedTools = buildNormalizedToolSet(rawProfileAllowedTools); const applyPolicy = (allowedSet: Set | undefined): void => { if (allowedSet === undefined) { config.setEphemeralSetting('tools.allowed', undefined); } else { config.setEphemeralSetting( 'tools.allowed', Array.from(allowedSet).sort(), ); } }; const experimentalAcp = argv.experimentalAcp; if (interactive !== true && experimentalAcp !== true) { if (approvalMode === ApprovalMode.YOLO) { if (profileAllowedExplicit || explicitAllowedTools.size > 0) { const finalAllowed = new Set(profileAllowedTools); explicitAllowedTools.forEach((tool) => finalAllowed.add(tool)); applyPolicy(finalAllowed); } else { applyPolicy(undefined); } } else { const baseAllowed = new Set( READ_ONLY_TOOL_NAMES.map(normalizeToolNameForPolicy), ); explicitAllowedTools.forEach((tool) => baseAllowed.add(tool)); if (approvalMode === ApprovalMode.AUTO_EDIT) { baseAllowed.add(EDIT_TOOL_NAME); } const finalAllowed = profileAllowedExplicit ? new Set( [...baseAllowed].filter((tool) => profileAllowedTools.has(tool)), ) : baseAllowed; applyPolicy(finalAllowed); } } else if (profileAllowedExplicit || explicitAllowedTools.size > 0) { const finalAllowed = new Set(profileAllowedTools); explicitAllowedTools.forEach((tool) => finalAllowed.add(tool)); applyPolicy(finalAllowed); } } /** * Step 16: Apply emojifilter, profile ephemeral settings, CLI /set args, disabled hooks. */ function applyEphemeralSettings(input: PostConfigInput): void { const { config, argv, profileSettingsWithTools, runtimeOverrides } = input; const settingsService = getSettingsService(input); if (!runtimeOverrides.settingsService) { logger.warn( '[cli-runtime] loadCliConfig called without runtime SettingsService override; using bootstrap-scoped instance (temporary compatibility path).', ); } if ( profileSettingsWithTools.emojifilter !== undefined && settingsService.get('emojifilter') === undefined ) { settingsService.set('emojifilter', profileSettingsWithTools.emojifilter); } // Apply stream idle timeout from settings.json and profile ephemerals. // Global stream idle timeout settings are always applied; profile-specific // ephemeral settings are skipped if --provider was explicitly specified. applyGlobalAndProfileEphemeralSettings(input); // In non-interactive mode, tool governance is enforced from approval mode, // so /set must not override governance-managed keys after step 15. // Interactive mode retains /set control for tools.allowed/tools.disabled. const GOVERNANCE_KEYS = new Set([ 'tools.allowed', 'tools.disabled', 'disabled-tools', ]); const rawSetArgs = argv.set ?? []; const enforceGovernanceSetProtection = !input.interactive; const setArgsForApplication = enforceGovernanceSetProtection ? rawSetArgs.filter((entry) => { const eqIdx = entry.indexOf('='); if (eqIdx === -1) return true; // malformed entry — let applyCliSetArguments handle/reject it const key = entry.slice(0, eqIdx).trim(); return !GOVERNANCE_KEYS.has(key); }) : rawSetArgs; const hadGovernanceOverrides = enforceGovernanceSetProtection && setArgsForApplication.length < rawSetArgs.length; const cliSetResult = applyCliSetArguments(config, setArgsForApplication); if (Object.keys(cliSetResult.modelParams).length > 0) { ( config as Config & { _cliModelParams?: Record } )._cliModelParams = cliSetResult.modelParams; } // Reapply tool governance if /set attempted to override governance keys if (hadGovernanceOverrides) { applyToolPolicies({ config, argv, profileSettingsWithTools, approvalMode: input.approvalMode, interactive: input.interactive, }); } } /** * Step 17: Seed default disabled tools, store profile model params, store bootstrap args, log warnings. */ function finalizeMetadata(input: PostConfigInput): void { const { config, profileLoadResult, defaultDisabledTools } = input; // Store profile model params on config if (profileLoadResult.profileModelParams) { ( config as Config & { _profileModelParams?: Record } )._profileModelParams = profileLoadResult.profileModelParams; } // Seed tools.disabled with defaultDisabledTools from settings if (Array.isArray(defaultDisabledTools) && defaultDisabledTools.length > 0) { const currentDisabled = Array.isArray( config.getEphemeralSetting('tools.disabled'), ) ? (config.getEphemeralSetting('tools.disabled') as string[]) : []; const currentAllowed = buildNormalizedToolSet( config.getEphemeralSetting('tools.allowed'), ); const disabledSet = new Set(currentDisabled); for (const toolName of defaultDisabledTools) { if (!currentAllowed.has(normalizeToolNameForPolicy(toolName))) { disabledSet.add(toolName); } } config.setEphemeralSetting('tools.disabled', Array.from(disabledSet)); } } // ─── Main orchestrator ──────────────────────────────────────────────────────── /** * Orchestrates all post-Config side effects in the correct order. * * Step 10: setCliRuntimeContext() * Step 11: registerCliProviderInfrastructure() — re-registration, conditional, dynamic import * Step 12: applyProfileToRuntime() — snapshot application * Step 13: preflightAgentActivation() — declarative provider switch (authMode 'none'; auth happens later) * Step 14: reapplyCliOverrides() — CLI args win after provider switch clears ephemerals * Step 15: applyToolGovernance() — tool policy (ephemeral settings for allowed/excluded tools) * Step 16: applyEphemeralSettings() — emojifilter, profile ephemerals, CLI /set args, disabled hooks * Step 17: finalizeMetadata() — seed default disabled tools, store model params, store bootstrap args, log warnings */ export async function finalizeConfig(input: PostConfigInput): Promise { // Propagate security.disableOsKeyring into the storage package's process-wide // opt-out (issue #2928 R3.2) BEFORE any profile/auth application. Profile // auth wiring (applyProfileToRuntime → createProviderKeyStorage().getKey()) // performs a real SecureStore read during steps 12-13 below, so this MUST run // first to suppress the OS keyring before that first read — otherwise a user // who sets security.disableOsKeyring still gets a Keychain prompt at startup. // The env var LLXPRT_DISABLE_OS_KEYRING=1 is independent and read directly in // storage, so it keeps working with zero CLI involvement. setOsKeyringDisabledBySetting( input.profileSettingsWithTools.security?.disableOsKeyring === true, ); // Step 10-11: Set runtime context + re-register provider infra await setupRuntimeContext(input); // Steps 12-13: Apply profile + switch provider const finalProvider = await activateProviderAndProfile(input); // Step 14: Reapply CLI overrides after provider switch await reapplyCliOverrides(input, finalProvider); // Step 15: Apply tool governance policy applyToolPolicies(input); // Step 16: Apply ephemeral settings applyEphemeralSettings(input); // Step 17: Finalize metadata finalizeMetadata(input); return input.config; }