如何在 Zed 中为 ACP Server 实现认证流程

大家好,我是张晋涛。

有一段时间没有更新长文了,这几天假期正好把博客重新整理了下,心情都变好了,正好用这篇来恢复下更新。

熟悉我的小伙伴都知道,对于当前的 Coding Agent 工具,我主要推荐的就 4 个,Amp,Claude Code,Codex 和 Warp。

https://x.com/zhangjintao9020/status/2019571842009428399?s=20

这篇主要就和 Amp 有关,而且其中的涉及到的代码变更也基本上都是通过 Amp 完成的。

背景

先简单说下 ACP 以及我做的项目 amp-acp

ACP(Agent Client Protocol)是一种标准化的协议,用于连接 AI Agent 和客户端编辑器(如 Zed)。 在 ACP 发布之后,我觉得这个协议蛮有用的,并且众多 coding agent 工具都快速的进行了跟进,唯独 Amp 团队官方并没有打算实现 ACP 的支持。 基于对 ACP 的兴趣以及对于 Amp 的喜欢,所以我发布了这个 amp-acp 项目。 amp-acp 是一个 ACP adapter,让用户可以在 Zed 中使用 Amp 这个 Coding Agent。

amp-acp 我一开始只是对 Amp CLI 的封装,但是我发现这样用户在使用的时候安装步骤比较繁琐,所以我将它重构为基于 Amp SDK 的方式进行构建,但是 Amp SDK 并没有那么完善,尤其是它也不支持 login 操作。

所以在 v0.6.1 之前,amp-acp 的认证方式非常原始:要求用户手动在 Zed 的 settings.json 中配置 AMP_API_KEY 环境变量,或者先通过 Amp CLI 执行 amp login。我对这个流程并不满意。

因此在 v0.7.0 中,我实现了完整的认证流程,让用户可以直接在 Zed 的 Agent Panel 中完成 API Key 的配置。

ACP 协议中的认证机制

协议层的设计

ACP 在 initialize 响应中定义了 authMethods 字段,用于声明 Agent Server 支持的认证方式:

{
  "protocolVersion": 1,
  "authMethods": [
    {
      "id": "setup",
      "name": "Amp API Key Setup",
      "description": "Run interactive setup to configure your Amp API key",
      "_meta": { ... }
    }
  ]
}

协议本身只定义了 idnamedescription 和一个通用的 _meta 扩展字段。认证的具体实现机制并不是协议标准的一部分,而是通过 _meta 扩展来实现的。

terminal-auth:Zed 的扩展认证机制

Zed 在 ACP 的 _meta 字段中定义了一个实验性的 terminal-auth 扩展,用于支持基于终端的交互式认证。客户端在 initialize 请求中通过 _meta 声明支持此能力:

// Zed 客户端初始化时声明支持 terminal-auth
.meta(acp::Meta::from_iter([
    ("terminal_output".into(), true.into()),
    ("terminal-auth".into(), true.into()),
]))

当 Agent Server 在 authMethods_meta 中返回 terminal-auth 配置时,Zed 会提取其中的 commandargsenv,在 Zed 内置终端中打开一个新的 terminal tab 来执行认证命令:

// Agent Server 返回的 authMethods
authMethods: [
  {
    id: 'setup',
    name: 'Amp API Key Setup',
    description: 'Run interactive setup to configure your Amp API key',
    _meta: {
      'terminal-auth': {
        command: getTerminalAuthCommand(), // 可执行文件路径
        args: ['--setup'],                 // 命令参数
        label: 'Amp API Key Setup',        // 终端 tab 标题
      },
    },
  },
],

认证流程的完整生命周期

下面是整个认证流程从触发到完成的详细过程:

1. 初始化阶段

Zed 启动 ACP Server 进程后,发送 initialize 请求。Server 返回 authMethods 列表,Zed 将其存储起来。

2. 触发认证

认证可以在两个时机被触发:

3. 执行认证

Zed 的处理流程(从其 Rust 源码中可以看到):

用户点击 "Amp API Key Setup" 按钮
  → Zed 检查 authMethod 的 _meta 中是否有 terminal-auth
  → 提取 command / args / env
  → 通过 SpawnInTerminal 在内置终端中执行命令
  → 等待进程退出并检查退出码
  → 退出码为 0 → 认证成功,reset 连接
  → 退出码非 0 → 认证失败,显示错误

4. 认证后重连

认证成功后,Zed 调用 this.reset(window, cx) 重置整个 Agent 视图。这意味着会重新走一遍 initializeauthenticate 的流程。

一些不得不提的坑

经验 1:terminal-auth 的退出码决定成功与否

Zed 对于 terminal-auth 类型的认证,会等待进程退出并检查退出码:

// Zed 源码 crates/agent_ui/src/acp/thread_view.rs
match exit_status {
    Some(status) if status.success() => Ok(()),
    Some(status) => Err(anyhow!(
        "Login command failed with exit code: {:?}",
        status.code()
    )),
    None => Err(anyhow!("Login command terminated without exit status")),
}

因此,--setup 命令必须

经验 2:labelterminal-auth 的必填字段

这个点是这篇文章中我觉得最值得说的地方。。。

虽然 terminal-auth 不是 ACP 协议的正式规范,没有文档说明哪些字段是必填的,但从 Zed 源码可以看到一个容易踩坑的细节:

// Zed 源码 crates/agent_ui/src/acp/thread_view.rs
if let (Some(command), Some(label)) = (
    terminal_auth.get("command").and_then(|v| v.as_str()),
    terminal_auth.get("label").and_then(|v| v.as_str()),
) {
    // 只有 command 和 label 同时存在才进入 terminal-auth 逻辑
    ...
}

