| 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<AcpSessionRuntimeOptions> | undefined, |
| ): Partial<AcpSessionRuntimeOptions> { |
| if (!patch) { |
| return {}; |
| } |
| const rawPatch = patch as Record<string, unknown>; |
| 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<AcpSessionRuntimeOptions> = {}; |
| 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<string, string> = {}; |
| 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>; |
| }): 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<string, string>(); |
| 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<AcpSessionRuntimeOptions> { |
| 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, |
| }, |
| }; |
| } |
|
|