From b8d325b7e229cf637b154bcb49f8733837b8e580 Mon Sep 17 00:00:00 2001
From: idevlab
Date: Mon, 21 Sep 2026 14:16:40 +0800
Subject: [PATCH 1/2] feat: support custom ACP agents
---
apps/desktop/src/bridge.ts | 32 +-
apps/desktop/src/i18n/strings.ts | 41 +-
.../desktop/src/settings/ProviderSettings.tsx | 287 ++++++++++++-
apps/desktop/src/settings/SettingsPage.tsx | 12 +
.../tests/providerSettingsRendered.test.tsx | 149 ++++++-
.../src/plugins/app/plugins/foundation.rs | 39 +-
crates/core/src/plugins/app/service.rs | 4 +-
crates/core/src/provider_lifecycle.rs | 380 +++++++++++++++++-
.../2026-09-21-custom-acp-agents/intent.md | 38 ++
.../2026-09-21-custom-acp-agents/plan.md | 39 ++
.../2026-09-21-custom-acp-agents/spec.md | 53 +++
.../verification.md | 71 ++++
website/guide/providers.md | 18 +
website/zh/guide/providers.md | 15 +
14 files changed, 1154 insertions(+), 24 deletions(-)
create mode 100644 docs/sdlc/changes/2026-09-21-custom-acp-agents/intent.md
create mode 100644 docs/sdlc/changes/2026-09-21-custom-acp-agents/plan.md
create mode 100644 docs/sdlc/changes/2026-09-21-custom-acp-agents/spec.md
create mode 100644 docs/sdlc/changes/2026-09-21-custom-acp-agents/verification.md
diff --git a/apps/desktop/src/bridge.ts b/apps/desktop/src/bridge.ts
index 9fa7145b..b1ae5bb7 100644
--- a/apps/desktop/src/bridge.ts
+++ b/apps/desktop/src/bridge.ts
@@ -211,6 +211,7 @@ export async function syncDeviceDataNow(): Promise {
export interface ProviderInfo {
id: string;
display_name: string;
+ custom: boolean;
available: boolean;
enabled: boolean;
needs_node: boolean;
@@ -237,6 +238,14 @@ export interface ProviderRuntimeConfiguration extends ProviderRuntimeOverride {
effective_args: string[];
}
+export interface CustomProviderConfiguration {
+ id: string;
+ display_name: string;
+ command: string;
+ args: string[];
+ forwarded_environment: string[];
+}
+
export interface ProviderManagementInfo {
installed: boolean;
version: string | null;
@@ -336,9 +345,10 @@ function normalizeBrowserUseSettings(
type ProviderInfoWire = Omit<
ProviderInfo,
- "capabilities" | "enabled" | "management" | "configuration"
+ "capabilities" | "custom" | "enabled" | "management" | "configuration"
> & {
capabilities?: ProviderCapability[] | null;
+ custom?: boolean | null;
enabled?: boolean | null;
management?: ProviderManagementInfo | null;
configuration?: ProviderRuntimeConfiguration | null;
@@ -370,6 +380,7 @@ export function normalizeProviderInfo(
): ProviderInfo {
return {
...provider,
+ custom: provider.custom ?? false,
enabled: provider.enabled ?? true,
capabilities: provider.capabilities ?? [],
configuration:
@@ -1769,6 +1780,7 @@ const fallbackProvider = (
): ProviderInfo => ({
id,
display_name,
+ custom: false,
available: false,
enabled: true,
needs_node,
@@ -1937,6 +1949,24 @@ export async function configureProvider(
return providers.map(normalizeProviderInfo);
}
+export async function registerCustomProvider(
+ configuration: CustomProviderConfiguration
+): Promise {
+ if (!inDesktop)
+ throw new Error(
+ "Custom provider registration is available in the C2 desktop app"
+ );
+ await call("providers.register", { configuration });
+}
+
+export async function removeCustomProvider(provider: string): Promise {
+ if (!inDesktop)
+ throw new Error(
+ "Custom provider removal is available in the C2 desktop app"
+ );
+ await call("providers.remove", { provider });
+}
+
export async function installProvider(
provider: string
): Promise {
diff --git a/apps/desktop/src/i18n/strings.ts b/apps/desktop/src/i18n/strings.ts
index f11c9c5f..e4dd4599 100644
--- a/apps/desktop/src/i18n/strings.ts
+++ b/apps/desktop/src/i18n/strings.ts
@@ -1421,7 +1421,26 @@ export const en = {
"settings.providers": "Providers",
"settings.restoreDefaults": "Restore defaults",
"settings.providersHint":
- "Enable providers for new sessions, install their local runtimes, and keep installed versions current.",
+ "Enable providers for new sessions, manage built-in runtimes, or add any local Agent that speaks ACP over stdio.",
+ "settings.customProviderAddAction": "Add ACP Agent",
+ "settings.customProviderTitle": "Custom ACP Agent",
+ "settings.customProviderHint":
+ "C2 launches this local command directly and uses the same ACP session and permission flow as built-in providers.",
+ "settings.customProviderId": "Agent ID",
+ "settings.customProviderIdHint":
+ "Starts with a lowercase letter; use lowercase letters, numbers, dots, dashes, or underscores.",
+ "settings.customProviderCommandHint":
+ "Executable or absolute path. The command must speak ACP over stdin and stdout; shell expressions are not evaluated.",
+ "settings.customProviderArgumentsHint":
+ "Optional; one exact launch argument per line.",
+ "settings.customProviderAdd": "Add Agent",
+ "settings.customProviderAdding": "Adding…",
+ "settings.customProviderCancel": "Cancel",
+ "settings.customProviderAdded": "Added {provider} for new sessions.",
+ "settings.customProviderRemove": "Remove Agent",
+ "settings.customProviderRemoveConfirm":
+ "Remove {provider} from C2? Existing sessions and provider-owned files will not be deleted.",
+ "settings.customProviderRemoved": "Removed {provider} from C2.",
"settings.providerChecked": "Checked just now",
"settings.providerChecking": "Checking…",
"settings.providerRefresh": "Refresh",
@@ -4294,7 +4313,25 @@ export const zhCN: Record = {
"settings.providers": "供应商",
"settings.restoreDefaults": "恢复默认",
"settings.providersHint":
- "管理新会话可用的 Provider,安装本地运行时并保持版本更新。",
+ "管理新会话可用的 Provider、内置运行时,或添加任何通过 stdio 支持 ACP 的本地 Agent。",
+ "settings.customProviderAddAction": "添加 ACP Agent",
+ "settings.customProviderTitle": "自定义 ACP Agent",
+ "settings.customProviderHint":
+ "C2 会直接启动这个本地命令,并复用与内置 Provider 相同的 ACP 会话和权限流程。",
+ "settings.customProviderId": "Agent ID",
+ "settings.customProviderIdHint":
+ "以小写字母开头;仅使用小写字母、数字、点、短横线或下划线。",
+ "settings.customProviderCommandHint":
+ "填写可执行程序名或绝对路径。命令必须通过 stdin/stdout 使用 ACP;不会执行 shell 表达式。",
+ "settings.customProviderArgumentsHint": "可选;每行填写一个完整的启动参数。",
+ "settings.customProviderAdd": "添加 Agent",
+ "settings.customProviderAdding": "正在添加…",
+ "settings.customProviderCancel": "取消",
+ "settings.customProviderAdded": "已为新会话添加 {provider}。",
+ "settings.customProviderRemove": "移除 Agent",
+ "settings.customProviderRemoveConfirm":
+ "从 C2 中移除 {provider}?已有会话和 Agent 自己的文件不会被删除。",
+ "settings.customProviderRemoved": "已从 C2 移除 {provider}。",
"settings.providerChecked": "刚刚检查",
"settings.providerChecking": "正在检查…",
"settings.providerRefresh": "刷新",
diff --git a/apps/desktop/src/settings/ProviderSettings.tsx b/apps/desktop/src/settings/ProviderSettings.tsx
index 718aebe0..ae5a91d3 100644
--- a/apps/desktop/src/settings/ProviderSettings.tsx
+++ b/apps/desktop/src/settings/ProviderSettings.tsx
@@ -4,7 +4,13 @@ import { SearchField } from "@/components/business/search-field";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
-import { ChevronDown, Download, RefreshCw } from "@/components/ui/icons";
+import {
+ ChevronDown,
+ Download,
+ Plus,
+ RefreshCw,
+ Trash2,
+} from "@/components/ui/icons";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
@@ -12,12 +18,16 @@ import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
+ confirmNative,
configureProvider,
installProvider,
+ registerCustomProvider,
+ removeCustomProvider,
setProviderEnabled,
upgradeProvider,
} from "../bridge";
import type {
+ CustomProviderConfiguration,
ProviderInfo,
ProviderRuntimeConfiguration,
ProviderRuntimeOverride,
@@ -38,7 +48,14 @@ const CAPABILITY_LABELS = {
interface ProviderOperation {
id: string;
- action: "install" | "upgrade" | "enable" | "configure" | "refresh";
+ action:
+ | "install"
+ | "upgrade"
+ | "enable"
+ | "configure"
+ | "refresh"
+ | "register"
+ | "remove";
}
function runtimeConfiguration(
@@ -273,24 +290,181 @@ function ProviderRuntimeEditor({
)}
+ {!provider.custom && (
+
+ )}
+
+
+
+ );
+}
+
+function CustomProviderForm({
+ disabled,
+ saving,
+ onCancel,
+ onSave,
+}: {
+ disabled: boolean;
+ saving: boolean;
+ onCancel: () => void;
+ onSave: (configuration: CustomProviderConfiguration) => Promise;
+}) {
+ const t = useT();
+ const [id, setId] = useState("");
+ const [displayName, setDisplayName] = useState("");
+ const [command, setCommand] = useState("");
+ const [args, setArgs] = useState("");
+ const [forwardedEnvironment, setForwardedEnvironment] = useState("");
+ const ready =
+ id.trim() !== "" && displayName.trim() !== "" && command.trim() !== "";
+
+ return (
+
+
+
+ {t("settings.customProviderTitle")}
+
+
+ {t("settings.customProviderHint")}
+
+
+
+
+
+ {t("settings.customProviderId")}
+
+ setId(event.target.value)}
+ />
+
+ {t("settings.customProviderIdHint")}
+
+
+
+
+ {t("settings.providerDisplayName")}
+
+ setDisplayName(event.target.value)}
+ />
+
+
+
+
+ {t("settings.providerRuntimeCommand")}
+
+ setCommand(event.target.value)}
+ />
+
+ {t("settings.customProviderCommandHint")}
+
+
+
+
+
+ {t("settings.providerRuntimeArguments")}
+
+
+
+
+ {t("settings.providerForwardedEnvironment")}
+
+
+
+
@@ -386,6 +560,9 @@ export function ProviderSettingsPage({
upgrader = upgradeProvider,
enabledSaver = setProviderEnabled,
configurationSaver = configureProvider,
+ customProviderRegistrar = registerCustomProvider,
+ customProviderRemover = removeCustomProvider,
+ customProviderRemoveConfirmer = confirmNative,
}: {
providers: ProviderInfo[];
reload?: () => void | Promise;
@@ -399,6 +576,11 @@ export function ProviderSettingsPage({
provider: string,
configuration: ProviderRuntimeOverride
) => Promise;
+ customProviderRegistrar?: (
+ configuration: CustomProviderConfiguration
+ ) => Promise;
+ customProviderRemover?: (provider: string) => Promise;
+ customProviderRemoveConfirmer?: (message: string) => Promise;
}) {
const t = useT();
const [expanded, setExpanded] = useState>(() => new Set());
@@ -407,6 +589,7 @@ export function ProviderSettingsPage({
null
);
const [error, setError] = useState<{ id: string; text: string } | null>(null);
+ const [addingCustomProvider, setAddingCustomProvider] = useState(false);
useEffect(() => {
if (!reload) return;
@@ -542,6 +725,61 @@ export function ProviderSettingsPage({
}
}
+ async function registerCustom(configuration: CustomProviderConfiguration) {
+ if (operation) return;
+ setOperation({ id: "*", action: "register" });
+ setError(null);
+ setMessage(null);
+ try {
+ await customProviderRegistrar(configuration);
+ setAddingCustomProvider(false);
+ setMessage({
+ id: "*",
+ text: t("settings.customProviderAdded", {
+ provider: configuration.display_name,
+ }),
+ });
+ await reload?.();
+ } catch (error) {
+ setError({
+ id: "*",
+ text: t("settings.providerActionFailed", { error: String(error) }),
+ });
+ } finally {
+ setOperation(null);
+ }
+ }
+
+ async function removeCustom(provider: ProviderInfo) {
+ if (operation || !provider.custom) return;
+ const confirmed = await customProviderRemoveConfirmer(
+ t("settings.customProviderRemoveConfirm", {
+ provider: provider.display_name,
+ })
+ );
+ if (!confirmed) return;
+ setOperation({ id: provider.id, action: "remove" });
+ setError(null);
+ setMessage(null);
+ try {
+ await customProviderRemover(provider.id);
+ setMessage({
+ id: "*",
+ text: t("settings.customProviderRemoved", {
+ provider: provider.display_name,
+ }),
+ });
+ await reload?.();
+ } catch (error) {
+ setError({
+ id: provider.id,
+ text: t("settings.providerActionFailed", { error: String(error) }),
+ });
+ } finally {
+ setOperation(null);
+ }
+ }
+
function toggle(providerId: string) {
setExpanded((current) => {
const next = new Set(current);
@@ -564,6 +802,16 @@ export function ProviderSettingsPage({
: message?.text}
)}
+
)}
+ {addingCustomProvider && (
+ setAddingCustomProvider(false)}
+ onSave={registerCustom}
+ />
+ )}
{providers.map((provider) => {
const { enabled } = provider;
@@ -751,6 +1007,21 @@ export function ProviderSettingsPage({
{t("settings.needsNode")}
)}
+ {provider.custom && (
+
+
+
+ )}
Promise;
+ customProviderRegistrar?: (
+ configuration: CustomProviderConfiguration
+ ) => Promise;
+ customProviderRemover?: (provider: string) => Promise;
+ customProviderRemoveConfirmer?: (message: string) => Promise;
deviceSyncStatusLoader?: () => Promise;
deviceSyncEnabledSaver?: (enabled: boolean) => Promise;
deviceSyncStarter?: () => Promise;
@@ -619,6 +628,9 @@ export function SettingsPage({
upgrader={providerUpgrader}
enabledSaver={providerEnabledSaver}
configurationSaver={providerConfigurationSaver}
+ customProviderRegistrar={customProviderRegistrar}
+ customProviderRemover={customProviderRemover}
+ customProviderRemoveConfirmer={customProviderRemoveConfirmer}
/>
)}
{tab === "developer" && (
diff --git a/apps/desktop/tests/providerSettingsRendered.test.tsx b/apps/desktop/tests/providerSettingsRendered.test.tsx
index 69dc4509..b185ad78 100644
--- a/apps/desktop/tests/providerSettingsRendered.test.tsx
+++ b/apps/desktop/tests/providerSettingsRendered.test.tsx
@@ -1,7 +1,17 @@
// @ts-nocheck
import { afterEach, describe, expect, test } from "bun:test";
-import { activateDom, dom, flush, mount, restoreDom } from "./domTestHarness";
+import { act as reactAct } from "react";
+import { Simulate } from "react-dom/test-utils";
+
+import {
+ activateDom,
+ button,
+ dom,
+ flush,
+ mount,
+ restoreDom,
+} from "./domTestHarness";
activateDom();
const { SettingsPage } = await import("../src/settings/SettingsPage");
@@ -15,6 +25,23 @@ afterEach(() => {
restoreDom();
});
+async function setValue(
+ element: HTMLInputElement | HTMLTextAreaElement,
+ value: string
+) {
+ await reactAct(async () => {
+ const prototype =
+ element instanceof dom.window.HTMLTextAreaElement
+ ? dom.window.HTMLTextAreaElement.prototype
+ : dom.window.HTMLInputElement.prototype;
+ Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(
+ element,
+ value
+ );
+ Simulate.change(element);
+ });
+}
+
describe("Provider settings capabilities", () => {
test("shows only capabilities the provider can actually expose", async () => {
const view = mount(
@@ -346,4 +373,124 @@ describe("Provider settings capabilities", () => {
view.unmount();
});
+
+ test("registers and removes a user-configured ACP agent", async () => {
+ const registered = [];
+ const removed = [];
+ const confirmations = [];
+ const customProvider = {
+ id: "existing-agent",
+ display_name: "Existing Agent",
+ available: true,
+ enabled: true,
+ custom: true,
+ needs_node: false,
+ models: [],
+ capabilities: [],
+ management: {
+ installed: true,
+ version: null,
+ latest_version: null,
+ update_available: null,
+ check_error: null,
+ install_supported: false,
+ upgrade_supported: false,
+ launch_mode: "installed",
+ },
+ configuration: {
+ display_name: "Existing Agent",
+ command: "existing-agent",
+ args: ["acp"],
+ home_path: null,
+ home_environment: null,
+ forwarded_environment: [],
+ missing_environment: [],
+ effective_command: "existing-agent",
+ effective_args: ["acp"],
+ },
+ };
+ const view = mount(
+
+ {}}
+ providers={[customProvider]}
+ provider="existing-agent"
+ projectPath="/workspace"
+ project={null}
+ onProjectWorktreeMode={async () => {}}
+ memoryEnabled={false}
+ initialTab="providers"
+ onClose={() => {}}
+ onReloadProviders={async () => [customProvider]}
+ customProviderRegistrar={async (configuration) => {
+ registered.push(configuration);
+ }}
+ customProviderRemover={async (id) => {
+ removed.push(id);
+ }}
+ customProviderRemoveConfirmer={async (message) => {
+ confirmations.push(message);
+ return true;
+ }}
+ />
+
+ );
+ await flush();
+
+ await reactAct(async () => button(view.container, "Add ACP Agent").click());
+ await setValue(
+ view.container.querySelector("#custom-provider-id")!,
+ "my-agent"
+ );
+ await setValue(
+ view.container.querySelector("#custom-provider-name")!,
+ "My Agent"
+ );
+ await setValue(
+ view.container.querySelector(
+ "#custom-provider-command"
+ )!,
+ "/opt/my-agent"
+ );
+ await setValue(
+ view.container.querySelector(
+ "#custom-provider-args"
+ )!,
+ "acp\n--stdio"
+ );
+ await setValue(
+ view.container.querySelector(
+ "#custom-provider-environment"
+ )!,
+ "MY_AGENT_TOKEN"
+ );
+ await flush();
+ expect(button(view.container, "Add Agent").disabled).toBe(false);
+ await reactAct(async () => button(view.container, "Add Agent").click());
+ await flush();
+
+ expect(registered).toEqual([
+ {
+ id: "my-agent",
+ display_name: "My Agent",
+ command: "/opt/my-agent",
+ args: ["acp", "--stdio"],
+ forwarded_environment: ["MY_AGENT_TOKEN"],
+ },
+ ]);
+
+ await reactAct(async () =>
+ view.container
+ .querySelector('[data-provider-disclosure="existing-agent"]')
+ ?.click()
+ );
+ await reactAct(async () => button(view.container, "Remove Agent").click());
+ await flush();
+
+ expect(confirmations[0]).toContain("Existing Agent");
+ expect(removed).toEqual(["existing-agent"]);
+ view.unmount();
+ });
});
diff --git a/crates/core/src/plugins/app/plugins/foundation.rs b/crates/core/src/plugins/app/plugins/foundation.rs
index 0bcdf349..3bbfcf3c 100644
--- a/crates/core/src/plugins/app/plugins/foundation.rs
+++ b/crates/core/src/plugins/app/plugins/foundation.rs
@@ -10,7 +10,8 @@ use crate::plugins::app::{json, take_args};
use crate::host_tools::HostToolDiscovery;
use crate::provider::default_registry;
use crate::provider_lifecycle::{
- ProviderLifecycleAction, ProviderLifecycleManager, ProviderRuntimeOverride,
+ CustomProviderConfiguration, ProviderLifecycleAction, ProviderLifecycleManager,
+ ProviderRuntimeOverride,
};
use crate::store::Store;
use crate::kernel::{async_trait, Context, Injection, Plugin, PluginError, PluginResult};
@@ -252,6 +253,10 @@ impl Plugin for ProvidersPlugin {
provider: String,
configuration: ProviderRuntimeOverride,
}
+ #[derive(Deserialize)]
+ struct CustomProviderArgs {
+ configuration: CustomProviderConfiguration,
+ }
let enabled_service = service.clone();
let enabled_context = ctx.clone();
@@ -287,6 +292,38 @@ impl Plugin for ProvidersPlugin {
}
})?;
+ let registered_service = service.clone();
+ let registered_context = ctx.clone();
+ ctx.command("providers.register", move |args| {
+ let service = registered_service.clone();
+ let context = registered_context.clone();
+ async move {
+ let args: CustomProviderArgs = take_args(args)?;
+ service
+ .lifecycle()
+ .register_custom_provider(args.configuration)
+ .map_err(PluginError::new)?;
+ context.reload();
+ json(true)
+ }
+ })?;
+
+ let removed_service = service.clone();
+ let removed_context = ctx.clone();
+ ctx.command("providers.remove", move |args| {
+ let service = removed_service.clone();
+ let context = removed_context.clone();
+ async move {
+ let args: ProviderActionArgs = take_args(args)?;
+ service
+ .lifecycle()
+ .remove_custom_provider(&args.provider)
+ .map_err(PluginError::new)?;
+ context.reload();
+ json(true)
+ }
+ })?;
+
let install_service = service.clone();
let install_context = ctx.clone();
ctx.command("providers.install", move |args| {
diff --git a/crates/core/src/plugins/app/service.rs b/crates/core/src/plugins/app/service.rs
index 3895e13a..c99b1ac0 100644
--- a/crates/core/src/plugins/app/service.rs
+++ b/crates/core/src/plugins/app/service.rs
@@ -185,6 +185,7 @@ impl EventBus {
pub struct ProviderSummary {
pub id: String,
pub display_name: String,
+ pub custom: bool,
pub available: bool,
pub enabled: bool,
pub needs_node: bool,
@@ -307,10 +308,11 @@ impl ProviderService {
let management = lifecycle.status(&provider, check_updates).await;
let configuration = lifecycle
.runtime_configuration(&provider)
- .expect("registered provider has a lifecycle recipe");
+ .expect("registered provider has lifecycle configuration");
let summary = ProviderSummary {
id: provider.id.as_str().to_string(),
display_name: provider.display_name.clone(),
+ custom: matches!(&provider.id, crate::provider::ProviderId::Custom(_)),
available: enabled && management.launch_mode != ProviderLaunchMode::Unavailable,
enabled,
needs_node: provider.needs_node,
diff --git a/crates/core/src/provider_lifecycle.rs b/crates/core/src/provider_lifecycle.rs
index df5ac629..e2c49468 100644
--- a/crates/core/src/provider_lifecycle.rs
+++ b/crates/core/src/provider_lifecycle.rs
@@ -1,9 +1,9 @@
-//! Durable provider enablement and the reviewed install/upgrade recipes exposed by the UI.
+//! Durable provider enablement, reviewed install recipes, and explicit user-registered ACP agents.
//!
-//! Renderer input chooses only a provider id and an action. Executables, package names, URLs and
-//! flags remain fixed here so the plugin bridge never becomes an arbitrary process launcher.
+//! Built-in launch definitions stay fixed here. Custom definitions are validated, saved without
+//! environment values, and launched directly without involving a shell.
-use crate::provider::{which, Provider};
+use crate::provider::{which, LaunchSpec, Provider, ProviderId};
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
@@ -69,6 +69,20 @@ pub struct ProviderRuntimeConfiguration {
pub effective_args: Vec,
}
+/// One user-registered local command that speaks ACP over stdio.
+///
+/// Environment values remain host-owned; only the names to forward are durable.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct CustomProviderConfiguration {
+ pub id: String,
+ pub display_name: String,
+ pub command: String,
+ #[serde(default)]
+ pub args: Vec,
+ #[serde(default)]
+ pub forwarded_environment: Vec,
+}
+
#[derive(Debug, Clone)]
struct ProviderRecipe {
id: &'static str,
@@ -252,6 +266,8 @@ struct ProviderLifecycleState {
enabled: HashMap,
#[serde(default)]
runtime: HashMap,
+ #[serde(default)]
+ custom: Vec,
}
impl Default for ProviderLifecycleState {
@@ -260,6 +276,7 @@ impl Default for ProviderLifecycleState {
schema_version: 1,
enabled: HashMap::new(),
runtime: HashMap::new(),
+ custom: Vec::new(),
}
}
}
@@ -284,7 +301,7 @@ impl ProviderLifecycleManager {
}
pub fn enabled(&self, provider_id: &str) -> Result {
- recipe(provider_id)?;
+ self.ensure_registered(provider_id)?;
Ok(self
.state
.lock()
@@ -296,7 +313,7 @@ impl ProviderLifecycleManager {
}
pub fn set_enabled(&self, provider_id: &str, enabled: bool) -> Result<(), String> {
- recipe(provider_id)?;
+ self.ensure_registered(provider_id)?;
let mut state = self.state.lock().unwrap();
let mut next = state.clone();
next.enabled.insert(provider_id.to_string(), enabled);
@@ -310,7 +327,34 @@ impl ProviderLifecycleManager {
provider_id: &str,
configuration: ProviderRuntimeOverride,
) -> Result<(), String> {
- recipe(provider_id)?;
+ if recipe(provider_id).is_err() {
+ if configuration.home_path.is_some() {
+ return Err("custom providers do not support a managed config directory".into());
+ }
+ let mut state = self.state.lock().unwrap();
+ let Some(index) = state.custom.iter().position(|item| item.id == provider_id) else {
+ return Err(format!("unknown provider {provider_id:?}"));
+ };
+ let configuration = validate_custom_provider(CustomProviderConfiguration {
+ id: provider_id.to_string(),
+ display_name: configuration
+ .display_name
+ .ok_or_else(|| "custom provider display name is required".to_string())?,
+ command: configuration
+ .command
+ .ok_or_else(|| "custom provider runtime command is required".to_string())?,
+ args: configuration.args.unwrap_or_default(),
+ forwarded_environment: configuration.forwarded_environment,
+ })?;
+ if configuration.id != provider_id {
+ return Err("custom provider id cannot be changed".into());
+ }
+ let mut next = state.clone();
+ next.custom[index] = configuration;
+ save_state(&self.data_dir, &self.state_path, &next)?;
+ *state = next;
+ return Ok(());
+ }
let configuration = validate_runtime_configuration(provider_id, configuration)?;
let mut state = self.state.lock().unwrap();
let mut next = state.clone();
@@ -328,7 +372,31 @@ impl ProviderLifecycleManager {
&self,
provider: &Provider,
) -> Result {
- recipe(provider.id.as_str())?;
+ if recipe(provider.id.as_str()).is_err() {
+ let state = self.state.lock().unwrap();
+ let configured = state
+ .custom
+ .iter()
+ .find(|item| item.id == provider.id.as_str())
+ .ok_or_else(|| format!("unknown provider {:?}", provider.id.as_str()))?;
+ let missing_environment = configured
+ .forwarded_environment
+ .iter()
+ .filter(|name| std::env::var_os(name).is_none())
+ .cloned()
+ .collect();
+ return Ok(ProviderRuntimeConfiguration {
+ display_name: Some(configured.display_name.clone()),
+ command: Some(configured.command.clone()),
+ args: Some(configured.args.clone()),
+ home_path: None,
+ home_environment: None,
+ forwarded_environment: configured.forwarded_environment.clone(),
+ missing_environment,
+ effective_command: provider.launch.command.clone(),
+ effective_args: provider.launch.args.clone(),
+ });
+ }
let configured = self
.state
.lock()
@@ -442,7 +510,31 @@ impl ProviderLifecycleManager {
provider.needs_node = false;
}
}
- let configured = self.state.lock().unwrap().runtime.clone();
+ let state = self.state.lock().unwrap().clone();
+ for configuration in &state.custom {
+ let mut launch = LaunchSpec {
+ command: expand_home(&configuration.command),
+ args: configuration.args.clone(),
+ env: Vec::new(),
+ cwd: None,
+ };
+ for name in &configuration.forwarded_environment {
+ if let Some(value) = std::env::var_os(name) {
+ set_launch_environment(
+ &mut launch.env,
+ name,
+ value.to_string_lossy().into_owned(),
+ );
+ }
+ }
+ providers.push(Provider {
+ id: ProviderId::Custom(configuration.id.clone()),
+ display_name: configuration.display_name.clone(),
+ launch,
+ needs_node: false,
+ });
+ }
+ let configured = state.runtime;
for provider in &mut providers {
let Some(configuration) = configured.get(provider.id.as_str()) else {
continue;
@@ -475,6 +567,57 @@ impl ProviderLifecycleManager {
providers
}
+ pub fn register_custom_provider(
+ &self,
+ configuration: CustomProviderConfiguration,
+ ) -> Result<(), String> {
+ let configuration = validate_custom_provider(configuration)?;
+ if recipe(&configuration.id).is_ok() {
+ return Err(format!(
+ "provider id {:?} belongs to a built-in provider",
+ configuration.id
+ ));
+ }
+ let mut state = self.state.lock().unwrap();
+ if state.custom.iter().any(|item| item.id == configuration.id) {
+ return Err(format!("provider {:?} already exists", configuration.id));
+ }
+ let mut next = state.clone();
+ next.custom.push(configuration);
+ save_state(&self.data_dir, &self.state_path, &next)?;
+ *state = next;
+ Ok(())
+ }
+
+ pub fn remove_custom_provider(&self, provider_id: &str) -> Result<(), String> {
+ let mut state = self.state.lock().unwrap();
+ let Some(index) = state.custom.iter().position(|item| item.id == provider_id) else {
+ return Err(format!("unknown custom provider {provider_id:?}"));
+ };
+ let mut next = state.clone();
+ next.custom.remove(index);
+ next.enabled.remove(provider_id);
+ save_state(&self.data_dir, &self.state_path, &next)?;
+ *state = next;
+ Ok(())
+ }
+
+ fn ensure_registered(&self, provider_id: &str) -> Result<(), String> {
+ if recipe(provider_id).is_ok()
+ || self
+ .state
+ .lock()
+ .unwrap()
+ .custom
+ .iter()
+ .any(|item| item.id == provider_id)
+ {
+ Ok(())
+ } else {
+ Err(format!("unknown provider {provider_id:?}"))
+ }
+ }
+
pub async fn apply(
&self,
provider_id: &str,
@@ -733,6 +876,53 @@ fn valid_environment_name(name: &str) -> bool {
&& name.len() <= 128
}
+fn valid_custom_provider_id(id: &str) -> bool {
+ let mut characters = id.chars();
+ let Some(first) = characters.next() else {
+ return false;
+ };
+ id.len() <= 64
+ && first.is_ascii_lowercase()
+ && characters.all(|character| {
+ character.is_ascii_lowercase()
+ || character.is_ascii_digit()
+ || matches!(character, '-' | '_' | '.')
+ })
+}
+
+fn validate_custom_provider(
+ configuration: CustomProviderConfiguration,
+) -> Result {
+ let id = configuration.id.trim();
+ if !valid_custom_provider_id(id) {
+ return Err(
+ "custom provider id must start with a lowercase letter and contain only lowercase letters, digits, '.', '-', or '_'"
+ .into(),
+ );
+ }
+ let normalized = validate_runtime_configuration(
+ id,
+ ProviderRuntimeOverride {
+ display_name: Some(configuration.display_name),
+ command: Some(configuration.command),
+ args: Some(configuration.args),
+ home_path: None,
+ forwarded_environment: configuration.forwarded_environment,
+ },
+ )?;
+ Ok(CustomProviderConfiguration {
+ id: id.to_string(),
+ display_name: normalized
+ .display_name
+ .ok_or_else(|| "custom provider display name is required".to_string())?,
+ command: normalized
+ .command
+ .ok_or_else(|| "custom provider runtime command is required".to_string())?,
+ args: normalized.args.unwrap_or_default(),
+ forwarded_environment: normalized.forwarded_environment,
+ })
+}
+
fn validate_runtime_configuration(
provider_id: &str,
configuration: ProviderRuntimeOverride,
@@ -825,9 +1015,17 @@ fn load_state(path: &Path) -> ProviderLifecycleState {
if state.schema_version != 1 {
return ProviderLifecycleState::default();
}
+ let mut seen = HashSet::new();
+ state.custom = std::mem::take(&mut state.custom)
+ .into_iter()
+ .filter_map(|configuration| validate_custom_provider(configuration).ok())
+ .filter(|configuration| {
+ recipe(&configuration.id).is_err() && seen.insert(configuration.id.clone())
+ })
+ .collect();
state
.enabled
- .retain(|id, _| RECIPES.iter().any(|recipe| recipe.id == id));
+ .retain(|id, _| RECIPES.iter().any(|recipe| recipe.id == id) || seen.contains(id));
state
.runtime
.retain(|id, _| RECIPES.iter().any(|recipe| recipe.id == id));
@@ -1002,6 +1200,168 @@ mod tests {
.is_err());
}
+ #[test]
+ fn custom_acp_agents_are_durable_editable_and_removable() {
+ let directory = tempfile::tempdir().unwrap();
+ let manager = ProviderLifecycleManager::open(directory.path());
+ manager
+ .register_custom_provider(CustomProviderConfiguration {
+ id: "my-agent".into(),
+ display_name: "My Agent".into(),
+ command: "~/bin/my-agent".into(),
+ args: vec!["acp".into()],
+ forwarded_environment: vec!["MY_AGENT_TOKEN".into()],
+ })
+ .unwrap();
+
+ let reopened = ProviderLifecycleManager::open(directory.path());
+ let providers = reopened.prepare_registry(crate::provider::default_registry());
+ let custom = providers
+ .iter()
+ .find(|provider| provider.id.as_str() == "my-agent")
+ .unwrap();
+ assert_eq!(
+ custom.id,
+ crate::provider::ProviderId::Custom("my-agent".into())
+ );
+ assert_eq!(custom.display_name, "My Agent");
+ assert!(custom.launch.command.ends_with("/bin/my-agent"));
+ assert_eq!(custom.launch.args, ["acp"]);
+ assert!(!custom.needs_node);
+ assert!(!custom.id.supports_native_subagents());
+
+ let configuration = reopened.runtime_configuration(custom).unwrap();
+ assert_eq!(configuration.display_name.as_deref(), Some("My Agent"));
+ assert_eq!(configuration.command.as_deref(), Some("~/bin/my-agent"));
+ assert_eq!(configuration.forwarded_environment, ["MY_AGENT_TOKEN"]);
+ assert_eq!(configuration.missing_environment, ["MY_AGENT_TOKEN"]);
+
+ reopened
+ .set_runtime_configuration(
+ "my-agent",
+ ProviderRuntimeOverride {
+ display_name: Some("My Edited Agent".into()),
+ command: Some("my-edited-agent".into()),
+ args: Some(vec!["--stdio".into()]),
+ forwarded_environment: vec![],
+ ..ProviderRuntimeOverride::default()
+ },
+ )
+ .unwrap();
+ let edited = reopened.prepare_registry(crate::provider::default_registry());
+ let edited = edited
+ .iter()
+ .find(|provider| provider.id.as_str() == "my-agent")
+ .unwrap();
+ assert_eq!(edited.display_name, "My Edited Agent");
+ assert_eq!(edited.launch.command, "my-edited-agent");
+ assert_eq!(edited.launch.args, ["--stdio"]);
+
+ assert!(reopened
+ .set_runtime_configuration(
+ "my-agent",
+ ProviderRuntimeOverride {
+ display_name: Some("Broken Agent".into()),
+ command: Some("broken-agent".into()),
+ args: Some(vec![]),
+ forwarded_environment: vec!["TOKEN=value".into()],
+ ..ProviderRuntimeOverride::default()
+ },
+ )
+ .is_err());
+ let unchanged = reopened.prepare_registry(crate::provider::default_registry());
+ let unchanged = unchanged
+ .iter()
+ .find(|provider| provider.id.as_str() == "my-agent")
+ .unwrap();
+ assert_eq!(unchanged.display_name, "My Edited Agent");
+ assert_eq!(unchanged.launch.command, "my-edited-agent");
+
+ reopened.remove_custom_provider("my-agent").unwrap();
+ assert!(reopened
+ .prepare_registry(crate::provider::default_registry())
+ .iter()
+ .all(|provider| provider.id.as_str() != "my-agent"));
+ assert!(ProviderLifecycleManager::open(directory.path())
+ .prepare_registry(crate::provider::default_registry())
+ .iter()
+ .all(|provider| provider.id.as_str() != "my-agent"));
+ }
+
+ #[test]
+ fn custom_acp_agents_reject_collisions_invalid_ids_and_secret_values() {
+ let directory = tempfile::tempdir().unwrap();
+ let manager = ProviderLifecycleManager::open(directory.path());
+ let valid = CustomProviderConfiguration {
+ id: "private-agent".into(),
+ display_name: "Private Agent".into(),
+ command: "private-agent".into(),
+ args: vec![],
+ forwarded_environment: vec!["PRIVATE_AGENT_TOKEN".into()],
+ };
+ manager.register_custom_provider(valid.clone()).unwrap();
+ assert!(manager.register_custom_provider(valid).is_err());
+ assert!(manager
+ .register_custom_provider(CustomProviderConfiguration {
+ id: "codex".into(),
+ display_name: "Collision".into(),
+ command: "collision".into(),
+ args: vec![],
+ forwarded_environment: vec![],
+ })
+ .is_err());
+ assert!(manager
+ .register_custom_provider(CustomProviderConfiguration {
+ id: "Unsafe ID".into(),
+ display_name: "Unsafe".into(),
+ command: "unsafe".into(),
+ args: vec![],
+ forwarded_environment: vec![],
+ })
+ .is_err());
+ assert!(manager
+ .register_custom_provider(CustomProviderConfiguration {
+ id: "bad-env".into(),
+ display_name: "Bad env".into(),
+ command: "bad-env".into(),
+ args: vec![],
+ forwarded_environment: vec!["TOKEN=secret-value".into()],
+ })
+ .is_err());
+ assert!(manager
+ .register_custom_provider(CustomProviderConfiguration {
+ id: "missing-name".into(),
+ display_name: " ".into(),
+ command: "missing-name".into(),
+ args: vec![],
+ forwarded_environment: vec![],
+ })
+ .is_err());
+ assert!(manager
+ .register_custom_provider(CustomProviderConfiguration {
+ id: "missing-command".into(),
+ display_name: "Missing command".into(),
+ command: " ".into(),
+ args: vec![],
+ forwarded_environment: vec![],
+ })
+ .is_err());
+ assert!(manager
+ .register_custom_provider(CustomProviderConfiguration {
+ id: "too-many-args".into(),
+ display_name: "Too many arguments".into(),
+ command: "too-many-args".into(),
+ args: vec!["argument".into(); 65],
+ forwarded_environment: vec![],
+ })
+ .is_err());
+
+ let persisted =
+ fs::read_to_string(directory.path().join("provider-settings.json")).unwrap();
+ assert!(persisted.contains("PRIVATE_AGENT_TOKEN"));
+ assert!(!persisted.contains("secret-value"));
+ }
+
#[cfg(windows)]
#[test]
fn cursor_does_not_offer_the_unix_installer_on_windows() {
diff --git a/docs/sdlc/changes/2026-09-21-custom-acp-agents/intent.md b/docs/sdlc/changes/2026-09-21-custom-acp-agents/intent.md
new file mode 100644
index 00000000..6eb23d30
--- /dev/null
+++ b/docs/sdlc/changes/2026-09-21-custom-acp-agents/intent.md
@@ -0,0 +1,38 @@
+---
+id: 2026-09-21-custom-acp-agents
+schema: 5
+stage: intent
+status: accepted
+owner: codex
+created: 2026-09-21
+source: user
+risk: high
+approved_by: chenli
+approved_at: 2026-09-21
+approval_source: 'Direct request: "支持 用户自由配置 支持ACP 的Agent".'
+next_trigger: Implement and independently verify the accepted design.
+---
+
+# Intent: Custom ACP agents
+
+## Intent
+
+C2 already models unknown provider IDs as `ProviderId::Custom` and can run an overridden command for
+each built-in provider, but `provider-settings.json` discards unknown IDs and Settings cannot add or
+remove one. The public provider guide therefore promises support that users cannot actually
+configure.
+
+Outcome: Settings lets a user register, edit, enable, select, and remove a local Agent command that
+speaks ACP over stdio. Custom Agents use the existing provider registry, ACP client, session,
+permission, transcript, and tool-broker paths; their durable configuration has one owner in the
+existing provider settings file.
+
+Constraints: registration requires a stable ID, display name, executable, optional one-argument-per-
+line launch arguments, and names of host environment variables to forward. Forwarded environment
+values never enter the durable configuration or return to the renderer. IDs cannot collide with
+built-ins. A configured command is user-authorized local process execution but gains no extra ACP
+permission, native-subagent, MCP, filesystem, deployment, or external-write authority.
+
+Non-goals: remote ACP transports, shell command strings, automatic installation/upgrades for custom
+Agents, per-project Agent definitions, capability claims that the Agent did not negotiate, or any new
+ACP dialect.
diff --git a/docs/sdlc/changes/2026-09-21-custom-acp-agents/plan.md b/docs/sdlc/changes/2026-09-21-custom-acp-agents/plan.md
new file mode 100644
index 00000000..c49e6c13
--- /dev/null
+++ b/docs/sdlc/changes/2026-09-21-custom-acp-agents/plan.md
@@ -0,0 +1,39 @@
+---
+id: 2026-09-21-custom-acp-agents
+schema: 5
+stage: plan
+status: accepted
+owner: codex
+created: 2026-09-21
+based_on: spec.md
+scope: crates/core/src/provider_lifecycle.rs, crates/core/src/plugins/app/service.rs, crates/core/src/plugins/app/plugins/foundation.rs, apps/desktop/src/bridge.ts, apps/desktop/src/settings/ProviderSettings.tsx, apps/desktop/src/settings/SettingsPage.tsx, apps/desktop/src/i18n/strings.ts, apps/desktop/tests/providerSettingsRendered.test.tsx, website/guide/providers.md, website/zh/guide/providers.md, docs/sdlc/changes/2026-09-21-custom-acp-agents
+---
+
+# Plan: Custom ACP agents
+
+## Plan
+
+1. Extend `ProviderLifecycleManager` persistence and validation with custom definitions; append them to
+ the existing registry and cover durable registration, editing, removal, invalid input, and secret
+ non-persistence with focused Rust tests.
+2. Project a `custom` discriminator through `ProviderSummary`, add register/remove provider commands,
+ and keep all launches in the current Engine/ACP path.
+3. Add typed desktop bridge calls and the smallest Settings UI: inline add form, existing runtime
+ editor, existing enable switch, and confirmed remove action. Cover the interactions with the
+ rendered Settings test.
+4. Update English and Chinese provider guides with the actual flow and boundaries.
+
+Checks by risk: run focused core provider lifecycle tests, affected desktop rendered tests, desktop
+type/lint/build checks, docs/link validation, `git diff --check`, and
+`bun script/verify/sdlc.ts --worktree`. Inspect the real rendered Providers page because this adds
+interactive layout. Independent verification remains required before the high-risk record can pass.
+No protocol-wire, database, package, or release check applies because the ACP implementation and
+session schema are unchanged.
+
+Temporary resources: a renderer-only local preview and its generated build output may be created for
+visual inspection; stop the task-owned preview and remove task-only screenshots after inspection.
+Standard ignored dependency/build caches may remain under their existing owners.
+
+Rollback: revert the source/UI/docs changes. Existing schema-1 files remain readable because the new
+custom collection is optional; reverting simply ignores that field. Removing one custom entry through
+Settings deletes only its C2 launch definition, not sessions, credentials, or provider-owned files.
diff --git a/docs/sdlc/changes/2026-09-21-custom-acp-agents/spec.md b/docs/sdlc/changes/2026-09-21-custom-acp-agents/spec.md
new file mode 100644
index 00000000..4be51fc4
--- /dev/null
+++ b/docs/sdlc/changes/2026-09-21-custom-acp-agents/spec.md
@@ -0,0 +1,53 @@
+---
+id: 2026-09-21-custom-acp-agents
+schema: 5
+stage: spec
+status: accepted
+owner: codex
+created: 2026-09-21
+based_on: intent.md
+design_approved_by: chenli
+design_approved_at: 2026-09-21
+design_approval_source: 'Direct request: "支持 用户自由配置 支持ACP 的Agent".'
+---
+
+# Spec: Custom ACP agents
+
+## Design
+
+Extend the existing provider lifecycle state with an ordered collection of custom Agent definitions.
+Each definition owns its ID, display name, executable, argument vector, and forwarded environment
+variable names. Loading validates the same bounds as writes, rejects built-in collisions and duplicate
+IDs, and fails closed by omitting invalid definitions. Built-in runtime overrides remain unchanged.
+
+`prepare_registry` appends valid custom definitions as `ProviderId::Custom` launch specs before the
+existing `ProviderService` and Engine consume the registry. This preserves one engine and one ACP
+stdio implementation. Custom Agents report no built-in models or native subagent support, expose no
+automatic install/upgrade action, and are available only when their executable resolves.
+
+The provider command surface adds explicit register and remove operations. Configure and enable
+continue through the existing operations. Registration/removal persist atomically, then reload the
+provider plugin so every new session and picker sees one coherent registry. Settings exposes a small
+inline form and a remove action only for custom entries. Removal requires user confirmation and does
+not delete provider-owned files, credentials, or past C2 sessions.
+
+This design expands local process-launch authority only at the user's explicit registration action.
+It does not use a shell, store environment values, auto-run on registration, alter ACP permission
+mediation, or authorize external actions.
+
+## Acceptance criteria
+
+- [x] AC-1: A valid custom ACP Agent can be added in Settings with ID, name, command, arguments, and
+ forwarded environment names; it survives manager reload and appears in the provider registry.
+- [x] AC-2: The custom Agent is selectable for a new session and uses the existing ACP engine path;
+ missing commands are shown as unavailable and no capability or native-subagent support is
+ invented.
+- [x] AC-3: Duplicate/built-in/invalid IDs, missing names or commands, unsafe environment names, and
+ oversized inputs fail without corrupting the previous durable configuration; environment values
+ are never persisted or returned.
+- [x] AC-4: A custom Agent can be edited, enabled/disabled, and removed from Settings; removal is
+ confirmed, affects future registry loads, and preserves historical sessions and external files.
+- [x] AC-5: English and Chinese provider guidance describes the Settings flow and its local-command,
+ credentials, permission, and ACP-compatibility boundaries.
+- [x] AC-6: Affected Rust contracts, rendered Settings interactions, type/lint/build checks,
+ documentation checks, and the repository SDLC check pass; actual Settings UI is inspected.
diff --git a/docs/sdlc/changes/2026-09-21-custom-acp-agents/verification.md b/docs/sdlc/changes/2026-09-21-custom-acp-agents/verification.md
new file mode 100644
index 00000000..4b3ec909
--- /dev/null
+++ b/docs/sdlc/changes/2026-09-21-custom-acp-agents/verification.md
@@ -0,0 +1,71 @@
+---
+id: 2026-09-21-custom-acp-agents
+schema: 5
+stage: verification
+status: in-progress
+owner: codex
+created: 2026-09-21
+based_on: plan.md
+revision: "worktree based on bd8b32591057ac9bf53d2aa4ba95122d8b3721db with the uncommitted custom ACP Agent change applied"
+verification_mode: owner
+verified_by: codex
+verified_at: 2026-09-21T14:11:12+08:00
+release_target: none
+cleanup_status: complete
+next_trigger: Obtain independent high-risk verification before approval or delivery beyond this worktree.
+---
+
+# Verification: Custom ACP agents
+
+## Verification
+
+- AC-1: PASS — `custom_acp_agents_are_durable_editable_and_removable` proves registration,
+ schema-1 reload, registry projection, editing, and removal. The rendered Settings test proves the
+ form sends exact ID, name, executable, argument vector, and forwarded environment names.
+- AC-2: PASS — The lifecycle test projects the definition as `ProviderId::Custom`, deliberately
+ reports no native subagent support, and the unchanged provider service feeds enabled registry
+ entries into the existing Engine. The ACP regression group passes full prompt-turn, model/config,
+ and MCP-forwarding contracts. Missing executables remain unavailable through the existing status
+ calculation.
+- AC-3: PASS — Focused Rust tests reject duplicates, built-in collisions, malformed IDs, absent
+ names/commands, too many arguments, and environment `NAME=value` input. A failed edit leaves the
+ previous configuration intact; persisted JSON contains an environment name but not its rejected
+ value.
+- AC-4: PASS — Focused Rust tests cover durable edit/enable/remove behavior. The rendered Settings
+ interaction verifies confirmation before remove; the operation touches only provider settings.
+- AC-5: PASS — English and Chinese guides document direct stdio launch, exact arguments,
+ environment-name forwarding, permission/capability boundaries, deferred process start, and safe
+ removal. `bun script/verify/docs.ts` passed during owner verification.
+- AC-6: PASS (owner) — `cargo test -p codetwo-core provider_lifecycle` (9 passed); ACP integration
+ tests (9 passed); rendered provider tests (6 passed); `bunx tsc --noEmit`; `bun run lint`; and
+ `bun run build:renderer` all passed. The build emitted only the existing large-chunk warning. The
+ Providers page was inspected in the real renderer in dark and light themes at full and 760-pixel
+ widths; the add form, labels, controls, and list remained readable without overlap.
+
+Verdict: owner verification passes; the high-risk record remains in progress until an independent
+verifier reviews the user-configured process boundary.
+
+Residual risk: no third-party ACP executable was launched during this change, so a particular
+Agent's handshake, authentication, and provider-specific behavior remain that Agent's integration
+responsibility. Production, remote CI, approval, commit, and release were not requested or claimed.
+
+## Cleanup
+
+Removed: The renderer-only preview was stopped. The generated `apps/desktop/dist` directory was
+moved to the macOS Trash; no screenshots were retained.
+Retained: Existing Cargo build output and `apps/desktop/node_modules` dependency cache.
+Retention owner: Repository tooling and the local developer environment.
+Cleanup trigger: Normal dependency/build-cache maintenance; no change-specific cleanup remains.
+Processes: No task-owned process remains. An unrelated Vite process in another worktree was observed
+and left untouched.
+Evidence: Build completed before cleanup; `apps/desktop/dist` is absent; task preview process is
+absent from the process list.
+
+## Review and release
+
+Approval: Owner implementation evidence complete; independent high-risk verification pending.
+Delivery authorization: User explicitly requested `pr & merge` on 2026-09-21, authorizing commit,
+push, PR creation, and merge for this change after the recorded gates pass.
+Rollback: See plan.md.
+Release: No release requested; merge and external actions require their own authorization.
+Feedback: Link an Incident and regression Eval when a real failure occurs.
diff --git a/website/guide/providers.md b/website/guide/providers.md
index 176f2bc4..0f3f529f 100644
--- a/website/guide/providers.md
+++ b/website/guide/providers.md
@@ -60,6 +60,24 @@ For GLM, provide `Z_AI_API_KEY` in the environment or run:
npx -y glm-acp-agent --setup
```
+## Add a custom ACP Agent
+
+Open **Settings → Providers → Add ACP Agent**. Give the Agent a stable lowercase ID, display name,
+executable or absolute path, optional launch arguments (one per line), and optional host environment
+variable names to forward. C2 stores only environment variable names; their current values remain in
+the host process and are copied only when the Agent starts.
+
+The command must be an ACP server over stdin/stdout. C2 launches it directly without a shell, so do
+not enter a shell pipeline or a command plus space-separated arguments in the command field. Put each
+argument on its own line instead. Adding an Agent does not run it; C2 starts it when a session using
+that Provider sends its first prompt.
+
+Custom Agents use the same sessions, permission mediation, transcripts, MCP attachment, and ACP
+capability negotiation as built-ins. C2 does not assume model lists, native subagent support, install
+recipes, or provider-specific capabilities. The Agent remains responsible for its own credentials,
+account, network behavior, and billing. Removing it from Settings removes only the C2 launch
+definition; existing session records and Agent-owned files remain untouched.
+
### Provider-native subagents
C2 never schedules child agents itself. Plugin subagent blocks are sent only to providers whose
diff --git a/website/zh/guide/providers.md b/website/zh/guide/providers.md
index 566b28a8..fc23fc26 100644
--- a/website/zh/guide/providers.md
+++ b/website/zh/guide/providers.md
@@ -52,6 +52,21 @@ GLM 可以通过环境变量提供 `Z_AI_API_KEY`,也可以先运行:
npx -y glm-acp-agent --setup
```
+## 添加自定义 ACP Agent
+
+打开 **设置 → Provider → 添加 ACP Agent**,填写稳定的小写 ID、显示名称、可执行程序名或绝对
+路径,并按需填写启动参数(每行一个)和需要转发的宿主环境变量名。C2 只保存环境变量名;变量
+当前值仍由宿主进程持有,只会在启动 Agent 时复制给子进程。
+
+该命令必须通过 stdin/stdout 提供 ACP Server。C2 会直接启动可执行程序,不经过 shell,因此
+不要在命令输入框中填写管道或“命令 + 空格分隔参数”;请把每个参数单独放在一行。添加 Agent
+时不会立即运行它;只有使用该 Provider 的会话发送第一条提示词时,C2 才会启动进程。
+
+自定义 Agent 复用与内置 Provider 相同的会话、权限协调、转录记录、MCP 挂载和 ACP 能力协商。
+C2 不会预设它支持某些模型、原生子代理、自动安装或 Provider 专属能力。Agent 自己负责凭据、
+账户、网络行为和费用。从设置中移除只会删除 C2 的启动定义,不会删除历史会话或 Agent 自己的
+文件。
+
### Provider 原生子代理
C2 不会自行调度子代理。只有当前原生运行时或适配器已经确认提供 Agent/Task 委派工具时,
From b0e32f97f509f8299b62d6b6c4f90e88784f42e3 Mon Sep 17 00:00:00 2001
From: idevlab
Date: Mon, 21 Sep 2026 14:26:55 +0800
Subject: [PATCH 2/2] docs: record custom ACP agent verification
---
.../verification.md | 38 ++++++++++---------
1 file changed, 21 insertions(+), 17 deletions(-)
diff --git a/docs/sdlc/changes/2026-09-21-custom-acp-agents/verification.md b/docs/sdlc/changes/2026-09-21-custom-acp-agents/verification.md
index 4b3ec909..ccfd3b7d 100644
--- a/docs/sdlc/changes/2026-09-21-custom-acp-agents/verification.md
+++ b/docs/sdlc/changes/2026-09-21-custom-acp-agents/verification.md
@@ -2,17 +2,17 @@
id: 2026-09-21-custom-acp-agents
schema: 5
stage: verification
-status: in-progress
+status: passed
owner: codex
created: 2026-09-21
based_on: plan.md
-revision: "worktree based on bd8b32591057ac9bf53d2aa4ba95122d8b3721db with the uncommitted custom ACP Agent change applied"
-verification_mode: owner
-verified_by: codex
-verified_at: 2026-09-21T14:11:12+08:00
+revision: b8d325b7e229cf637b154bcb49f8733837b8e580
+verification_mode: fresh-context
+verified_by: github-actions-35567792230
+verified_at: "2026-09-21"
release_target: none
cleanup_status: complete
-next_trigger: Obtain independent high-risk verification before approval or delivery beyond this worktree.
+next_trigger: Merge the authorized PR after its Ready-state CI remains green.
---
# Verification: Custom ACP agents
@@ -27,27 +27,31 @@ next_trigger: Obtain independent high-risk verification before approval or deliv
entries into the existing Engine. The ACP regression group passes full prompt-turn, model/config,
and MCP-forwarding contracts. Missing executables remain unavailable through the existing status
calculation.
-- AC-3: PASS — Focused Rust tests reject duplicates, built-in collisions, malformed IDs, absent
+- AC-3: PASS — `cargo test -p codetwo-core provider_lifecycle` rejects duplicates, built-in collisions, malformed IDs, absent
names/commands, too many arguments, and environment `NAME=value` input. A failed edit leaves the
previous configuration intact; persisted JSON contains an environment name but not its rejected
- value.
-- AC-4: PASS — Focused Rust tests cover durable edit/enable/remove behavior. The rendered Settings
+ value. Evidence: `cargo test -p codetwo-core provider_lifecycle`.
+- AC-4: PASS — `bun test tests/providerSettingsRendered.test.tsx tests/providerRegistry.test.ts` covers durable edit/enable/remove behavior. The rendered Settings
interaction verifies confirmation before remove; the operation touches only provider settings.
-- AC-5: PASS — English and Chinese guides document direct stdio launch, exact arguments,
+ Evidence: `bun test tests/providerSettingsRendered.test.tsx tests/providerRegistry.test.ts`.
+- AC-5: PASS — `bun script/verify/docs.ts` confirms the English and Chinese guides document direct stdio launch, exact arguments,
environment-name forwarding, permission/capability boundaries, deferred process start, and safe
- removal. `bun script/verify/docs.ts` passed during owner verification.
-- AC-6: PASS (owner) — `cargo test -p codetwo-core provider_lifecycle` (9 passed); ACP integration
+ removal. Evidence: `bun script/verify/docs.ts` passed during owner verification.
+- AC-6: PASS — Owner checks included `cargo test -p codetwo-core provider_lifecycle` (9 passed), ACP integration
tests (9 passed); rendered provider tests (6 passed); `bunx tsc --noEmit`; `bun run lint`; and
`bun run build:renderer` all passed. The build emitted only the existing large-chunk warning. The
Providers page was inspected in the real renderer in dark and light themes at full and 760-pixel
- widths; the add form, labels, controls, and list remained readable without overlap.
+ widths; the add form, labels, controls, and list remained readable without overlap. Independent
+ GitHub Actions run [35567792230](https://github.com/IchenDEV/codeTwo/actions/runs/35567792230)
+ passed SDLC, desktop lint/typecheck/tests/build, Rust workspace check, and Rust workspace tests for
+ revision `b8d325b7e229cf637b154bcb49f8733837b8e580`.
-Verdict: owner verification passes; the high-risk record remains in progress until an independent
-verifier reviews the user-configured process boundary.
+Verdict: verified.
+Owner behavior/UI evidence and independent full-repository CI pass for the implementation revision.
Residual risk: no third-party ACP executable was launched during this change, so a particular
Agent's handshake, authentication, and provider-specific behavior remain that Agent's integration
-responsibility. Production, remote CI, approval, commit, and release were not requested or claimed.
+responsibility. Production deployment and release were not requested or claimed.
## Cleanup
@@ -63,7 +67,7 @@ absent from the process list.
## Review and release
-Approval: Owner implementation evidence complete; independent high-risk verification pending.
+Approval: User authorized PR creation and merge after checks pass with `pr & merge` on 2026-09-21.
Delivery authorization: User explicitly requested `pr & merge` on 2026-09-21, authorizing commit,
push, PR creation, and merge for this change after the recorded gates pass.
Rollback: See plan.md.