Files
openclaw/src/acp/control-plane/runtime-options.ts
Onur Solmaz a7d56e3554 feat: ACP thread-bound agents (#23580)
* docs: add ACP thread-bound agents plan doc

* docs: expand ACP implementation specification

* feat(acp): route ACP sessions through core dispatch and lifecycle cleanup

* feat(acp): add /acp commands and Discord spawn gate

* ACP: add acpx runtime plugin backend

* fix(subagents): defer transient lifecycle errors before announce

* Agents: harden ACP sessions_spawn and tighten spawn guidance

* Agents: require explicit ACP target for runtime spawns

* docs: expand ACP control-plane implementation plan

* ACP: harden metadata seeding and spawn guidance

* ACP: centralize runtime control-plane manager and fail-closed dispatch

* ACP: harden runtime manager and unify spawn helpers

* Commands: route ACP sessions through ACP runtime in agent command

* ACP: require persisted metadata for runtime spawns

* Sessions: preserve ACP metadata when updating entries

* Plugins: harden ACP backend registry across loaders

* ACPX: make availability probe compatible with adapters

* E2E: add manual Discord ACP plain-language smoke script

* ACPX: preserve streamed spacing across Discord delivery

* Docs: add ACP Discord streaming strategy

* ACP: harden Discord stream buffering for thread replies

* ACP: reuse shared block reply pipeline for projector

* ACP: unify streaming config and adopt coalesceIdleMs

* Docs: add temporary ACP production hardening plan

* Docs: trim temporary ACP hardening plan goals

* Docs: gate ACP thread controls by backend capabilities

* ACP: add capability-gated runtime controls and /acp operator commands

* Docs: remove temporary ACP hardening plan

* ACP: fix spawn target validation and close cache cleanup

* ACP: harden runtime dispatch and recovery paths

* ACP: split ACP command/runtime internals and centralize policy

* ACP: harden runtime lifecycle, validation, and observability

* ACP: surface runtime and backend session IDs in thread bindings

* docs: add temp plan for binding-service migration

* ACP: migrate thread binding flows to SessionBindingService

* ACP: address review feedback and preserve prompt wording

* ACPX plugin: pin runtime dependency and prefer bundled CLI

* Discord: complete binding-service migration cleanup and restore ACP plan

* Docs: add standalone ACP agents guide

* ACP: route harness intents to thread-bound ACP sessions

* ACP: fix spawn thread routing and queue-owner stall

* ACP: harden startup reconciliation and command bypass handling

* ACP: fix dispatch bypass type narrowing

* ACP: align runtime metadata to agentSessionId

* ACP: normalize session identifier handling and labels

* ACP: mark thread banner session ids provisional until first reply

* ACP: stabilize session identity mapping and startup reconciliation

* ACP: add resolved session-id notices and cwd in thread intros

* Discord: prefix thread meta notices consistently

* Discord: unify ACP/thread meta notices with gear prefix

* Discord: split thread persona naming from meta formatting

* Extensions: bump acpx plugin dependency to 0.1.9

* Agents: gate ACP prompt guidance behind acp.enabled

* Docs: remove temp experiment plan docs

* Docs: scope streaming plan to holy grail refactor

* Docs: refactor ACP agents guide for human-first flow

* Docs/Skill: add ACP feature-flag guidance and direct acpx telephone-game flow

* Docs/Skill: add OpenCode and Pi to ACP harness lists

* Docs/Skill: align ACP harness list with current acpx registry

* Dev/Test: move ACP plain-language smoke script and mark as keep

* Docs/Skill: reorder ACP harness lists with Pi first

* ACP: split control-plane manager into core/types/utils modules

* Docs: refresh ACP thread-bound agents plan

* ACP: extract dispatch lane and split manager domains

* ACP: centralize binding context and remove reverse deps

* Infra: unify system message formatting

* ACP: centralize error boundaries and session id rendering

* ACP: enforce init concurrency cap and strict meta clear

* Tests: fix ACP dispatch binding mock typing

* Tests: fix Discord thread-binding mock drift and ACP request id

* ACP: gate slash bypass and persist cleared overrides

* ACPX: await pre-abort cancel before runTurn return

* Extension: pin acpx runtime dependency to 0.1.11

* Docs: add pinned acpx install strategy for ACP extension

* Extensions/acpx: enforce strict local pinned startup

* Extensions/acpx: tighten acp-router install guidance

* ACPX: retry runtime test temp-dir cleanup

* Extensions/acpx: require proactive ACPX repair for thread spawns

* Extensions/acpx: require restart offer after acpx reinstall

* extensions/acpx: remove workspace protocol devDependency

* extensions/acpx: bump pinned acpx to 0.1.13

* extensions/acpx: sync lockfile after dependency bump

* ACPX: make runtime spawn Windows-safe

* fix: align doctor-config-flow repair tests with default-account migration (#23580) (thanks @osolmaz)
2026-02-26 11:00:09 +01:00

350 lines
11 KiB
TypeScript

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,
},
};
}