* 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)
350 lines
11 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
}
|