fix(skill): tool-gate whitelist + markdown link host normalization

When an agent had any skill bound, the runtime tool gate was silently
hiding @Tool beans that aren't declared in any skill manifest, even
though the global system prompts (SOUL.md / "Web Search Capability" /
"File Reading Guidelines") explicitly tell the LLM these tools are
available. Result: the model would call search / renderDocx / read_file
/ etc., hit "Tool not found", then either give up or fall back to
unhelpful behaviour (e.g. dumping markdown text instead of producing a
.docx download).

This commit:

- Adds universally-promised, agent-wide tools to SYSTEM_LEVEL_TOOLS so
  they bypass the manifest restriction: document/media generation
  (renderDocx*, image_generate, music_generate, video_generate),
  global capability tools the system prompt mentions (search,
  browser_use, read_file / write_file / edit_file /
  execute_shell_command, detect_file_type, extract_*_text,
  readMateClawDoc), skill discovery siblings (listSkillFiles,
  listAvailableSkills), and the delegate triplet (delegateToAgent,
  delegateParallel, listAvailableAgents).

- Fixes 5 entries in the prior whitelist whose names did not match
  any real @Tool bean and were therefore silently dead:
    read_workspace_file   -> read_workspace_memory_file
    write_workspace_file  -> write_workspace_memory_file
    list_workspace_files  -> list_workspace_memory_files
    delegate_agent        -> delegateToAgent
    datetime              -> getCurrentDate / getCurrentDateTime / getCurrentTime
  Also adds the missing edit_workspace_memory_file.

- In the chat markdown renderer, strips any hallucinated
  https?://<host> prefix from /api/v1/files/generated/<id> download
  links before building the <a href>. Multiple LLMs have been
  observed prepending bogus hosts when echoing tool-returned download
  URLs back to the user, breaking the click. One-line defensive
  normalization independent of which model is in use.

Verified end-to-end on a previously-broken agent: search / browser_use
/ execute_shell_command / renderDocx all dispatch correctly now and
the final markdown link is a clean same-origin path. 36 whitelist
entries cross-checked against real @Tool method names.
AgentBindingServiceTest green.
This commit is contained in:
matevip 2026-05-01 12:24:25 +08:00
parent f8ec223bcb
commit 5e6764ecf1
17 changed files with 1275 additions and 44 deletions

View File

