import { isAbsolute } from "node:path"; import type { AcpSessionRuntimeOptions, SessionAcpMeta } from "../../config/sessions/types.js"; import { AcpRuntimeError } from "../runtime/errors.js"; const MAX_RUNTIME_MODE_LENGTH = 64; const MAX_MODEL_LENGTH = 200; const MAX_PERMISSION_PROFILE_LENGTH = 80; const MAX_CWD_LENGTH = 4096; const MIN_TIMEOUT_SECONDS = 1; const MAX_TIMEOUT_SECONDS = 24 * 60 * 60; const MAX_BACKEND_OPTION_KEY_LENGTH = 64; const MAX_BACKEND_OPTION_VALUE_LENGTH = 512; const MAX_BACKEND_EXTRAS = 32; const SAFE_OPTION_KEY_RE = /^[a-z0-9][a-z0-9._:-]*$/i; function failInvalidOption(message: string): never { throw new AcpRuntimeError("ACP_INVALID_RUNTIME_OPTION", message); } function validateNoControlChars(value: string, field: string): string { for (let i = 0; i < value.length; i += 1) { const code = value.charCodeAt(i); if (code < 32 || code === 127) { failInvalidOption(`${field} must not include control characters.`); } } return value; } function validateBoundedText(params: { value: unknown; field: string; maxLength: number }): string { const normalized = normalizeText(params.value); if (!normalized) { failInvalidOption(`${params.field} must not be empty.`); } if (normalized.length > params.maxLength) { failInvalidOption(`${params.field} must be at most ${params.maxLength} characters.`); } return validateNoControlChars(normalized, params.field); } function validateBackendOptionKey(rawKey: unknown): string { const key = validateBoundedText({ value: rawKey, field: "ACP config key", maxLength: MAX_BACKEND_OPTION_KEY_LENGTH, }); if (!SAFE_OPTION_KEY_RE.test(key)) { failInvalidOption( "ACP config key must use letters, numbers, dots, colons, underscores, or dashes.", ); } return key; } function validateBackendOptionValue(rawValue: unknown): string { return validateBoundedText({ value: rawValue, field: "ACP config value", maxLength: MAX_BACKEND_OPTION_VALUE_LENGTH, }); } export function validateRuntimeModeInput(rawMode: unknown): string { return validateBoundedText({ value: rawMode, field: "Runtime mode", maxLength: MAX_RUNTIME_MODE_LENGTH, }); } export function validateRuntimeModelInput(rawModel: unknown): string { return validateBoundedText({ value: rawModel, field: "Model id", maxLength: MAX_MODEL_LENGTH, }); } export function validateRuntimePermissionProfileInput(rawProfile: unknown): string { return validateBoundedText({ value: rawProfile, field: "Permission profile", maxLength: MAX_PERMISSION_PROFILE_LENGTH, }); } export function validateRuntimeCwdInput(rawCwd: unknown): string { const cwd = validateBoundedText({ value: rawCwd, field: "Working directory", maxLength: MAX_CWD_LENGTH, }); if (!isAbsolute(cwd)) { failInvalidOption(`Working directory must be an absolute path. Received "${cwd}".`); } return cwd; } export function validateRuntimeTimeoutSecondsInput(rawTimeout: unknown): number { if (typeof rawTimeout !== "number" || !Number.isFinite(rawTimeout)) { failInvalidOption("Timeout must be a positive integer in seconds."); } const timeout = Math.round(rawTimeout); if (timeout < MIN_TIMEOUT_SECONDS || timeout > MAX_TIMEOUT_SECONDS) { failInvalidOption( `Timeout must be between ${MIN_TIMEOUT_SECONDS} and ${MAX_TIMEOUT_SECONDS} seconds.`, ); } return timeout; } export function parseRuntimeTimeoutSecondsInput(rawTimeout: unknown): number { const normalized = normalizeText(rawTimeout); if (!normalized || !/^\d+$/.test(normalized)) { failInvalidOption("Timeout must be a positive integer in seconds."); } return validateRuntimeTimeoutSecondsInput(Number.parseInt(normalized, 10)); } export function validateRuntimeConfigOptionInput( rawKey: unknown, rawValue: unknown, ): { key: string; value: string; } { return { key: validateBackendOptionKey(rawKey), value: validateBackendOptionValue(rawValue), }; } export function validateRuntimeOptionPatch( patch: Partial | undefined, ): Partial { if (!patch) { return {}; } const rawPatch = patch as Record; const allowedKeys = new Set([ "runtimeMode", "model", "cwd", "permissionProfile", "timeoutSeconds", "backendExtras", ]); for (const key of Object.keys(rawPatch)) { if (!allowedKeys.has(key)) { failInvalidOption(`Unknown runtime option "${key}".`); } } const next: Partial = {}; if (Object.hasOwn(rawPatch, "runtimeMode")) { if (rawPatch.runtimeMode === undefined) { next.runtimeMode = undefined; } else { next.runtimeMode = validateRuntimeModeInput(rawPatch.runtimeMode); } } if (Object.hasOwn(rawPatch, "model")) { if (rawPatch.model === undefined) { next.model = undefined; } else { next.model = validateRuntimeModelInput(rawPatch.model); } } if (Object.hasOwn(rawPatch, "cwd")) { if (rawPatch.cwd === undefined) { next.cwd = undefined; } else { next.cwd = validateRuntimeCwdInput(rawPatch.cwd); } } if (Object.hasOwn(rawPatch, "permissionProfile")) { if (rawPatch.permissionProfile === undefined) { next.permissionProfile = undefined; } else { next.permissionProfile = validateRuntimePermissionProfileInput(rawPatch.permissionProfile); } } if (Object.hasOwn(rawPatch, "timeoutSeconds")) { if (rawPatch.timeoutSeconds === undefined) { next.timeoutSeconds = undefined; } else { next.timeoutSeconds = validateRuntimeTimeoutSecondsInput(rawPatch.timeoutSeconds); } } if (Object.hasOwn(rawPatch, "backendExtras")) { const rawExtras = rawPatch.backendExtras; if (rawExtras === undefined) { next.backendExtras = undefined; } else if (!rawExtras || typeof rawExtras !== "object" || Array.isArray(rawExtras)) { failInvalidOption("Backend extras must be a key/value object."); } else { const entries = Object.entries(rawExtras); if (entries.length > MAX_BACKEND_EXTRAS) { failInvalidOption(`Backend extras must include at most ${MAX_BACKEND_EXTRAS} entries.`); } const extras: Record = {}; for (const [entryKey, entryValue] of entries) { const { key, value } = validateRuntimeConfigOptionInput(entryKey, entryValue); extras[key] = value; } next.backendExtras = Object.keys(extras).length > 0 ? extras : undefined; } } return next; } export function normalizeText(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); return trimmed || undefined; } export function normalizeRuntimeOptions( options: AcpSessionRuntimeOptions | undefined, ): AcpSessionRuntimeOptions { const runtimeMode = normalizeText(options?.runtimeMode); const model = normalizeText(options?.model); const cwd = normalizeText(options?.cwd); const permissionProfile = normalizeText(options?.permissionProfile); let timeoutSeconds: number | undefined; if (typeof options?.timeoutSeconds === "number" && Number.isFinite(options.timeoutSeconds)) { const rounded = Math.round(options.timeoutSeconds); if (rounded > 0) { timeoutSeconds = rounded; } } const backendExtrasEntries = Object.entries(options?.backendExtras ?? {}) .map(([key, value]) => [normalizeText(key), normalizeText(value)] as const) .filter(([key, value]) => Boolean(key && value)) as Array<[string, string]>; const backendExtras = backendExtrasEntries.length > 0 ? Object.fromEntries(backendExtrasEntries) : undefined; return { ...(runtimeMode ? { runtimeMode } : {}), ...(model ? { model } : {}), ...(cwd ? { cwd } : {}), ...(permissionProfile ? { permissionProfile } : {}), ...(typeof timeoutSeconds === "number" ? { timeoutSeconds } : {}), ...(backendExtras ? { backendExtras } : {}), }; } export function mergeRuntimeOptions(params: { current?: AcpSessionRuntimeOptions; patch?: Partial; }): AcpSessionRuntimeOptions { const current = normalizeRuntimeOptions(params.current); const patch = normalizeRuntimeOptions(validateRuntimeOptionPatch(params.patch)); const mergedExtras = { ...current.backendExtras, ...patch.backendExtras, }; return normalizeRuntimeOptions({ ...current, ...patch, ...(Object.keys(mergedExtras).length > 0 ? { backendExtras: mergedExtras } : {}), }); } export function resolveRuntimeOptionsFromMeta(meta: SessionAcpMeta): AcpSessionRuntimeOptions { const normalized = normalizeRuntimeOptions(meta.runtimeOptions); if (normalized.cwd || !meta.cwd) { return normalized; } return normalizeRuntimeOptions({ ...normalized, cwd: meta.cwd, }); } export function runtimeOptionsEqual( a: AcpSessionRuntimeOptions | undefined, b: AcpSessionRuntimeOptions | undefined, ): boolean { return JSON.stringify(normalizeRuntimeOptions(a)) === JSON.stringify(normalizeRuntimeOptions(b)); } export function buildRuntimeControlSignature(options: AcpSessionRuntimeOptions): string { const normalized = normalizeRuntimeOptions(options); const extras = Object.entries(normalized.backendExtras ?? {}).toSorted(([a], [b]) => a.localeCompare(b), ); return JSON.stringify({ runtimeMode: normalized.runtimeMode ?? null, model: normalized.model ?? null, permissionProfile: normalized.permissionProfile ?? null, timeoutSeconds: normalized.timeoutSeconds ?? null, backendExtras: extras, }); } export function buildRuntimeConfigOptionPairs( options: AcpSessionRuntimeOptions, ): Array<[string, string]> { const normalized = normalizeRuntimeOptions(options); const pairs = new Map(); if (normalized.model) { pairs.set("model", normalized.model); } if (normalized.permissionProfile) { pairs.set("approval_policy", normalized.permissionProfile); } if (typeof normalized.timeoutSeconds === "number") { pairs.set("timeout", String(normalized.timeoutSeconds)); } for (const [key, value] of Object.entries(normalized.backendExtras ?? {})) { if (!pairs.has(key)) { pairs.set(key, value); } } return [...pairs.entries()]; } export function inferRuntimeOptionPatchFromConfigOption( key: string, value: string, ): Partial { const validated = validateRuntimeConfigOptionInput(key, value); const normalizedKey = validated.key.toLowerCase(); if (normalizedKey === "model") { return { model: validateRuntimeModelInput(validated.value) }; } if ( normalizedKey === "approval_policy" || normalizedKey === "permission_profile" || normalizedKey === "permissions" ) { return { permissionProfile: validateRuntimePermissionProfileInput(validated.value) }; } if (normalizedKey === "timeout" || normalizedKey === "timeout_seconds") { return { timeoutSeconds: parseRuntimeTimeoutSecondsInput(validated.value) }; } if (normalizedKey === "cwd") { return { cwd: validateRuntimeCwdInput(validated.value) }; } return { backendExtras: { [validated.key]: validated.value, }, }; }