Zed 使用 Rust 的 tuple pattern matching,commandlabel 必须同时存在才会进入 terminal-auth 分支。如果你只提供了 command 而缺少 label,整个条件不成立,Zed 会静默跳过 terminal-auth,fallback 到普通的 connection.authenticate() 路径——不会有任何错误提示,认证就是不工作。

所以 terminal-auth 的最小可用配置是:

{
  "terminal-auth": {
    "command": "/path/to/binary",
    "label": "My Auth Setup"
  }
}

argsenv 是可选的,但 label 绝对不能省。

经验 3:Bun 编译二进制的 /$bunfs/ 虚拟路径问题

当使用 bun build --compile 打包成独立二进制文件时,process.argv[1] 会变成一个虚拟路径(如 /$bunfs/root/amp-acp),这个路径在文件系统中并不存在。如果直接用它作为 terminal-authcommand,Zed 将无法执行:

export function getTerminalAuthCommand(
  argv1: string | undefined = process.argv[1],
  execPath: string = process.execPath,
): string {
  const resolvedArgv1 = argv1 ? path.resolve(argv1) : '';
  // Bun 编译的二进制中,argv[1] 是 /$bunfs/ 开头的虚拟路径
  if (!resolvedArgv1 || resolvedArgv1.startsWith('/$bunfs/')) {
    return execPath;  // 使用实际的可执行文件路径
  }
  return resolvedArgv1;  // 非编译模式,使用脚本路径
}

在开发时用 bun dist/index.js 运行一切正常,但打包成二进制后就会出问题。

经验 4:运行时 Auth 错误检测需要模式匹配

即使初始认证成功了,API Key 也可能在运行过程中失效(过期、被撤销等)。Amp SDK 在这种情况下不会抛出特定的错误类型,而是返回包含错误信息的字符串。因此需要通过模式匹配来检测:

export function isAuthError(message: string): boolean {
  const lower = message.toLowerCase();
  return lower.includes('invalid or missing api key') ||
    lower.includes("run 'amp login'") ||
    lower.includes('authentication') ||
    lower.includes('unauthorized') ||
    lower.includes('no api key found') ||
    (lower.includes('api key') && lower.includes('login flow')) ||
    (lower.includes('api key') && (lower.includes('missing') || lower.includes('invalid')));
}

检测到 auth 错误后,在 prompt 的执行流程中抛出 RequestError.authRequired(),让 Zed 重新触发认证流程:

// 在 streaming 结果中检测
if (message.type === 'result' && message.is_error) {
  if (typeof message.error === 'string' && isAuthError(message.error)) {
    throw RequestError.authRequired();
  }
}

// 在 catch 块中检测
if (err instanceof Error && isAuthError(err.message)) {
  throw RequestError.authRequired();
}

经验 5:authenticate 方法的角色

ACP 协议中的 authenticate 方法在流程中承担的是验证确认的角色,而非执行认证:

async authenticate(_params: AuthenticateRequest): Promise<AuthenticateResponse> {
  if (process.env.AMP_API_KEY) {
    return {};  // API Key 已配置,认证成功
  }
  throw RequestError.authRequired();  // 仍未配置,继续要求认证
}

对于 terminal-auth 模式,实际的认证动作(用户输入 API Key)发生在终端进程中。Zed 在终端进程成功退出后,会调用 authenticate 来确认认证状态。

经验 6:凭证存储策略

由于 ACP Server 是一个 stdio 进程,没有持久化的状态,需要自行管理凭证存储:

function getConfigDir(): string {
  if (process.platform === 'win32') {
    return path.join(
      process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming'),
      'amp-acp'
    );
  }
  return path.join(
    process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config'),
    'amp-acp'
  );
}

凭证文件以 0o600 权限写入,确保只有当前用户可读。同时在启动时检查:环境变量 AMP_API_KEY 优先 → 凭证文件次之 → 都没有则等待认证流程触发。

经验 7:stdout 是协议通道,日志必须走 stderr

ACP 使用 stdin/stdout 进行 JSON-RPC 通信。--setup 的交互式提示和所有日志都必须输出到 stderr,否则会破坏协议通信:

// 在入口文件最开始就重定向
console.log = console.error;
console.info = console.error;
console.warn = console.error;
console.debug = console.error;

// readline 也要指向 stderr
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stderr  // 不是 stdout!
});

整体流程图

内置终端ACP ServerZed Editor用户内置终端ACP ServerZed Editor用户alt[主动触发][被动触发]alt[退出码 = 0][退出码 ≠ 0]1. 启动进程 (stdio)2. initialize 请求_meta: {terminal-auth: true}返回 authMethods3. 存储 authMethods4a. 使用 /login 命令4b. 发送 promptsession/promptauthRequired (-32000)5. 显示 "Authenticate to Amp" Callout6. 点击认证按钮检测 terminal-auth _meta7. SpawnInTerminalamp-acp --setup输入 API Key进程退出码8. authenticate 确认认证成功reset 重连正常使用 🎉显示认证失败

总结

这篇中其他的部分其实都不重要,就是顺手记录下。唯一有价值的地方(也是我踩到坑的地方)就是 label 不能少:Zed 要求 commandlabel 同时存在才启用 terminal-auth,缺少 label 会静默失败。不翻 Zed 的代码还真没想到是这么个逻辑(当然,也不是我自己看的,我把 Zed 的源码路径丢给了 Amp 让它自己来找的)

最近 Amp 团队打算把它的编辑器插件都自动销毁了,如果你在使用 Zed 和 Amp,那么不妨来试试看这个项目:amp-acp


欢迎订阅我的文章公众号【MoeLove】

TheMoeLove

Comments