@ -166,11 +166,23 @@ public class AcpStdioClient implements AutoCloseable {
* Send {@code session/new} establishes a session for prompting.
* For the connection-test path we don't actually prompt, just
* verify the server accepts the handshake.
*
* <p>The {@code cwd} parameter is always written into the request
* body. Zed's ACP Zod schema (used by {@code @zed-industries/claude-
* agent-acp} and the codex variant) marks {@code cwd} as a required
* string and returns {@code -32602 Invalid params} when it's
* missing. If the caller passes null/blank we substitute the JVM
* working directory a workspace-aware default lives in
* {@code AcpRuntimeSupport#resolveCwd}, but this fallback ensures
* the protocol never sees {@code undefined} regardless of caller.
*/
public JsonNode newSession(String cwd, long timeoutMillis)
throws IOException, InterruptedException {
ObjectNode params = mapper.createObjectNode();
if (cwd != null && !cwd.isBlank()) params.put("cwd", cwd);
String safeCwd = (cwd == null || cwd.isBlank())
? System.getProperty("user.dir", ".")
: cwd;
params.put("cwd", safeCwd);
params.set("mcpServers", mapper.createArrayNode());
return sendRequest("session/new", params, timeoutMillis);
}

View File

@ -0,0 +1,27 @@
package vip.mate.acp.event;
/**
* Lifecycle event for ACP endpoint rows.
*
* <p>Published by {@code AcpEndpointService} whenever a row is created,
* updated, toggled, or deleted. Listened to by
* {@code AcpSkillBridge} so it can re-sync the auto-bridged virtual
* skill cards and their wrapper tool registrations without a full
* application restart.
*
* <p>Mirrors the {@code SkillWorkspaceEvent} pattern a small immutable
* record carrying just enough context for listeners to fan out.
*/
public record AcpEndpointChangedEvent(Long endpointId, String name, Type type) {
public enum Type {
/** Row inserted. */
CREATED,
/** Row attributes updated (command/args/env/etc.). */
UPDATED,
/** {@code enabled} flag flipped. */
TOGGLED,
/** Row deleted. */
DELETED
}
}

View File

@ -31,6 +31,7 @@ public class AcpConnectionTester {
private final ObjectMapper objectMapper;
private final AcpEndpointService endpointService;
private final AcpRuntimeSupport runtimeSupport;
/**
* Spawn the configured agent, exchange initialize + session/new,
@ -46,11 +47,16 @@ public class AcpConnectionTester {
List<String> args = endpointService.parseArgs(endpoint);
Map<String, String> env = endpointService.parseEnv(endpoint);
result.put("args", args);
// Same as AcpDelegationService Zed's ACP server requires a
// non-blank cwd at session/new, so the connection test must
// also default it. The "Test" button used to fail at session/new
// with -32602 even when the CLI itself was healthy.
String resolvedCwd = runtimeSupport.resolveCwd(endpoint, null);
AcpStdioClient client;
try {
client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(),
args, env, /* cwd */ null);
args, env, resolvedCwd);
} catch (Exception e) {
return persistAndReturn(endpoint, result, "ERROR",
"Spawn failed: " + e.getMessage(), started);
@ -85,15 +91,18 @@ public class AcpConnectionTester {
// session/new validates that the agent really stands up a
// working session, not just initialize handshake.
try {
JsonNode sessionResp = autoClose.newSession(null, SESSION_NEW_TIMEOUT_MS);
JsonNode sessionResp = autoClose.newSession(resolvedCwd, SESSION_NEW_TIMEOUT_MS);
if (sessionResp != null && sessionResp.has("sessionId")) {
result.put("sessionId", sessionResp.path("sessionId").asText(""));
}
} catch (Exception e) {
// session/new may fail for legitimate reasons (e.g. agent
// requires auth flow first). Still report OK on initialize
// but flag in the message.
result.put("sessionWarning", e.getMessage());
// but flag in the message translated when it smells
// like an auth error so the test page UI shows actionable
// text instead of raw JSON-RPC.
String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage());
result.put("sessionWarning", authHint != null ? authHint : e.getMessage());
}
} catch (Exception e) {
return persistAndReturn(endpoint, result, "ERROR",

View File

@ -57,6 +57,7 @@ public class AcpDelegationService {
private final ObjectMapper objectMapper;
private final AcpEndpointService endpointService;
private final AcpRuntimeSupport runtimeSupport;
/**
* Run a one-shot ACP prompt against {@code endpointName}. Returns
@ -88,12 +89,16 @@ public class AcpDelegationService {
List<String> args = endpointService.parseArgs(endpoint);
Map<String, String> env = endpointService.parseEnv(endpoint);
boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted());
// Always resolve cwd to a real directory: Zed's ACP Zod schema
// marks cwd as a required string and rejects {@code undefined}
// with -32602. See {@link AcpRuntimeSupport#resolveCwd}.
String resolvedCwd = runtimeSupport.resolveCwd(endpoint, cwdHint);
StringBuilder accumulator = new StringBuilder();
AcpStdioClient client;
try {
client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(),
args, env, cwdHint);
args, env, resolvedCwd);
} catch (IOException e) {
throw new MateClawException("err.acp.spawn_failed",
"Failed to spawn ACP agent '" + endpointName + "': " + e.getMessage());
@ -109,7 +114,7 @@ public class AcpDelegationService {
"ACP protocol mismatch with endpoint '" + endpointName + "'");
}
JsonNode session = autoClose.newSession(cwdHint, SESSION_NEW_TIMEOUT_MS);
JsonNode session = autoClose.newSession(resolvedCwd, SESSION_NEW_TIMEOUT_MS);
String sessionId = session == null ? null : session.path("sessionId").asText("");
if (sessionId == null || sessionId.isBlank()) {
throw new MateClawException("err.acp.session_failed",
@ -123,6 +128,15 @@ public class AcpDelegationService {
} catch (IOException | InterruptedException e) {
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage());
// Upstream CLIs (claude-code / codex / qwen-code) wrap their
// own auth failures in opaque JSON-RPC noise. Recognise the
// 401/403/forbidden/unauthorized fingerprints and rewrite
// the message into something the user can act on, with the
// exact env var name they need to set.
String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage());
if (authHint != null) {
throw new MateClawException("err.acp.auth_failed", authHint);
}
throw new MateClawException("err.acp.delegation_failed",
"ACP delegation to '" + endpointName + "' failed: " + e.getMessage());
}

View File

@ -5,7 +5,9 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import vip.mate.acp.event.AcpEndpointChangedEvent;
import vip.mate.acp.model.AcpEndpointEntity;
import vip.mate.acp.repository.AcpEndpointMapper;
import vip.mate.exception.MateClawException;
@ -36,6 +38,7 @@ public class AcpEndpointService {
private final AcpEndpointMapper mapper;
private final ObjectMapper objectMapper;
private final ApplicationEventPublisher eventPublisher;
public List<AcpEndpointEntity> list() {
return mapper.selectList(new LambdaQueryWrapper<AcpEndpointEntity>()
@ -43,6 +46,17 @@ public class AcpEndpointService {
.orderByAsc(AcpEndpointEntity::getName));
}
/**
* Subset of {@link #list()} that returns only enabled rows.
* Used by {@code AcpSkillBridge} to enumerate virtual skill cards
* (one per enabled endpoint).
*/
public List<AcpEndpointEntity> listEnabled() {
return mapper.selectList(new LambdaQueryWrapper<AcpEndpointEntity>()
.eq(AcpEndpointEntity::getEnabled, true)
.orderByAsc(AcpEndpointEntity::getName));
}
public AcpEndpointEntity get(Long id) {
AcpEndpointEntity ep = mapper.selectById(id);
if (ep == null) throw new MateClawException("err.acp.endpoint_not_found",
@ -80,6 +94,7 @@ public class AcpEndpointService {
if (input.getWorkspaceId() == null) input.setWorkspaceId(1L);
mapper.insert(input);
log.info("Created ACP endpoint: {}", input.getName());
publish(input, AcpEndpointChangedEvent.Type.CREATED);
return input;
}
@ -104,6 +119,7 @@ public class AcpEndpointService {
existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes());
}
mapper.updateById(existing);
publish(existing, AcpEndpointChangedEvent.Type.UPDATED);
return existing;
}
@ -115,15 +131,29 @@ public class AcpEndpointService {
}
mapper.deleteById(id);
log.info("Deleted ACP endpoint: {}", existing.getName());
publish(existing, AcpEndpointChangedEvent.Type.DELETED);
}
public AcpEndpointEntity toggle(Long id, boolean enabled) {
AcpEndpointEntity existing = get(id);
existing.setEnabled(enabled);
mapper.updateById(existing);
publish(existing, AcpEndpointChangedEvent.Type.TOGGLED);
return existing;
}
private void publish(AcpEndpointEntity ep, AcpEndpointChangedEvent.Type type) {
try {
eventPublisher.publishEvent(new AcpEndpointChangedEvent(
ep.getId(), ep.getName(), type));
} catch (Exception e) {
// Listener failures must not break the CRUD path. The bridge
// will resync on the next ApplicationReady tick anyway.
log.warn("Failed to publish AcpEndpointChangedEvent for '{}': {}",
ep.getName(), e.getMessage());
}
}
/** Persist a connection-test outcome on the row. */
public void recordTestResult(Long id, String status, String error) {
AcpEndpointEntity existing = mapper.selectById(id);

View File

@ -0,0 +1,188 @@
package vip.mate.acp.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.acp.model.AcpEndpointEntity;
import vip.mate.workspace.core.model.WorkspaceEntity;
import vip.mate.workspace.core.service.WorkspaceService;
import java.io.File;
import java.util.Locale;
/**
* Shared runtime helpers for ACP code paths.
*
* <p>Two responsibilities, both motivated by upstream ACP servers
* (e.g. {@code @zed-industries/claude-agent-acp}) being strict about
* inputs and noisy in failure modes:
*
* <ul>
* <li>{@link #resolveCwd} pick a non-blank cwd for {@code session/new}.
* Zed's ACP Zod schema marks {@code cwd} as a required string and
* returns {@code -32602 Invalid params} when it's missing. We
* prefer the endpoint's bound workspace {@code base_path} (per-
* workspace context) and fall back to the JVM working directory
* only as a last resort. Never returns null/blank.</li>
*
* <li>{@link #translateAuthError} turn upstream JSON-RPC noise like
* {@code "API Error: 403 {...forbidden...}"} into an actionable
* hint that names the env var the user actually has to set in
* Settings ACP Endpoints (e.g. {@code ANTHROPIC_API_KEY} for
* claude-code, {@code OPENAI_API_KEY} for codex). Returns
* {@code null} when the error doesn't smell like an auth failure.</li>
* </ul>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AcpRuntimeSupport {
private final WorkspaceService workspaceService;
/**
* Resolution order (first non-blank wins):
* <ol>
* <li>Caller-provided hint (skill manifest's {@code acp.cwd},
* wrapper tool {@code cwd} arg, or explicit override).</li>
* <li>Workspace {@code base_path} when the endpoint is bound to a
* workspace and the workspace declares one.</li>
* <li>{@code System.getProperty("user.dir")} the JVM working
* directory at server launch. Reasonable for a single-user
* desktop install, but exposes the server's launch dir to the
* upstream agent, which is why it's last.</li>
* </ol>
*/
public String resolveCwd(AcpEndpointEntity endpoint, String callerHint) {
if (callerHint != null && !callerHint.isBlank()) {
return callerHint;
}
if (endpoint != null && endpoint.getWorkspaceId() != null) {
try {
WorkspaceEntity ws = workspaceService.getById(endpoint.getWorkspaceId());
if (ws != null && ws.getBasePath() != null && !ws.getBasePath().isBlank()) {
File f = new File(ws.getBasePath());
if (f.isDirectory()) return f.getAbsolutePath();
}
} catch (Exception e) {
log.debug("Workspace lookup failed for ACP cwd default (id={}): {}",
endpoint.getWorkspaceId(), e.getMessage());
}
}
return System.getProperty("user.dir", ".");
}
/**
* Detect upstream auth errors and emit an actionable hint string.
* Returns null when the message doesn't match caller should keep
* the original error as-is.
*
* <p>Heuristic: looks for HTTP-like 401/403 markers OR the words
* {@code forbidden / unauthorized / not allowed / api key / token}
* in the original message (case-insensitive). The patterns are loose
* on purpose different ACP CLIs phrase auth errors differently
* and the cost of a false positive (a slightly more verbose error
* banner) is much smaller than a false negative (user staring at a
* raw JSON-RPC blob).
*
* <p>Special case: a claude-code endpoint returning {@code 403
* "Request not allowed"} is almost always the keychain-hijack
* scenario rather than a wrong API key. The third-party
* {@code @zed-industries/claude-agent-acp} package wraps
* {@code @anthropic-ai/claude-agent-sdk}, whose auth dispatcher
* checks the macOS keychain ({@code Claude Code-credentials}) /
* {@code ~/.claude/credentials.json} BEFORE the
* {@code ANTHROPIC_API_KEY} env var. So a host that's done
* {@code claude login} silently shadows whatever API key the user
* configured in the endpoint env, and Anthropic's API rejects the
* subscription OAuth token (first-party-only) with the very
* specific {@code "Request not allowed"} error string. We detect
* that exact combination and surface the keychain-clearing remedy
* instead of the generic "set ANTHROPIC_API_KEY" hint, which
* doesn't apply here.
*/
public String translateAuthError(AcpEndpointEntity endpoint, String originalMessage) {
if (originalMessage == null) return null;
String lower = originalMessage.toLowerCase(Locale.ROOT);
boolean looksLikeAuth =
lower.contains("403")
|| lower.contains("401")
|| lower.contains("forbidden")
|| lower.contains("unauthorized")
|| lower.contains("not allowed")
|| lower.contains("invalid api key")
|| lower.contains("invalid token")
|| lower.contains("authenticate");
if (!looksLikeAuth) return null;
String name = endpoint != null && endpoint.getName() != null ? endpoint.getName() : "(unknown)";
String slug = lower(name);
String command = endpoint != null ? lower(endpoint.getCommand()) : "";
// Keychain-hijack detection must come before the generic env-
// missing branch because both would superficially match.
boolean keychainHijack = lower.contains("request not allowed")
&& (slug.contains("claude") || command.contains("claude-agent-acp"));
if (keychainHijack) {
StringBuilder sb = new StringBuilder();
sb.append("ACP endpoint '").append(name).append("' upstream auth failed with ");
sb.append("'Request not allowed' — almost always means the host CLI's OAuth ");
sb.append("credentials are hijacking the SDK auth path. ");
sb.append("The Claude Agent SDK reads ~/.claude/ / macOS keychain BEFORE the ");
sb.append("ANTHROPIC_API_KEY env var, so the API key you configured here is ");
sb.append("never sent — Anthropic rejects the subscription OAuth token because ");
sb.append("third-party processes aren't allowed to use it. ");
sb.append("To fix: ");
sb.append("(macOS) run `claude logout`, or `security delete-generic-password ");
sb.append("-s \"Claude Code-credentials\"`; ");
sb.append("(Linux / Windows) delete ~/.claude/credentials.json. ");
sb.append("Then click Test connection again. Original: ").append(originalMessage);
return sb.toString();
}
String envVar = expectedAuthEnvVar(endpoint);
StringBuilder sb = new StringBuilder();
sb.append("ACP endpoint '").append(name).append("' upstream auth failed. ");
sb.append("Most likely the endpoint env has no API key. ");
sb.append("Edit Settings ▸ ACP Endpoints → ").append(name).append(" → env, ");
if (envVar != null) {
sb.append("add `{\"").append(envVar).append("\":\"...\"}`");
} else {
sb.append("add the appropriate API key for this CLI");
}
sb.append(". Note: claude-code / codex / qwen-code refuse OAuth tokens from their host CLIs, ");
sb.append("so a real API key is required. Original: ").append(originalMessage);
return sb.toString();
}
/**
* Best-effort guess of the API key env var the upstream CLI expects.
* Returns null when we don't recognise the endpoint caller emits a
* generic "appropriate API key" hint instead.
*/
public String expectedAuthEnvVar(AcpEndpointEntity endpoint) {
if (endpoint == null) return null;
String name = lower(endpoint.getName());
String command = lower(endpoint.getCommand());
// Match by name first (slug is the stable identifier); fall back
// to command keywords for user-defined rows.
if (name.contains("claude") || command.contains("claude-agent-acp") || command.contains("anthropic")) {
return "ANTHROPIC_API_KEY";
}
if (name.contains("codex") || command.contains("codex") || command.contains("openai")) {
return "OPENAI_API_KEY";
}
if (name.contains("qwen") || command.contains("qwen") || command.contains("dashscope")) {
return "DASHSCOPE_API_KEY";
}
if (name.contains("gemini") || command.contains("gemini") || command.contains("google-genai")) {
return "GOOGLE_API_KEY";
}
// opencode multi-model no single canonical env var.
return null;
}
private static String lower(String s) {
return s == null ? "" : s.toLowerCase(Locale.ROOT);
}
}

View File

@ -228,25 +228,74 @@ public class AgentBindingService {
* {@link #getEffectiveToolNames} allowlist completely.
*/
private static final Set<String> SYSTEM_LEVEL_TOOLS = Set.of(
// Memory write/read primitives every agent needs these
// regardless of skill bindings, otherwise the self-evolution
// path collapses (§11.3 / §11.4).
// Structured memory primitives used by every agent regardless
// of skill bindings, otherwise the self-evolution path collapses
// (§11.3 / §11.4).
"record_lesson",
"remember",
"remember_structured",
"recall_structured",
"forget_structured",
// Workspace memory file CRUD (PROFILE.md / MEMORY.md / SOUL.md)
"read_workspace_file",
"write_workspace_file",
"list_workspace_files",
// Workspace memory file CRUD (PROFILE.md / MEMORY.md / SOUL.md /
// memory/YYYY-MM-DD.md). Prior versions whitelisted
// "read_workspace_file" / "write_workspace_file" /
// "list_workspace_files" those names match no @Tool bean; the
// actual function names carry the "_memory" segment, so the
// earlier carve-out was silently dead.
"list_workspace_memory_files",
"read_workspace_memory_file",
"write_workspace_memory_file",
"edit_workspace_memory_file",
// Skill discovery / dispatch skills are docs, not callables;
// these helpers let the LLM read SKILL.md / run scripts.
"readSkillFile",
"runSkillScript",
// Date/time + delegate fundamental cross-skill utilities
"datetime",
"delegate_agent"
"listSkillFiles",
"listAvailableSkills",
// Date / time prior whitelist had a fictional "datetime"; the
// real DateTimeTool exposes three separate methods.
"getCurrentDate",
"getCurrentDateTime",
"getCurrentTime",
// Multi-agent delegation prior whitelist had "delegate_agent",
// but DelegateAgentTool's @Tool methods are delegateToAgent /
// delegateParallel / listAvailableAgents. Same dead-name bug.
"delegateToAgent",
"delegateParallel",
"listAvailableAgents",
// Document / media generation agent-wide capabilities, never
// declared inside any skill manifest. Pre-Phase-2b these were
// universally visible; the new gate silently strips them whenever
// any skill is bound, breaking "generate a Word doc / image /
// song / video" intents on agents that happen to have a skill
// on. Regression observed 2026-05-01: a Code Reviewer agent with
// skills bound dropped renderDocx and fell back to dumping the
// markdown body for the user to copy.
"renderDocx",
"renderDocxFromFile",
"renderDocxFromFiles",
"image_generate",
"music_generate",
"video_generate",
// Universal capabilities the global system prompts (SOUL.md /
// AGENTS.md / "Web Search Capability" / "File Reading Guidelines")
// explicitly tell the LLM exist. Pre-Phase-2b they were globally
// available; the new gate silently hid them on any agent with
// skills bound, so the prompt promises a tool the registry then
// refuses ("Tool not found: search"). Observed 2026-05-01 on the
// Code Reviewer agent the model called search got
// not-found gave up before ever reaching renderDocx.
"search",
"browser_use",
"read_file",
"write_file",
"edit_file",
"execute_shell_command",
"detect_file_type",
"extract_document_text",
"extract_pdf_text",
"extract_docx_text",
"readMateClawDoc"
);
private ResolvedSkill findResolvedSkillById(Long skillId) {

View File

@ -0,0 +1,495 @@
package vip.mate.skill.acp;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import vip.mate.acp.event.AcpEndpointChangedEvent;
import vip.mate.acp.model.AcpEndpointEntity;
import vip.mate.acp.service.AcpDelegationService;
import vip.mate.acp.service.AcpEndpointService;
import vip.mate.skill.knowledge.SkillScopedToolCallback;
import vip.mate.skill.manifest.SkillManifest;
import vip.mate.skill.model.SkillEntity;
import vip.mate.skill.runtime.model.ResolvedSkill;
import vip.mate.tool.ToolRegistry;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* RFC-090 §3.2 / §14.4 (parallel) ACP endpoint virtual skill bridge.
*
* <p>Mirrors {@link vip.mate.skill.mcp.McpSkillBridge}: every enabled
* row in {@code mate_acp_endpoint} is automatically surfaced as a
* virtual {@link SkillEntity} + {@link ResolvedSkill}, and a wrapper
* tool ({@code acp_<slug>_prompt}) is registered with
* {@link ToolRegistry} so any agent can delegate to that endpoint
* without manual binding.
*
* <p>This solves the "ACP configured as skill cannot be called"
* usability bug (matches the inspiration from QwenPaw's
* {@code delegate_external_agent} pattern, but keeps MateClaw's
* skill-card affordance for endpoint discovery): the user manages
* endpoints in Settings ACP Endpoints, and a card automatically
* appears on the Skills page no per-endpoint SKILL.md authoring
* required.
*
* <p>Lifecycle:
* <ul>
* <li>{@link ApplicationReadyEvent} initial wrapper registration
* for all enabled endpoints.</li>
* <li>{@link AcpEndpointChangedEvent} re-sync registrations on
* create / update / toggle / delete.</li>
* <li>{@code list/list-status} entry points rebuild the virtual
* SkillEntity / ResolvedSkill snapshots on demand so the Skills
* page always shows current state.</li>
* </ul>
*
* <p>ID namespace: virtual skill ids use a high sentinel
* {@link #VIRTUAL_ID_BASE} different from the MCP bridge's, so the two
* id spaces never collide and a callsite can dispatch on which bridge
* owns an id without coordination.
*/
@Slf4j
@Service
public class AcpSkillBridge {
/**
* High sentinel for ACP virtual id space. Distinct from
* {@code McpSkillBridge.VIRTUAL_ID_BASE} (9e18) so the two virtual
* spaces are partitionable by simple range checks.
*/
public static final long VIRTUAL_ID_BASE = 8_000_000_000_000_000_000L;
/** Upper bound, exclusive — anything in [BASE, BASE + 1e17) is ours. */
public static final long VIRTUAL_ID_BOUND = VIRTUAL_ID_BASE + 100_000_000_000_000_000L;
private final AcpEndpointService endpointService;
private final AcpDelegationService delegationService;
private final ObjectMapper objectMapper;
private final ToolRegistry toolRegistry;
/**
* endpointId set of registered wrapper tool names. Keeps the tool
* registry synced with the live endpoint set: when an endpoint is
* toggled off/deleted, we know exactly which tools to remove.
*/
private final ConcurrentHashMap<Long, Set<String>> registeredWrappers = new ConcurrentHashMap<>();
@Autowired
public AcpSkillBridge(AcpEndpointService endpointService,
AcpDelegationService delegationService,
ObjectMapper objectMapper,
@Lazy ToolRegistry toolRegistry) {
this.endpointService = endpointService;
this.delegationService = delegationService;
this.objectMapper = objectMapper;
this.toolRegistry = toolRegistry;
}
public static boolean isVirtualAcpSkillId(Long id) {
return id != null && id >= VIRTUAL_ID_BASE && id < VIRTUAL_ID_BOUND;
}
public static Long extractEndpointId(Long virtualId) {
if (!isVirtualAcpSkillId(virtualId)) return null;
return virtualId - VIRTUAL_ID_BASE;
}
public static long virtualIdFor(AcpEndpointEntity endpoint) {
return VIRTUAL_ID_BASE + endpoint.getId();
}
@PostConstruct
public void init() {
log.info("AcpSkillBridge initialized (virtual ID range: {}+endpointId)", VIRTUAL_ID_BASE);
}
/**
* Initial registration: enumerate enabled endpoints once the
* application is fully bootstrapped. {@link ApplicationReadyEvent}
* is preferred over {@code @PostConstruct} because the database
* bootstrap (Flyway + seed data) finishes only after the context
* comes up, and the endpoint table may be empty before that.
*/
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
try {
int registered = 0;
for (AcpEndpointEntity ep : endpointService.listEnabled()) {
registerWrappers(ep);
registered++;
}
if (registered > 0) {
log.info("AcpSkillBridge: registered wrappers for {} enabled endpoint(s)", registered);
}
} catch (Exception e) {
log.warn("AcpSkillBridge initial registration failed: {}", e.getMessage());
}
}
/**
* Resync wrapper registrations on every endpoint lifecycle event.
* Disabled / deleted endpoints have their wrappers removed; enabled
* endpoints are re-registered (idempotent we deregister-then-add
* to avoid double registration when the user re-enables a row).
*/
@EventListener(AcpEndpointChangedEvent.class)
public void onEndpointChanged(AcpEndpointChangedEvent event) {
Long endpointId = event.endpointId();
if (endpointId == null) return;
try {
switch (event.type()) {
case DELETED -> deregisterWrappers(endpointId);
case CREATED, UPDATED, TOGGLED -> {
AcpEndpointEntity ep = safeGet(endpointId);
if (ep == null || !Boolean.TRUE.equals(ep.getEnabled())) {
deregisterWrappers(endpointId);
} else {
// Idempotent: drop stale set, then build fresh
// covers args/env edits where the wrapper itself
// doesn't change but we still want a clean slate.
deregisterWrappers(endpointId);
registerWrappers(ep);
}
}
}
} catch (Exception e) {
log.warn("AcpSkillBridge resync for endpoint '{}' ({}) failed: {}",
event.name(), event.type(), e.getMessage());
}
}
/**
* Snapshot every enabled endpoint as a virtual {@link SkillEntity}.
* Used by the Skills list endpoint; rows are non-persistent and
* regenerated on each call.
*/
public List<SkillEntity> listAcpDerivedSkillEntities() {
return safeListEnabled().stream().map(this::endpointToEntity).toList();
}
/**
* Snapshot every enabled endpoint as a virtual {@link ResolvedSkill}
* with synthesized manifest. Status reflects the last connection
* test on the row: OK READY, ERROR / unknown SETUP_NEEDED.
*/
public List<ResolvedSkill> listAcpDerivedResolvedSkills() {
return safeListEnabled().stream().map(this::endpointToResolved).toList();
}
/**
* Lookup a single virtual ResolvedSkill by virtual id. Used by the
* Skill detail drawer's reverse lookup path.
*/
public ResolvedSkill findResolvedById(Long virtualId) {
Long endpointId = extractEndpointId(virtualId);
if (endpointId == null) return null;
AcpEndpointEntity ep = safeGet(endpointId);
return ep != null && Boolean.TRUE.equals(ep.getEnabled()) ? endpointToResolved(ep) : null;
}
public SkillEntity findEntityById(Long virtualId) {
Long endpointId = extractEndpointId(virtualId);
if (endpointId == null) return null;
AcpEndpointEntity ep = safeGet(endpointId);
return ep != null && Boolean.TRUE.equals(ep.getEnabled()) ? endpointToEntity(ep) : null;
}
// ==================== Tool registration ====================
private void registerWrappers(AcpEndpointEntity ep) {
if (ep == null || !Boolean.TRUE.equals(ep.getEnabled())) return;
String slug = slugify(ep.getName());
if (slug.isEmpty()) {
log.warn("ACP endpoint id={} has blank name; cannot register wrapper", ep.getId());
return;
}
String toolName = "acp_" + slug + "_prompt";
String desc = String.format(
"Delegate a prompt to the '%s' ACP coding agent. " +
"Send a single-string instruction; receive the agent's final reply.%s",
ep.getName(),
ep.getDescription() == null || ep.getDescription().isBlank()
? ""
: "" + ep.getDescription());
// cwd stays optional in the schema so the LLM doesn't have to
// invent a path. The server defaults to the endpoint's bound
// workspace base_path when omitted (see AcpRuntimeSupport).
String schema = "{"
+ "\"type\":\"object\","
+ "\"properties\":{"
+ "\"prompt\":{\"type\":\"string\",\"description\":\"the instruction or question to send\"},"
+ "\"cwd\":{\"type\":\"string\",\"description\":\"optional working directory; "
+ "defaults to the endpoint's workspace base path when omitted\"}"
+ "},"
+ "\"required\":[\"prompt\"]"
+ "}";
final String endpointName = ep.getName();
final Long endpointId = ep.getId();
SkillScopedToolCallback callback = new SkillScopedToolCallback(toolName, desc, schema, input -> {
try {
JsonNode args = input == null || input.isBlank()
? objectMapper.createObjectNode()
: objectMapper.readTree(input);
String userPrompt = args.path("prompt").asText("").trim();
if (userPrompt.isEmpty()) return errorJson("prompt is required");
String cwdHint = args.path("cwd").asText("");
String reply = delegationService.prompt(endpointName, userPrompt,
cwdHint == null || cwdHint.isBlank() ? null : cwdHint);
JSONObject resp = new JSONObject()
.set("endpoint", endpointName)
.set("reply", reply);
return JSONUtil.toJsonStr(resp);
} catch (Exception e) {
log.warn("ACP wrapper '{}' failed: {}", toolName, e.getMessage());
return errorJson(e.getMessage() == null ? "delegation failed" : e.getMessage());
}
});
// Availability supplier: re-check each agent tool-set build so
// a toggle-off without a deregister call still hides the tool.
toolRegistry.registerPluginTool(callback, () -> {
AcpEndpointEntity live = safeGet(endpointId);
return live != null && Boolean.TRUE.equals(live.getEnabled());
});
registeredWrappers.computeIfAbsent(endpointId, k -> ConcurrentHashMap.newKeySet()).add(toolName);
log.info("AcpSkillBridge: registered wrapper '{}' for endpoint '{}'", toolName, endpointName);
}
private void deregisterWrappers(Long endpointId) {
Set<String> names = registeredWrappers.remove(endpointId);
if (names == null || names.isEmpty()) return;
for (String name : names) {
try {
toolRegistry.unregisterPluginTool(name);
} catch (Exception e) {
log.debug("Unregister ACP wrapper '{}' failed: {}", name, e.getMessage());
}
}
log.info("AcpSkillBridge: deregistered {} wrapper(s) for endpoint id={}", names.size(), endpointId);
}
// ==================== Synthesis ====================
private SkillEntity endpointToEntity(AcpEndpointEntity ep) {
SkillEntity s = new SkillEntity();
s.setId(virtualIdFor(ep));
s.setName(slugify(ep.getName()));
s.setNameEn(displayName(ep));
s.setNameZh(ep.getDescription() != null && !ep.getDescription().isBlank()
? displayName(ep) : null);
s.setDescription(buildDescription(ep));
s.setSkillType("acp");
s.setIcon(iconFor(ep));
s.setVersion("1.0.0");
s.setAuthor("acp-bridge");
s.setEnabled(Boolean.TRUE.equals(ep.getEnabled()));
s.setBuiltin(Boolean.TRUE.equals(ep.getBuiltin()));
s.setTags("acp");
s.setSecurityScanStatus("PASSED"); // ACP endpoints are user-configured external CLIs, not skill scripts
s.setConfigJson(buildConfigJson(ep));
s.setManifestJson(serializeManifest(buildManifest(ep)));
return s;
}
private ResolvedSkill endpointToResolved(AcpEndpointEntity ep) {
SkillManifest manifest = buildManifest(ep);
boolean ok = "OK".equalsIgnoreCase(nullSafe(ep.getLastStatus()));
boolean errored = "ERROR".equalsIgnoreCase(nullSafe(ep.getLastStatus()))
|| (ep.getLastError() != null && !ep.getLastError().isBlank());
// "Unknown" (untested) endpoints are treated as READY so the user
// can call them without an explicit Test click unlike MCP, an
// ACP CLI is spawned per call so an untested-but-installed CLI
// works fine on first invocation. ERROR keeps SETUP_NEEDED.
boolean ready = !errored;
Map<String, String> featureStatuses = new LinkedHashMap<>();
featureStatuses.put("default", ready ? "READY" : "SETUP_NEEDED");
Set<String> active = new LinkedHashSet<>();
if (ready) active.add("default");
List<String> missing = new ArrayList<>();
if (!ready) {
missing.add("acp:" + ep.getName() + " (status: " + nullSafe(ep.getLastStatus()) + ")");
}
String summary = ok
? "ACP endpoint '" + ep.getName() + "' tested OK"
: (errored
? "ACP endpoint '" + ep.getName() + "' last test failed: " + nullSafe(ep.getLastError())
: "ACP endpoint '" + ep.getName() + "' not yet tested — calls will spawn the CLI on demand");
return ResolvedSkill.builder()
.id(virtualIdFor(ep))
.name(slugify(ep.getName()))
.description(buildDescription(ep))
.content("") // no SKILL.md
.source("acp")
.skillDir(null)
.configuredSkillDir(null)
.runtimeAvailable(ready)
.resolutionError(ready ? null : nullSafe(ep.getLastError()))
.references(Map.of())
.scripts(Map.of())
.enabled(Boolean.TRUE.equals(ep.getEnabled()))
.icon(iconFor(ep))
.builtin(Boolean.TRUE.equals(ep.getBuiltin()))
.securityBlocked(false)
.securitySummary("ACP-derived skill (external CLI; not subject to SKILL.md scanning)")
.dependencyReady(ready)
.missingDependencies(missing)
.dependencySummary(summary)
.manifest(manifest)
.featureStatuses(featureStatuses)
.activeFeatures(active)
.build();
}
/**
* Synthesize a §10.2 minimal manifest from the live endpoint row.
* The wrapper tool name {@code acp_<slug>_prompt} is the single
* advertised tool, surfaced via {@code allowedTools} + the default
* feature so {@code ResolvedSkill.getEffectiveAllowedTools()} picks
* it up the same way as a hand-authored skill manifest.
*/
private SkillManifest buildManifest(AcpEndpointEntity ep) {
String slug = slugify(ep.getName());
String toolName = "acp_" + slug + "_prompt";
List<String> tools = List.of(toolName);
SkillManifest.FeatureDef defaultFeature = SkillManifest.FeatureDef.builder()
.id("default")
.label(displayName(ep))
.requires(List.of("acp:" + ep.getName()))
.platforms(List.of())
.tools(tools)
.build();
SkillManifest.RequirementDef acpRequirement = SkillManifest.RequirementDef.builder()
.key("acp:" + ep.getName())
.type("acp")
.check(ep.getName())
.description("ACP endpoint '" + ep.getName() + "' must be enabled and reachable. "
+ "Configure in Settings ▸ ACP Endpoints.")
.build();
SkillManifest.AcpBinding binding = SkillManifest.AcpBinding.builder()
.endpoint(ep.getName())
.resolvedEndpointId(ep.getId())
.build();
return SkillManifest.builder()
.id(slug)
.name(slug)
.description(buildDescription(ep))
.icon(iconFor(ep))
.version("1.0.0")
.author("acp-bridge")
.type("acp")
.category("system")
.allowedTools(tools)
.requires(List.of(acpRequirement))
.features(List.of(defaultFeature))
.acp(binding)
.selfEvolution(SkillManifest.SelfEvolution.builder()
// Bridged ACP cards don't author LESSONS.md the
// upstream agent owns its own self-evolution.
.lessonsEnabled(false)
.lessonsMaxEntries(0)
.memoryWritesAllowed(true)
.build())
.extras(Map.of("acpEndpointId", ep.getId()))
.build();
}
// ==================== Helpers ====================
private List<AcpEndpointEntity> safeListEnabled() {
try {
return endpointService.listEnabled();
} catch (Exception e) {
log.warn("AcpSkillBridge could not list enabled endpoints: {}", e.getMessage());
return Collections.emptyList();
}
}
private AcpEndpointEntity safeGet(Long id) {
try {
return endpointService.get(id);
} catch (Exception e) {
return null;
}
}
private String slugify(String raw) {
if (raw == null) return "";
return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-");
}
private String displayName(AcpEndpointEntity ep) {
if (ep.getDisplayName() != null && !ep.getDisplayName().isBlank()) return ep.getDisplayName();
return ep.getName() != null ? ep.getName() : "acp-" + ep.getId();
}
private String buildDescription(AcpEndpointEntity ep) {
if (ep.getDescription() != null && !ep.getDescription().isBlank()) return ep.getDescription();
return "ACP coding agent '" + ep.getName() + "' — delegate prompts via the "
+ "auto-registered tool. Configure in Settings ▸ ACP Endpoints.";
}
private String iconFor(AcpEndpointEntity ep) {
// Light heuristic match the most popular ACP runners by name.
String n = nullSafe(ep.getName()).toLowerCase(Locale.ROOT);
if (n.contains("claude")) return "🟠";
if (n.contains("codex") || n.contains("openai")) return "";
if (n.contains("qwen")) return "🔵";
if (n.contains("gemini") || n.contains("google")) return "🟡";
if (n.contains("opencode")) return "🟢";
return "🤝";
}
private String buildConfigJson(AcpEndpointEntity ep) {
try {
return objectMapper.writeValueAsString(Map.of(
"acpEndpointId", ep.getId(),
"command", nullSafe(ep.getCommand()),
"trusted", Boolean.TRUE.equals(ep.getTrusted()),
"source", Map.of("type", "acp")));
} catch (Exception e) {
return "{}";
}
}
private String serializeManifest(SkillManifest manifest) {
try {
return objectMapper.writeValueAsString(manifest);
} catch (Exception e) {
return null;
}
}
private static String nullSafe(String s) {
return s == null ? "" : s;
}
private static String errorJson(String msg) {
return JSONUtil.createObj().set("error", msg).toString();
}
}

View File

@ -51,6 +51,7 @@ public class SkillController {
private final AgentService agentService;
private final AgentBindingService agentBindingService;
private final vip.mate.skill.mcp.McpSkillBridge mcpSkillBridge;
private final vip.mate.skill.acp.AcpSkillBridge acpSkillBridge;
@Operation(summary = "获取技能分页列表RFC-042 §2.1")
@GetMapping
@ -93,6 +94,29 @@ public class SkillController {
// Bridge failure must not break the Skills page.
}
}
// RFC-090 §3.2 (parallel) same auto-bridge for ACP endpoints.
if (page == 1 && (skillType == null || skillType.isBlank() || "acp".equalsIgnoreCase(skillType))) {
try {
List<SkillEntity> acpSkills = acpSkillBridge.listAcpDerivedSkillEntities();
if (!acpSkills.isEmpty()) {
String kw = keyword == null ? "" : keyword.trim().toLowerCase();
List<SkillEntity> filtered = acpSkills.stream()
.filter(s -> kw.isEmpty()
|| (s.getName() != null && s.getName().toLowerCase().contains(kw))
|| (s.getDescription() != null && s.getDescription().toLowerCase().contains(kw)))
.filter(s -> enabled == null || enabled.equals(s.getEnabled()))
.toList();
if (!filtered.isEmpty()) {
java.util.List<SkillEntity> merged = new java.util.ArrayList<>(filtered);
merged.addAll(dbPage.getRecords());
dbPage.setRecords(merged);
dbPage.setTotal(dbPage.getTotal() + filtered.size());
}
}
} catch (Exception e) {
// Bridge failure must not break the Skills page.
}
}
return R.ok(dbPage);
}
@ -112,6 +136,15 @@ public class SkillController {
} catch (Exception ignored) {
// Bridge failure must not break the badge fetch.
}
try {
long virtualAcp = acpSkillBridge.listAcpDerivedSkillEntities().size();
if (virtualAcp > 0) {
result.merge("acp", virtualAcp, Long::sum);
result.merge("all", virtualAcp, Long::sum);
}
} catch (Exception ignored) {
// Same defensive stance as the MCP bridge above.
}
return R.ok(result);
}
@ -152,6 +185,11 @@ public class SkillController {
.map(R::ok)
.orElse(R.fail("MCP-derived skill not found: " + id));
}
// RFC-090 §3.2 (parallel) same path for ACP-derived virtual skills.
if (vip.mate.skill.acp.AcpSkillBridge.isVirtualAcpSkillId(id)) {
SkillEntity ent = acpSkillBridge.findEntityById(id);
return ent != null ? R.ok(ent) : R.fail("ACP-derived skill not found: " + id);
}
return R.ok(skillService.getSkill(id));
}

View File

@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import vip.mate.skill.acp.AcpSkillBridge;
import vip.mate.skill.lessons.SkillLessonsService;
import vip.mate.skill.manifest.SkillManifest;
import vip.mate.skill.mcp.McpSkillBridge;
@ -47,16 +48,23 @@ public class SkillRuntimeService {
* which boots later in the lifecycle.
*/
private final McpSkillBridge mcpSkillBridge;
/**
* RFC-090 §3.2 (parallel) ACP-endpoint virtual-skill bridge.
* Same {@code @Lazy} treatment as the MCP bridge.
*/
private final AcpSkillBridge acpSkillBridge;
@Autowired
public SkillRuntimeService(SkillService skillService,
SkillPackageResolver packageResolver,
@Lazy SkillLessonsService lessonsService,
@Lazy McpSkillBridge mcpSkillBridge) {
@Lazy McpSkillBridge mcpSkillBridge,
@Lazy AcpSkillBridge acpSkillBridge) {
this.skillService = skillService;
this.packageResolver = packageResolver;
this.lessonsService = lessonsService;
this.mcpSkillBridge = mcpSkillBridge;
this.acpSkillBridge = acpSkillBridge;
}
// 缓存已解析的 active skills5分钟过期
@ -144,6 +152,14 @@ public class SkillRuntimeService {
} catch (Exception e) {
log.warn("MCP skill bridge active merge failed: {}", e.getMessage());
}
try {
// RFC-090 §3.2 (parallel) ACP-derived virtual skills.
for (ResolvedSkill virt : acpSkillBridge.listAcpDerivedResolvedSkills()) {
if (passesActiveGate(virt)) resolved.add(virt);
}
} catch (Exception e) {
log.warn("ACP skill bridge active merge failed: {}", e.getMessage());
}
activeSkillsCache.put(CACHE_KEY, resolved);
log.info("Refreshed active skills: {} enabled", resolved.size());
@ -169,6 +185,11 @@ public class SkillRuntimeService {
} catch (Exception e) {
log.warn("MCP skill bridge merge failed: {}", e.getMessage());
}
try {
resolved.addAll(acpSkillBridge.listAcpDerivedResolvedSkills());
} catch (Exception e) {
log.warn("ACP skill bridge merge failed: {}", e.getMessage());
}
return resolved;
}

View File

@ -258,9 +258,18 @@ const customRenderer = {
// Dangerous scheme — render the inner content as plain content (no anchor).
return innerHtml
}
// Defense against LLMs hallucinating a host on tool-returned download URLs.
// /api/v1/files/generated/<id> is always same-origin; multiple models have
// been observed prepending bogus schemes/hosts (https://localhost:8080,
// https://ai-tools-system.com, …) when echoing the URL back, breaking the
// download. Strip any prepended scheme://host so the link works regardless
// of what the model wrote.
const hostStripped = /^https?:\/\/[^/]+(\/api\/v1\/files\/generated\/.+)$/i.exec(href)
const safeHref = hostStripped ? hostStripped[1] : href
let extra = ''
try {
const url = new URL(href, typeof window !== 'undefined' ? window.location.href : 'http://localhost/')
const url = new URL(safeHref, typeof window !== 'undefined' ? window.location.href : 'http://localhost/')
if (typeof window !== 'undefined' && url.origin !== window.location.origin) {
extra = ' target="_blank" rel="noopener noreferrer"'
}
@ -268,7 +277,7 @@ const customRenderer = {
// Malformed URL — treat as same-origin (relative link path).
}
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''
return `<a href="${escapeHtml(href)}"${titleAttr}${extra}>${innerHtml}</a>`
return `<a href="${escapeHtml(safeHref)}"${titleAttr}${extra}>${innerHtml}</a>`
},
}

View File

@ -1149,10 +1149,28 @@ export default {
description: 'Description',
command: 'Command',
args: 'Args (JSON array)',
env: 'Env (JSON object)',
env: 'Environment variables',
toolParseMode: 'Tool parse mode',
enabled: 'Enabled',
},
env: {
formMode: 'Edit as JSON',
jsonMode: 'Edit as form',
suggested: 'Suggested',
addRow: 'Add variable',
keyPlaceholder: 'KEY_NAME',
valuePlaceholder: 'value (e.g. sk-…)',
show: 'Show',
hide: 'Hide',
remove: 'Remove',
hints: {
anthropic: 'Anthropic API key (sk-ant-…). Get one at https://console.anthropic.com/settings/keys',
openai: 'OpenAI API key (sk-…). Get one at https://platform.openai.com/api-keys',
dashscope: 'Aliyun DashScope key — same value as MateClaw .env DASHSCOPE_API_KEY',
google: 'Google AI Studio API key. Get one at https://aistudio.google.com/apikey',
claudeOauth: 'Heads up: claude-code OAuth login (the one stored in ~/.claude/) does NOT work here, and worse — the Claude Agent SDK reads it BEFORE this env var, silently shadowing your API key. If you have ever run `claude login` on this host, clear the keychain first: (macOS) `claude logout` or `security delete-generic-password -s "Claude Code-credentials"`; (Linux/Windows) delete ~/.claude/credentials.json. Then put a real API key (sk-ant-…) above. Or switch to qwen-code / opencode to reuse a CLI login.',
},
},
modal: {
newTitle: 'Add ACP Endpoint',
editTitle: 'Edit ACP Endpoint',
@ -2000,6 +2018,7 @@ export default {
all: 'All',
builtin: 'Built-in',
mcp: 'MCP',
acp: 'ACP',
dynamic: 'Dynamic',
},
search: {
@ -2073,6 +2092,7 @@ export default {
types: {
builtin: 'Built-in',
mcp: 'MCP',
acp: 'ACP',
dynamic: 'Dynamic',
},
source: {

View File

@ -277,7 +277,7 @@ export default {
mcpConnections: 'MCP 连接',
toolsCatalog: '工具目录',
activity: '活动记录',
acpEndpoints: 'ACP Endpoints',
acpEndpoints: 'ACP 端点',
settingsGroup: '设置',
agents: '智能体',
security: '安全',
@ -1121,23 +1121,23 @@ export default {
},
acp: {
kicker: '外部 Agent',
title: 'ACP Endpoints',
title: 'ACP 端点',
desc: '通过 stdio 委派给外部编码 Agentcodex / claude-code / opencode / qwen-code。安装对应 CLI 后开启该入口。',
addEndpoint: '新增 Endpoint',
addEndpoint: '新增端点',
builtin: '内置',
test: '测试',
testing: '测试中…',
statusUnknown: '未测试',
empty: '暂无 ACP endpoint。',
loadFailed: '加载 ACP endpoint 列表失败',
saveFailed: '保存 endpoint 失败',
empty: '暂无 ACP 端点。',
loadFailed: '加载 ACP 端点列表失败',
saveFailed: '保存端点失败',
deleteTitle: '确认删除',
deleteConfirm: '确认删除 ACP endpoint "{name}"?内置 endpoint 不可删除。',
deleteFailed: '删除 endpoint 失败',
toggleFailed: '切换 endpoint 状态失败',
deleteConfirm: '确认删除 ACP 端点 "{name}"?内置端点不可删除。',
deleteFailed: '删除端点失败',
toggleFailed: '切换端点状态失败',
invalidJson: 'JSON 格式错误',
columns: {
name: 'Endpoint',
name: '端点',
command: '命令',
status: '最近一次测试',
enabled: '启用',
@ -1149,13 +1149,31 @@ export default {
description: '描述',
command: '命令',
args: '参数 (JSON 数组)',
env: '环境变量 (JSON 对象)',
env: '环境变量',
toolParseMode: 'Tool 解析模式',
enabled: '启用',
},
env: {
formMode: '编辑 JSON',
jsonMode: '回到表单',
suggested: '推荐',
addRow: '添加变量',
keyPlaceholder: 'KEY_NAME',
valuePlaceholder: '值(如 sk-…)',
show: '显示',
hide: '隐藏',
remove: '移除',
hints: {
anthropic: 'Anthropic API keysk-ant-…)。申请: https://console.anthropic.com/settings/keys',
openai: 'OpenAI API keysk-…)。申请: https://platform.openai.com/api-keys',
dashscope: '阿里云 DashScope key — 跟 MateClaw .env 里的 DASHSCOPE_API_KEY 同一个值',
google: 'Google AI Studio API key。申请: https://aistudio.google.com/apikey',
claudeOauth: '注意: claude-code 的 OAuth 登录(~/.claude/ 里那个)在这里不能用——更糟的是 Claude Agent SDK 会"优先读 OAuth、再读 API key",导致你下面填的 key 被静默忽略。如果你这台机器上跑过 `claude login`,先清钥匙串: (macOS) `claude logout` 或 `security delete-generic-password -s "Claude Code-credentials"`(Linux/Windows) 删 ~/.claude/credentials.json。然后再回来填真正的 API keysk-ant-…)。或者改用 qwen-code / opencode 复用各自 CLI 的本地登录态。',
},
},
modal: {
newTitle: '新增 ACP Endpoint',
editTitle: '编辑 ACP Endpoint',
newTitle: '新增 ACP 端点',
editTitle: '编辑 ACP 端点',
},
},
mcp: {
@ -2002,6 +2020,7 @@ export default {
all: '全部',
builtin: '内置',
mcp: 'MCP',
acp: 'ACP',
dynamic: '动态',
},
search: {
@ -2075,6 +2094,7 @@ export default {
types: {
builtin: '内置',
mcp: 'MCP',
acp: 'ACP',
dynamic: '动态',
},
source: {

View File

@ -131,8 +131,81 @@
<input v-model="form.argsJson" class="form-input mono" placeholder='["-y","@zed-industries/codex-acp"]' />
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('acp.fields.env') }}</label>
<textarea v-model="form.envJson" class="form-input form-textarea mono" rows="2" placeholder='{"OPENAI_API_KEY":"..."}'></textarea>
<div class="env-header">
<label class="form-label">{{ t('acp.fields.env') }}</label>
<button type="button" class="btn-link env-mode-toggle" @click="toggleEnvMode">
{{ envJsonMode ? t('acp.env.formMode') : t('acp.env.jsonMode') }}
</button>
</div>
<!-- Suggestion chips: clicking adds a pre-named row to the form. -->
<div v-if="!envJsonMode && envSuggestions.length > 0" class="env-suggestions">
<span class="env-suggestions-label">{{ t('acp.env.suggested') }}:</span>
<button
v-for="sug in envSuggestions"
:key="sug.key"
type="button"
class="env-suggestion-chip"
:class="{ 'is-added': hasEnvKey(sug.key) }"
:title="sug.hint"
:disabled="hasEnvKey(sug.key)"
@click="addSuggestedEnv(sug)"
>
{{ sug.key }}
<span class="chip-mark">{{ hasEnvKey(sug.key) ? '✓' : '+' }}</span>
</button>
</div>
<!-- Visual key/value editor (default mode). -->
<div v-if="!envJsonMode" class="env-table">
<div v-for="(entry, idx) in envEntries" :key="idx" class="env-row">
<input
v-model="entry.key"
class="form-input mono env-key-input"
:placeholder="t('acp.env.keyPlaceholder')"
@blur="entry.masked = isSecretKey(entry.key)"
/>
<div class="env-value-wrap">
<input
v-model="entry.value"
:type="entry.masked && !entry.revealed ? 'password' : 'text'"
class="form-input mono env-value-input"
:placeholder="t('acp.env.valuePlaceholder')"
autocomplete="off"
/>
<button
v-if="entry.masked"
type="button"
class="env-eye"
@click="entry.revealed = !entry.revealed"
:title="entry.revealed ? t('acp.env.hide') : t('acp.env.show')"
>
{{ entry.revealed ? '🙈' : '👁' }}
</button>
</div>
<button
type="button"
class="env-remove"
@click="removeEnvEntry(idx)"
:title="t('acp.env.remove')"
>×</button>
</div>
<button type="button" class="env-add-btn" @click="addEnvEntry">
+ {{ t('acp.env.addRow') }}
</button>
</div>
<!-- Raw JSON fallback for advanced users. -->
<textarea
v-else
v-model="form.envJson"
class="form-input form-textarea mono"
rows="3"
placeholder='{"OPENAI_API_KEY":"..."}'
></textarea>
<!-- Endpoint-specific hint (e.g. claude-code OAuth caveat). -->
<div v-if="envHint" class="env-hint">💡 {{ envHint }}</div>
</div>
<div class="form-group full-width">
<label class="toggle-inline">
@ -152,7 +225,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { mcConfirm } from '@/components/common/useConfirm'
@ -197,6 +270,156 @@ const form = reactive<any>(defaultForm())
const canSave = computed(() => !!form.name && !!form.command)
// ==================== Env editor state ====================
//
// The env field used to be a raw JSON textarea easy to mistype the
// quotes / commas / brace and impossible for users to discover which
// API key they actually need to paste. The visual editor below replaces
// that with a key/value table + per-endpoint suggestion chips, and
// keeps a "Edit as JSON" toggle so advanced users can still drop into
// a textarea when they want.
//
// Source-of-truth contract:
// - In form mode: envEntries is the truth; we serialize it into
// form.envJson at save time (and on toggle).
// - In JSON mode: form.envJson is the truth; we parse it back to
// envEntries when toggling off.
interface EnvEntry {
key: string
value: string
masked: boolean
revealed: boolean
}
interface EnvSuggestion {
key: string
hint: string
}
const envEntries = ref<EnvEntry[]>([])
const envJsonMode = ref(false)
/**
* Treat any key whose name implies "secret" as masked by default. The
* user can still toggle visibility on the row. The pattern matches the
* obvious cases (API_KEY / TOKEN / SECRET / PASS*) false negatives
* just show the value in plaintext, which is no worse than the old
* JSON textarea did.
*/
function isSecretKey(key: string): boolean {
if (!key) return false
return /(KEY|TOKEN|SECRET|PASS|CREDENTIAL)/i.test(key)
}
function entriesFromJson(json: string): EnvEntry[] {
if (!json || !json.trim()) return []
try {
const obj = JSON.parse(json)
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return []
return Object.entries(obj).map(([k, v]) => ({
key: k,
value: typeof v === 'string' ? v : String(v),
masked: isSecretKey(k),
revealed: false,
}))
} catch {
return []
}
}
function entriesToJson(entries: EnvEntry[]): string {
const obj: Record<string, string> = {}
for (const e of entries) {
const k = e.key.trim()
if (k) obj[k] = e.value
}
return JSON.stringify(obj)
}
function addEnvEntry() {
envEntries.value.push({ key: '', value: '', masked: false, revealed: false })
}
function removeEnvEntry(idx: number) {
envEntries.value.splice(idx, 1)
}
function hasEnvKey(key: string): boolean {
return envEntries.value.some((e) => e.key === key)
}
function addSuggestedEnv(sug: EnvSuggestion) {
if (hasEnvKey(sug.key)) return
envEntries.value.push({
key: sug.key,
value: '',
masked: isSecretKey(sug.key),
revealed: false,
})
}
/**
* Suggestion chips per endpoint mirrors the server-side
* AcpRuntimeSupport.expectedAuthEnvVar() so the auth-error translator
* and the form helper agree on what env var each endpoint expects.
*/
const envSuggestions = computed<EnvSuggestion[]>(() => {
const slug = String(form.name || '').toLowerCase()
const cmd = String(form.command || '').toLowerCase()
const args = String(form.argsJson || '').toLowerCase()
const matches = (kw: string) => slug.includes(kw) || cmd.includes(kw) || args.includes(kw)
if (matches('claude') || matches('anthropic')) {
return [{ key: 'ANTHROPIC_API_KEY', hint: t('acp.env.hints.anthropic') }]
}
if (matches('codex') || matches('openai')) {
return [{ key: 'OPENAI_API_KEY', hint: t('acp.env.hints.openai') }]
}
if (matches('qwen') || matches('dashscope')) {
return [{ key: 'DASHSCOPE_API_KEY', hint: t('acp.env.hints.dashscope') }]
}
if (matches('gemini') || matches('google')) {
return [{ key: 'GOOGLE_API_KEY', hint: t('acp.env.hints.google') }]
}
// opencode is multi-provider; surface a soft hint instead of a chip
// because we don't know which provider the user has configured.
return []
})
/**
* Caveat banner shown beneath the env editor. Today it only fires for
* claude-code endpoints, where users are most likely to confuse OAuth
* login (which doesn't authenticate against the public API) with the
* env var the third-party Zed wrapper actually needs.
*/
const envHint = computed(() => {
const slug = String(form.name || '').toLowerCase()
if (slug.includes('claude')) return t('acp.env.hints.claudeOauth')
return ''
})
/**
* Switch between form and JSON. The truth-of-record swaps direction;
* the watch below performs the bridging conversion so the user never
* sees stale data on the other side.
*/
function toggleEnvMode() {
envJsonMode.value = !envJsonMode.value
}
watch(envJsonMode, (isJson, wasJson) => {
if (isJson && !wasJson) {
// form JSON: serialize entries; the textarea now owns the value.
form.envJson = entriesToJson(envEntries.value)
} else if (!isJson && wasJson) {
// JSON form: parse textarea back into rows; if the JSON is
// invalid, keep the entries empty rather than throw the user can
// fix the JSON and toggle again.
envEntries.value = entriesFromJson(form.envJson)
}
})
onMounted(loadEndpoints)
async function loadEndpoints() {
@ -221,6 +444,8 @@ function argsPreview(ep: AcpEndpoint): string {
function openCreateModal() {
editing.value = null
Object.assign(form, defaultForm())
envEntries.value = []
envJsonMode.value = false
showModal.value = true
}
@ -236,6 +461,10 @@ function openEditModal(ep: AcpEndpoint) {
toolParseMode: ep.toolParseMode || 'call_title',
enabled: !!ep.enabled,
})
// Always open in form mode so users see the structured editor first.
// The "Edit as JSON" toggle is one click away if they need it.
envEntries.value = entriesFromJson(form.envJson)
envJsonMode.value = false
showModal.value = true
}
@ -245,6 +474,12 @@ function closeModal() {
}
async function saveEndpoint() {
// In form mode, the entries array is the truth serialize it back
// into envJson before validating. In JSON mode the textarea is the
// truth and we validate it as-is.
if (!envJsonMode.value) {
form.envJson = entriesToJson(envEntries.value)
}
// Sanity-check args/env are valid JSON before sending; the server
// will tolerate empty strings, but we'd rather fail fast in UI.
try {
@ -384,4 +619,59 @@ td .status-error { background: none; color: var(--mc-text-tertiary); font-size:
.form-textarea { resize: vertical; }
.toggle-inline { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 22px; border-top: 1px solid var(--mc-border-light); }
/* ---------- Visual env editor ---------- */
.env-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
.env-mode-toggle { font-size: 11px; padding: 0; }
.env-suggestions {
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
margin: 4px 0 8px; padding: 7px 10px;
background: var(--mc-primary-bg); border-radius: 8px; font-size: 12px;
}
.env-suggestions-label { color: var(--mc-text-secondary); font-weight: 600; }
.env-suggestion-chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 9px;
background: var(--mc-bg-elevated); border: 1px solid var(--mc-border);
border-radius: 999px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; cursor: pointer; color: var(--mc-text-primary);
transition: 0.15s;
}
.env-suggestion-chip:hover { border-color: var(--mc-primary); color: var(--mc-primary); }
.env-suggestion-chip:disabled { cursor: default; }
.env-suggestion-chip.is-added {
background: rgba(34, 197, 94, 0.12); color: #16a34a;
border-color: transparent;
}
.env-suggestion-chip .chip-mark { font-weight: 700; opacity: 0.7; }
.env-table { display: flex; flex-direction: column; gap: 6px; }
.env-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(120px, 1.4fr) 24px; gap: 6px; align-items: center; }
.env-key-input, .env-value-input { font-size: 12px; padding: 7px 9px; }
.env-value-wrap { position: relative; display: flex; }
.env-value-input { padding-right: 32px; flex: 1; }
.env-eye {
position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
border: none; background: none; font-size: 13px; cursor: pointer; padding: 2px;
width: 24px; height: 24px; line-height: 1; border-radius: 4px;
}
.env-eye:hover { background: var(--mc-bg-sunken); }
.env-remove {
width: 24px; height: 24px; border: none; background: none; cursor: pointer;
color: var(--mc-text-tertiary); border-radius: 6px; font-size: 16px; line-height: 1;
}
.env-remove:hover { background: var(--mc-danger-bg); color: var(--mc-danger); }
.env-add-btn {
padding: 6px; background: none; border: 1px dashed var(--mc-border);
border-radius: 8px; color: var(--mc-text-secondary); font-size: 12px;
cursor: pointer; transition: 0.15s;
}
.env-add-btn:hover { border-color: var(--mc-primary); color: var(--mc-primary); }
.env-hint {
margin-top: 8px; padding: 7px 10px;
background: var(--mc-primary-bg); border-radius: 6px;
font-size: 11px; color: var(--mc-text-secondary); line-height: 1.55;
}
</style>

View File

@ -26,7 +26,7 @@
<div class="agent-selector">
<button class="agent-select-trigger" @click="agentDropdownOpen = !agentDropdownOpen" :title="`${$t('chat.selectAgent')} (⌘K)`">
<span class="agent-select-trigger__icon">{{ currentAgent?.icon || '🤖' }}</span>
<span class="agent-select-trigger__icon"><SkillIcon :value="currentAgent?.icon" :size="24" :fallback="'🤖'" /></span>
<span v-if="!convPanelCollapsed || isMobile" class="agent-select-trigger__name">{{ currentAgent?.name || $t('chat.selectAgent') }}</span>
<svg v-if="!convPanelCollapsed || isMobile" class="agent-select-trigger__arrow" :class="{ open: agentDropdownOpen }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
@ -154,7 +154,7 @@
<div class="chat-stage-copy" v-if="currentAgent">
<div class="chat-stage-kicker">{{ $t('nav.chat') }}</div>
<div class="agent-badge" :title="currentAgent.name">
<span class="agent-badge-icon">{{ currentAgent.icon || '🤖' }}</span>
<span class="agent-badge-icon"><SkillIcon :value="currentAgent.icon" :size="22" :fallback="'🤖'" /></span>
<span class="agent-badge-name">{{ currentAgent.name }}</span>
<span class="agent-badge-type">{{ currentAgent.agentType === 'react' ? 'ReAct' : 'Plan-Execute' }}</span>
<span class="status-dot" :class="connectionStatusClass" :title="connectionStatusLabel"></span>

View File

@ -17,7 +17,7 @@
<!-- Agent selector (ChatConsole pattern) -->
<div class="agent-selector">
<button class="agent-select-trigger" @click="agentDropdownOpen = !agentDropdownOpen">
<span class="agent-select-trigger__icon">{{ currentAgent?.icon || '🧠' }}</span>
<span class="agent-select-trigger__icon"><SkillIcon :value="currentAgent?.icon" :size="24" :fallback="'🧠'" /></span>
<span class="agent-select-trigger__name">{{ currentAgent?.name || t('memory.selectAgent') }}</span>
<svg class="agent-select-trigger__arrow" :class="{ open: agentDropdownOpen }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>

View File

@ -861,6 +861,9 @@ const categoryTabs = computed(() => [
{ label: t('skills.tabs.all'), value: 'all', icon: '🗂️' },
{ label: t('skills.tabs.builtin'), value: 'builtin', icon: '🔧' },
{ label: t('skills.tabs.mcp'), value: 'mcp', icon: '🔌' },
// ACP (Agent Communication Protocol) auto-bridged from
// Settings ACP Endpoints; one card per enabled endpoint.
{ label: t('skills.tabs.acp'), value: 'acp', icon: '🤝' },
{ label: t('skills.tabs.dynamic'), value: 'dynamic', icon: '📦' },
])
@ -1473,19 +1476,24 @@ function needsSetup(skill: Skill): boolean {
}
function getSkillIcon(type: string) {
return { builtin: '🔧', mcp: '🔌', dynamic: '📦' }[type] ?? '🛠️'
return { builtin: '🔧', mcp: '🔌', acp: '🤝', dynamic: '📦' }[type] ?? '🛠️'
}
function getSkillIconBg(type: string) {
return { builtin: 'bg-blue', mcp: 'bg-purple', dynamic: 'bg-green' }[type] ?? 'bg-gray'
return { builtin: 'bg-blue', mcp: 'bg-purple', acp: 'bg-orange', dynamic: 'bg-green' }[type] ?? 'bg-gray'
}
function getSkillTypeBadge(type: string) {
return { builtin: 'badge-blue', mcp: 'badge-purple', dynamic: 'badge-green' }[type] ?? 'badge-gray'
return { builtin: 'badge-blue', mcp: 'badge-purple', acp: 'badge-orange', dynamic: 'badge-green' }[type] ?? 'badge-gray'
}
function getSkillTypeLabel(type: string) {
const map: Record<string, string> = { builtin: t('skills.types.builtin'), mcp: t('skills.types.mcp'), dynamic: t('skills.types.dynamic') }
const map: Record<string, string> = {
builtin: t('skills.types.builtin'),
mcp: t('skills.types.mcp'),
acp: t('skills.types.acp'),
dynamic: t('skills.types.dynamic'),
}
return map[type] ?? type
}
</script>
@ -1676,6 +1684,7 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); }
.skill-icon-wrap { width: 44px; height: 44px; border-radius: 14px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.bg-blue { background: var(--mc-primary-bg); }
.bg-purple { background: var(--mc-primary-bg); }
.bg-orange { background: var(--mc-primary-bg); }
.bg-green { background: var(--mc-primary-bg); }
.bg-gray { background: var(--mc-bg-sunken); }
.skill-icon { font-size: 20px; }