freeCodeCamp.org

How to Build an AI Agent with Per-User OAuth Access [Full Handbook]

8.5内容质量
How to Build an AI Agent with Per-User OAuth Access [Full Handbook]

TL;DR · AI 摘要

本文详解如何构建支持多用户OAuth的AI代理,通过Slack和GitHub集成实现用户级权限控制,提供完整代码实现和安全架构设计。

核心要点

  • 使用用户特定OAuth令牌替代共享API密钥,避免权限混淆
  • 通过加密存储和动态令牌转换实现敏感信息零暴露
  • Node.js+ES模块构建无框架轻量级后端服务

结构提纲

按章节快速跳转。

  1. 说明多用户AI代理必须解决的权限归属问题

  2. 通过用户特定标识符和动态令牌转换实现安全访问

  3. 展示包含OAuth回调、加密存储和工具调用的完整架构

  4. 详细讲解授权重定向、状态验证和令牌交换全过程

  5. 使用Node.js加密模块实现按用户键值存储的令牌仓库

  6. 演示如何基于用户标识动态获取令牌执行操作

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • AI代理OAuth实现
    • 核心机制
      • 用户标识符路由
      • 动态令牌转换
    • 安全架构
      • 加密存储
      • 零令牌暴露
    • 技术实现
      • Node.js后端
      • OAuth流程

金句 / Highlights

值得收藏与分享的关键句。

#AI代理#OAuth#Slack#GitHub#Node.js
打开原文

如何构建支持每个用户OAuth访问的AI代理 [完整手册]

2026年8月12日

/

#ai agents

Saif Ali Shaik

当你的AI代理需要为多个用户服务时,每个工具调用都必须回答:这个代理是代表谁在行动?让我们通过构建一个连接SlackGitHub的AI代理来学习如何解决这个问题。

Slack的读取操作会使用该用户的workspace。GitHub的问题创建会以该用户身份在可访问的仓库中进行。代理可能会做出错误的调用,但绝不能使用错误用户的访问权限。

解决方案包含两个部分,这两个部分都出现在本教程的前半部分:

  • 每个用户单独授权访问。Alice为自己授权Slack访问权限,Bob则为自己授权。
  • 你的代理传递标识符而非令牌。像[email protected]这样的字符串用于选择使用哪个授权。一个函数在调用时将其转换为令牌,且该令牌不会出现在你的模型输入、工具模式或日志中。

大多数代理教程会在达到这两个要点前就停止。它们会给你一个API密钥,连接一个函数,让模型调用它。这种设计在只有一个用户时有效,但遇到第二个用户时就会失效。

为了使这个模式具体化,你将构建一个命令行代理,它监视Slack频道,自行判断哪些消息描述了实际工作,为符合条件的消息创建GitHub问题,并在Slack线程中用问题链接回复。每次调用都以单个用户的OAuth授权身份执行。

你将亲自实现OAuth流程:授权重定向、状态检查、令牌交换、加密存储以及刷新路径。这些实现都不复杂,完整查看整个流程能让身份论证变得可验证,而非仅凭信念接受。

本文不涉及两个主题:我们不会覆盖Model Context Protocol服务器、语音或实时主机。身份模式在这些场景中同样适用,但周边基础设施值得单独撰写文章。

目录

  • 你将构建的内容
  • 先决条件
  • 什么是AI代理工具?
  • 为什么共享令牌会失效
  • 架构概览
  • 如何注册Slack和GitHub的OAuth应用
  • 如何运行授权流程
  • 如何加密存储令牌(以用户为密钥)
  • 如何以当前用户身份执行工具调用
  • 如何处理刷新和撤销
  • 如何添加第二个提供方
  • 完整操作指南
  • 如何将该模式应用于其他使用场景
  • 我构建此系统时遇到的问题
  • 结论

你将构建的内容

该代理名为channel-watcher-agent。每次运行执行四个操作:

  • 从Slack频道读取最近消息。
  • 逐条询问模型,判断文本是否描述了错误或具体行动项。
  • 为符合条件的消息创建GitHub问题。
  • 在原始Slack线程中用新问题链接回复。

无需点击按钮启动任何操作。Slack已有"从消息创建问题"的操作,这是不同产品。此处代理自主阅读频道,形成自己的判断,仅对认为值得处理的内容采取行动。

有意保持堆栈精简:

组件 | 角色 --- | --- Node.js,纯ES模块 | 无需Web框架,无需队列

node:http``` | OAuth回调服务器
node:sqlite``` | 无需安装依赖的令牌存储
Vercel AI SDK | 模型调用和工具循环

这五个组件中有三个随Node.js自带。你只需安装AI SDK及其相关包。

最终你将获得:

- 两个OAuth应用(Slack和GitHub),用户只需一次授权即可。

- 一个以用户和提供方为键的加密令牌存储。

- 一个代理程序,将当前用户解析为标识符,并确保令牌永远不会传递给模型。

- 一个工具循环,模型决定是否需要提交问题。

- 一个演示,展示第二个用户的运行会停止,而不是读取第一个用户的数据。

完成的代码位于 github.com/saif-shines/channel-watcher-agent 。

## 先决条件

账户和工具:

- Node.js 22.13 或更新版本,以及 npm。令牌存储使用 node:sqlite,从该版本开始稳定。

- 一个可以安装应用的 Slack 工作区,以及一个要监视的频道。临时频道效果最佳。

- 一个 GitHub 账户和一个可以接收测试问题的仓库。

- AI SDK 支持的模型提供商的 API 密钥。示例中使用了 Anthropic。

- mkcert,用于生成本地 HTTPS 证书。如何注册 Slack 和 GitHub OAuth 应用解释了为什么普通的 http://localhost 回调无法使用。

有用的背景知识(但都不是硬性要求):

- async 和 await,以及阅读小型 Node 脚本。

- OAuth 2.0 的高层次理解:应用将用户重定向到提供方,用户同意后,应用接收令牌。

- 工具调用(有时称为函数调用)。下一节将涵盖教程所需内容。

开始前的一个警告:代理程序会写入真实系统。它会在 GitHub 上创建真实问题,并在 Slack 中发送真实消息。在确认它只对您预期的消息做出反应之前,请使用测试 Slack 频道和临时 GitHub 仓库。

## 什么是 AI 代理工具?

工具是您与输入一起提供给模型的函数。模型本身无法运行该函数。它只能请求:使用此标题和正文调用 fileGithubIssue。您的代码执行该调用,返回结果,模型使用该结果选择下一步操作。

请求、执行、返回。这种交互是整个机制,所有被称为“代理”的内容都是围绕它的循环。

### 工具与 API 的区别

工具和 API 包装了相同的调用,但编写方式面向不同的读者。

API 是为您编写的。它假设您阅读了文档,并且知道 thread_ts 是将 Slack 消息转换为线程回复的字段。

工具是为一个未阅读任何内容的模型编写的。因此,工具自带解释:

- 模型可以推理的名称,如 fileGithubIssue。

- 包括何时不使用工具的自然语言描述。

- 输入的模式,让模型知道 title 是必需的字符串。

以下是项目中的一个工具。大部分代码是解释而非逻辑:

const fileGithubIssue = tool({ description: '为可操作的 Slack 消息创建 GitHub 问题', inputSchema: z.object({

body: z.string(), }), execute: async ({ title, body }) => { // ...实际的 API 调用在此处 }, });

code

描述和 inputSchema 是模型可见的部分。execute 函数是您独有的。身份在 execute 中确定,因此模型永远不会知道调用针对的是哪个账户。

- 结果返回到模型中。当 fileGithubIssue 返回后,模型可以读取新的问题 URL,并在 Slack 回复中使用它。这种链式调用使得第二步成为可能。

- 凭据不会出现在对话中。模型通过名称请求操作,且永远不会看到令牌。一个从未见过的令牌无法泄露到补全内容、日志行或提示注入负载中。

第三个原因正是本教程其余部分要实现的目标。你将有意避免让令牌进入模型:代理持有标识符,而令牌仅在调用提供方时才会出现。

### 大多数代理需要使用多个应用

很少有实用的代理只与一个应用交互。支持代理会读取 Zendesk 并更新 Salesforce。站立会议代理会读取 GitHub 并发布到 Slack。日程安排代理会读取 Gmail 并写入 Google 日历。

每个应用都带有其独立的 OAuth 注册、作用域名称、令牌生命周期和刷新行为。将列表乘以代理的每个用户,真正的挑战就显现出来了。

## 为什么共享令牌会失效

一个共享凭证供所有人使用在演示中有效,但一旦第二个人出现就会失败。想象一下 Slack 部分的简化版本:创建一个 Slack 应用,安装它,将 bot token 复制到 .env,然后让所有工具调用都使用它。

三个问题同时出现。

首先,每次运行都使用相同的权限。bot 可以看到它被邀请到的每个频道,无论谁触发了运行。即使你从未加入过某个频道,询问代理关于该频道的信息时,bot 仍然会读取它。代理已经成为绕过你工作区权限的途径。

其次,审计追踪也出现了错误。每个 GitHub 问题都显示是 bot 创建的。每个 Slack 回复都来自 bot。当被问及某个问题为何存在时,诚实的回答是"一个代理为某人创建了它,但我们无法确定是谁"。

第三,撤销功能失效。用户离职且 Slack 账号被停用后,代理仍会继续运行,因为它从未使用过该用户的凭证。

替代方案是按用户授权。每个用户自行授权应用。这虽然带来了新需求:需要一个存储这些授权的地方。

### 区别体现在一个响应字段中

Slack 以异常直观的方式展示了差异。当用户完成授权页面后,令牌交换返回的 JSON 对象中同时包含两种类型的令牌:

{ "ok": true, "access_token": "xoxb-REDACTED-BOT-TOKEN", "token_type": "bot", "authed_user": { "id": "U0A1B2C3D", "scope": "channels:history,chat:write,users:read", "access_token": "xoxp-REDACTED-USER-TOKEN", "token_type": "user" } }

code

顶层的 access_token 是 bot 令牌。嵌套的 authed_user.access_token 是刚刚授权的用户令牌。使用第一个令牌调用 conversations.history 会返回应用被邀请到的所有频道。使用第二个令牌调用则只返回用户已可见的频道。这种区分同样适用于写操作:使用用户令牌调用 chat.postMessage 会以该用户名义发布消息。

两个字段的前缀仅差一个字母,而你的代理整个权限模型都取决于存储的是哪一个。本教程仅请求用户作用域,因此 Slack 根本不会发放任何 bot 令牌。

### 令牌必须远离模型和日志

按用户分配的令牌成为系统中最敏感的数据。有两个存储位置必须避免:

- 模型:将令牌排除在输入、工具描述和工具返回值之外。模型若曾接触过某个令牌,就可能重复使用它,而提示注入会将任何工具结果转化为不可信输入。

- 你的日志:在调试代理时,工具输入和输出正是需要记录的内容。这些有效负载中的令牌会永久存储在你的日志系统中。

本教程将令牌限制在单一明确的路径上。你的代码传递一个标识符——指向特定用户的稳定引用。一个辅助组件将该标识符转换为令牌,之后令牌直接进入服务提供商的调用流程,不再参与其他任何环节。它永远不会出现在工具模式中,不会与模型可读的任何内容关联,也不会被任何工具返回。

### 为什么你拥有OAuth应用和存储

自行实现流程的关键不在于管道本身,而在于控制谁可以使用谁的授权。

在本教程中,用户是团队成员。每个人连接自己的Slack和GitHub,代理则以触发运行的用户身份执行操作。当这些用户是你的产品客户时,同样的设计依然适用:每个人仍拥有自己的授权,错误的映射会导致某人使用他人的访问权限执行操作。唯一变化的是标识符的来源:团队成员使用会话,客户使用租户记录。

## 架构概览

两个流程至关重要,且发生在不同阶段。将它们分离是实现该架构的大部分工作。

连接阶段:每个用户和每个应用只发生一次。用户授权后,令牌存储到你的系统中。此时代理并未运行。

运行阶段:每次执行都会发生。代理将当前用户解析为标识符并执行任务。无需授权界面,也不需要浏览器。

连接阶段(每个用户和应用只发生一次)

你的用户 connect.js Slack / GitHub | | | |-- "连接Slack" --->| | |<--- 授权链接 -----| | |----------------------- OAuth授权 ---------->| | |<--- 重定向+授权码 ----| | |---- 交换授权码 ----->| | |<---- 令牌 ------------| | | | | [加密,存储 | | 到(标识符, | | 服务提供方)] | | | |

运行阶段(每次代理执行都会发生)

你的代理 令牌存储 Slack / GitHub | | | [从自身会话解析标识符 | | ] | | | | | |-- getAccessToken( --->| | | 标识符, | | | 服务提供方 ) | | |<---- 令牌 -----------| | | | | |------------------ 以用户身份调用API ------------>| |<----------------- 结果 -----------------------| | | | [模型只看到结果, | | 从不接触令牌] | |

code

该架构衍生出三个关键属性。

标识符在您的代理代码中替换令牌。令牌上方的存储处理类似 [email protected] 或 user_8f21c 的字符串。该字符串本身毫无价值:没有存储及其加密密钥,它无法打开任何内容。

一个标识符可跨越多个应用。单个标识符下方有 Slack 的一行和 GitHub 的一行。第三个应用不会创建第三个标识符来协调。

授权保留在您的代码中。存储回答哪些令牌属于某个标识符。存储无法判断请求是否值得回应。决定调用方是否可以作为该标识符进行操作发生在任何调用之前。

有一条规则必须遵守,违背它将破坏整个设计:从经过身份验证的会话中在服务器端解析标识符。永远不要从请求体、查询参数或浏览器中接受标识符。从客户端接受的标识符相当于“以任何用户身份行动”的端点。

## 如何注册 Slack 和 GitHub 的 OAuth 应用

本教程端到端使用 Slack 和 GitHub 作为两个提供商。两者都需要相同三件事:已注册的应用、重定向 URI 和一组作用域。细节差异足够明显,值得分别说明。

### 重定向 URI 必须使用 HTTPS

大多数涉及 OAuth 的教程会直接给你 http://localhost:3000/callback 并继续讲解。Slack 会拒绝这个地址。Slack 的文档明确指出“重定向 URL 必须使用 HTTPS”,对本地主机没有例外。GitHub 更宽松,接受两者,因此一个 HTTPS 回调地址即可满足两者需求。

这条规则看似繁琐,因为在本地主机上请求永远不会离开你的机器,网络上也没有内容可以被拦截。Slack 依然统一应用此规则,对于发放凭证的提供商来说,统一规则且没有例外是一个可辩护的选择:每个例外都需要有人正确处理,而“这真的是本地主机吗”这个问题之前曾被错误回答过。

mkcert 会生成一个由本地颁发机构签名的证书,并将其添加到你的系统信任存储中,因此浏览器会接受它而不会发出警告:

mkcert -install mkcert localhost

code

这会将 localhost.pem 和 localhost-key.pem 写入当前目录。使用类似 ngrok 的隧道服务也可以实现,但其免费 URL 会轮换,这意味着每次会话都需要重新编辑两个应用注册信息。

### Slack 应用和唯一重要的设置

在 api.slack.com/apps 中,创建一个工作区的应用。然后打开 OAuth & Permissions 并设置两件事。

在重定向 URL 下添加 https://localhost:3000/callback。

然后找到作用域。页面有两部分,选择错误的部分会静默重建共享机器人设计:

| 部分 | 授予权限 | 是否在此处使用? |
|------|----------|------------------|
| Bot Token Scopes | 以应用身份行动的 `xoxb-` 令牌 | 否 |
| User Token Scopes | 以用户身份行动的 `xoxp-` 令牌 | 是 |

在 User Token Scopes 下添加:

- channels:history:读取用户所属的公共频道中的消息
- chat:write:以用户身份发帖
- users:read:将用户 ID 转换为名称

保持 Bot Token Scopes 为空。从基本信息中复制 Client ID 和 Client Secret。

### GitHub OAuth 应用

在 Settings → Developer settings → OAuth Apps → New OAuth App 中,将授权回调 URL 设置为相同的 https://localhost:3000/callback,然后生成客户端密钥。如果需要周围细节,GitHub 会完整文档化网络应用流程。

GitHub 的创建问题作用域取决于仓库:

- repo 适用于私有仓库,并同时授予对代码的读写权限。

- public_repo 是更狭窄的选择,当你的测试仓库是公开时已经足够。

尽可能选择更狭窄的权限范围。你不需要的权限范围,将来会成为需要解释的负担。

### 环境文件

两个应用都会生成客户端 ID 和客户端密钥,而存储需要一个加密密钥。先生成密钥:

node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))"

code

然后填写 .env 文件:

OAUTH_REDIRECT_URI=https://localhost:3000/callback TLS_CERT_PATH=./localhost.pem TLS_KEY_PATH=./localhost-key.pem

SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET=

TOKEN_ENCRYPTION_KEY=

SLACK_CHANNEL_ID=C0XXXXXXXXX GITHUB_REPO=your-name/your-test-repo

code

这些客户端密钥用于向提供方验证你的应用程序。它们不是用户凭证,也绝不能出现在浏览器中。

## 如何运行授权流程

所有与提供方相关的代码应集中在一个地方,这样以后添加第三个提供方时只需添加条目,而不需要添加分支。

### 第一步:一次性描述每个提供方

const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI;

export const providers = { slack: { label: 'Slack', authorizeUrl: 'https://slack.com/oauth/v2/authorize', tokenUrl: 'https://slack.com/api/oauth.v2.access',

// 这些参数应放在 user_scope 中,而不是 scopescope 中列出的权限会授予 // 机器人令牌,而本项目正是为避免这种令牌而存在。 userScopes: ['channels:history', 'chat:write', 'users:read'],

buildAuthorizeUrl(state) { const url = new URL(this.authorizeUrl); url.searchParams.set('client_id', process.env.SLACK_CLIENT_ID); url.searchParams.set('user_scope', this.userScopes.join(',')); url.searchParams.set('redirect_uri', REDIRECT_URI); url.searchParams.set('state', state); return url.toString(); }, // exchangeCode 和 refresh 的实现见下文 }, };

code

user_scope 参数是一行完整的参数。Slack 用 scope 参数读取机器人权限,用 user_scope 参数读取用户权限。本项目只设置后者,因此响应中完全不会包含机器人令牌。

state 参数不是可选参数。它是你生成的随机字符串,发送给提供方,并在回调时验证。没有它,互联网上的任何页面都可以将浏览器指向你的回调 URL,并附加攻击者的代码,你的服务器会愉快地交换并存储攻击者的令牌到你的用户标识下。

### 第二步:交换代码并获取正确的令牌

async exchangeCode(code) { const response = await fetch(this.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ code, client_id: process.env.SLACK_CLIENT_ID, client_secret: process.env.SLACK_CLIENT_SECRET, redirect_uri: REDIRECT_URI, }), });

const json = await response.json();

// Slack 即使在交换失败时也会返回 HTTP 200。ok 字段 // 才是真正的状态。 if (!json.ok) { throw new Error(Slack 令牌交换失败: ${json.error}); }

return normalizeSlackTokens(json.authed_user); }

code

这个函数中两个细节在遗漏时会导致实际的调试时间损耗。

Slack 在失败时返回 HTTP 200。检查 response.ok 可以判断 HTTP 请求是否成功,确实如此。json.ok 字段才是报告 OAuth 交换是否成功的字段。

使用 json.authed_user 而非 json。这是与上方部分的分叉,以单个属性访问的形式表达。在此处读取 json.access_token 会通过编译、运行、存储令牌,但会悄无声息地让代理的所有用户使用相同的机器人身份。

规范化结果使代码库的其余部分与提供商无关:

function normalizeSlackTokens(authedUser) { return { accessToken: authedUser.access_token, refreshToken: authedUser.refresh_token ?? null, expiresAt: authedUser.expires_in ? Date.now() + authedUser.expires_in * 1000 : null, scope: authedUser.scope, }; }

code

GitHub 的相同功能版本有两点值得注意的差异:

async exchangeCode(code) { const response = await fetch(this.tokenUrl, { method: 'POST', // 缺少此请求头时,GitHub 会返回表单编码的响应体 headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: new URLSearchParams({ code, client_id: process.env.GITHUB_CLIENT_ID, client_secret: process.env.GITHUB_CLIENT_SECRET, redirect_uri: REDIRECT_URI, }), });

const json = await response.json(); if (json.error) { throw new Error( GitHub 令牌交换失败: ${json.error_description ?? json.error} ); }

// OAuth 应用令牌没有过期时间,因此无需刷新 return { accessToken: json.access_token, refreshToken: null, expiresAt: null, scope: json.scope, }; }

code

Accept: application/json 请求头很容易被忽略,导致令人困惑的失败:当响应体返回 access_token=gho_...&scope=repo 时,response.json() 会抛出异常。

### 步骤 3:捕获重定向

OAuth 需要一个落地点。对于命令行工具,启动一个服务器处理每个提供商的一个回调后退出即可。由于 Slack 要求 HTTPS,OAUTH_REDIRECT_URI 中的协议决定了启动哪种服务器:

function createCallbackServer(handler) { if (redirect.protocol !== 'https:') { return createHttpServer(handler); }

try { return createHttpsServer( { cert: readFileSync(process.env.TLS_CERT_PATH), key: readFileSync(process.env.TLS_KEY_PATH), }, handler ); } catch (err) { throw new Error( 无法读取 TLS 证书 (${err.code ?? err.message}).\n + '使用 mkcert 生成本地信任的证书:\n' + ' mkcert -install\n' + ' mkcert localhost\n' + '然后将 TLS_CERT_PATH 和 TLS_KEY_PATH 指向它生成的两个文件。' ); } }

code

缺少证书的情况迟早会发生,而 ENOENT 错误本身无法说明 OAuth 的问题。catch 块用四行说明了应该运行的替代方案。

处理程序本身是验证状态的地方:

const pending = new Map();

function handleCallback(request, response) { const url = new URL(request.url, redirect.origin);

if (url.pathname !== redirect.pathname) { response.writeHead(404).end('未找到'); return; }

const state = url.searchParams.get('state'); const entry = pending.get(state);

if (!entry) { response.writeHead(400).end('状态不匹配。重新开始流程。'); return; }

pending.delete(state);

code

const error = url.searchParams.get('error'); if (error) { response.writeHead(400).end(Authorization denied: ${error}); entry.reject(new Error([${entry.provider}] authorization denied: ${error})); return; }

entry.finish(url.searchParams.get('code'), response); }

code

待处理映射表用于状态检查。只有当该流程生成状态值时,才会将其写入该映射表,且状态值在被使用时会立即被删除。未识别的状态意味着回调不是来自你启动的流程,而重复出现的状态意味着重放攻击。这两种情况都可以通过一次Map查找发现。

生成状态并等待回调:

function connect(providerName) { const provider = providers[providerName]; const state = randomBytes(16).toString('hex');

console.log(\n[${providerName}] authorize as "${IDENTIFIER}":); console.log(provider.buildAuthorizeUrl(state));

return new Promise((resolve, reject) => { pending.set(state, { provider: providerName, reject, async finish(code, response) { const tokens = await provider.exchangeCode(code); saveGrant(IDENTIFIER, providerName, tokens); response .writeHead(200, { 'Content-Type': 'text/html' }) .end(<p>${provider.label} connected. You can close this tab.</p>); resolve(); }, }); }); }

code

使用randomBytes(16)而不是Math.random()。可预测的状态参数等同于没有状态参数。

运行时会依次处理每个未连接的提供商:

[slack] authorize as "[email protected]": https://slack.com/oauth/v2/authorize?client_id=123.456&user_scope=channels%3Ahistory%2Cchat%3Awrite%2Cusers%3Aread&redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&state=1159699dbf1a808fd33ba31c7b643505

code

注意这个URL中没有包含任何scope参数。Slack没有指示生成机器人令牌的指令,因此不会生成。

## 如何加密存储以用户为键的令牌

存储需要解决一个问题:对于某个用户和提供商,哪个令牌属于他?所有其他问题都源于如何安全地保持这个答案。

node:sqlite自Node v22.5版本起已内置,从v22.13版本起不再需要标志参数。这使得无需安装即可使用真正的数据库:

import { DatabaseSync } from 'node:sqlite'; import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

const KEY = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY ?? '', 'base64');

if (KEY.length !== 32) { throw new Error( 'TOKEN_ENCRYPTION_KEY must be 32 bytes, base64-encoded. ' + Got ${KEY.length} bytes. ); }

const db = new DatabaseSync( process.env.TOKEN_DB_PATH ?? new URL('../tokens.db', import.meta.url).pathname );

// 每个用户和提供商对应一行。expires_at存储在密文之外,这样可以在不解密的情况下检查令牌的新鲜度。 db.exec( CREATE TABLE IF NOT EXISTS grants ( identifier TEXT NOT NULL, provider TEXT NOT NULL, ciphertext BLOB NOT NULL, iv BLOB NOT NULL, auth_tag BLOB NOT NULL, expires_at INTEGER, PRIMARY KEY (identifier, provider) ) );

code

复合主键是隔离保证的书面体现。(identifier, provider)意味着Alice的Slack记录和Bob的Slack记录不会冲突,任何同时指定这两个字段的查询都不会返回其他人的授权记录。

expires_at 故意放在密文之外。在每次调用之前检查令牌是否需要刷新是必要的。如果需要解密才能判断是否需要刷新,意味着需要频繁解密,因此唯一不涉及机密信息的字段保持可读状态。

加密使用的是 AES-256-GCM,它同时提供认证和加密功能:

function encrypt(payload) { const iv = randomBytes(12); const cipher = createCipheriv('aes-256-gcm', KEY, iv); const ciphertext = Buffer.concat([ cipher.update(JSON.stringify(payload), 'utf8'), cipher.final(), ]); return { ciphertext, iv, authTag: cipher.getAuthTag() }; }

function decrypt({ ciphertext, iv, authTag }) { const decipher = createDecipheriv('aes-256-gcm', KEY, iv); decipher.setAuthTag(authTag); const plaintext = Buffer.concat([ decipher.update(ciphertext), decipher.final(), ]); return JSON.parse(plaintext.toString('utf8')); }

code

这对加密方案有三条规则,违反其中任何一条的后果比完全不加密更严重,因为看起来似乎仍然有效:

- 每次加密使用新的 IV:在 GCM 中重复使用初始化向量是灾难性的错误,而非小错误。每次调用都必须通过 randomBytes(12) 生成新 IV,并与密文一起存储。

- 保留认证标签:GCM 生成的标签可证明密文未被篡改。如果在解密时未调用 setAuthTag,将导致加密数据缺乏完整性保护,而 decipher.final() 不会报错。

- 加密整个令牌对象而非单独字段。对 { accessToken, refreshToken, scope } 整体加密意味着只需管理一个 IV 和一个标签,而不是每个字段都需要单独处理。

读写操作因此变得简单直接:

export function saveGrant(identifier, provider, tokens) { const { ciphertext, iv, authTag } = encrypt(tokens); db.prepare( INSERT INTO grants (identifier, provider, ciphertext, iv, auth_tag, expires_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (identifier, provider) DO UPDATE SET ciphertext = excluded.ciphertext, iv = excluded.iv, auth_tag = excluded.auth_tag, expires_at = excluded.expires_at ).run(identifier, provider, ciphertext, iv, authTag, tokens.expiresAt ?? null); }

code

ON CONFLICT 子句的重要性远超其表面看起来的样子。重新授权必须替换现有授权而非失败或产生重复记录,而重新授权正是用户在撤销授权或更改权限范围后会执行的操作。

加密密钥本身在此处存储于 .env 文件中,这在教程中是合适的,但在生产环境中应使用密钥管理服务(如 KMS)存储。如果密钥丢失,所有存储的授权都将无法读取,并迫使所有用户重新授权。这会导致真正的服务中断,但相比另一种情况(数据库文件被盗导致攻击者获取所有用户的有效令牌)要好得多。

## 如何以当前用户身份运行工具调用

运行时有三个步骤:解析标识符、使用该标识符获取令牌,并将整个过程包装为工具。

### 第一步:解析标识符,然后进行授权

标识符可以是任何能稳定代表一个用户的字符串,如电子邮件地址、用户 ID 或租户作用域的密钥。

// 在真实应用中,该标识符应来自已认证的会话,并在服务器端解析 // 永远不要从客户端输入中接受该标识符 const IDENTIFIER = process.argv[2] ?? 'channel-watcher-agent';

code

从命令行参数读取标识符可以让演示无需登录即可运行,并使本教程后续的隔离测试只需一条命令即可完成。真实应用应替换该行代码:

// 实际应用:从已认证的会话中解析,服务端执行 const session = await getSession(request); // 你的认证逻辑 const identifier = await lookupIdentifier(session.userId); // 你的数据库查询

code

这两行代码的顺序至关重要。应先验证调用者的身份,再查找该调用者可代表的标识符。若标识符来自客户端,该端点将变为任何用户Slack数据的读取者。

### 步骤 2:延迟将标识符转换为令牌

标识符与所有提供商调用之间仅隔一个函数:

const REFRESH_WINDOW_MS = 60_000;

export async function getAccessToken(identifier, providerName) { const grant = readGrant(identifier, providerName);

if (!grant) { throw new Error( [${providerName}] "${identifier}" 没有授权记录。\n + 请先连接:node src/connect.js ${identifier} ); }

const expiringSoon = grant.expiresAt !== null && grant.expiresAt !== undefined && grant.expiresAt - Date.now() < REFRESH_WINDOW_MS;

if (!expiringSoon) { return grant.accessToken; }

if (!grant.refreshToken) { throw new Error( [${providerName}] "${identifier}" 的令牌已过期且没有刷新令牌。\n + '用户需要重新授权。' ); }

const refreshed = await providers[providerName].refresh(grant.refreshToken); saveGrant(identifier, providerName, refreshed); return refreshed.accessToken; }

code

请在API调用前立即调用此函数,而非启动时一次性调用。长时间运行的代理可能超出12小时令牌的有效期,提前解析令牌可能在最不方便的时刻发现问题。延迟获取仅需一次廉价的数据库读取,即可消除整个问题类别。

此外,60秒窗口并非单纯的缓冲时间。剩余4秒的令牌可能通过简单过期检查,却在传输过程中失效。在窗口内刷新可确保返回的令牌至少能支持一分钟的操作。

最后,缺失授权记录会抛出错误而非降级处理。没有合理的降级方案可选。未连接用户的正确处理方式是停止执行,并提示连接方法。

### 步骤 3:将提供商调用封装为工具

身份注入在此处完成,位于模型可影响的任何内容之下一层:

export function buildTools(identifier) { const [owner, repo] = process.env.GITHUB_REPO.split('/');

const fileGithubIssue = tool({ description: '为可操作的Slack消息创建GitHub问题', inputSchema: z.object({

body: z.string(), }), execute: async ({ title, body }) => { const token = await getAccessToken(identifier, 'github'); return createIssue(token, owner, repo, { title, body }); }, });

const replyInSlackThread = tool({ description: '在原始Slack线程中回复(例如添加创建的问题链接)', inputSchema: z.object({ text: z.string(), thread_ts: z.string(), }), execute: async ({ text, thread_ts }) => { const token = await getAccessToken(identifier, 'slack'); return postThreadReply( token, process.env.SLACK_CHANNEL_ID, text, thread_ts ); }, });

return { fileGithubIssue, replyInSlackThread }; }

code

对比模型可控制的内容与不可控制的内容。模型可以决定标题、正文和文本内容,但无法决定用户身份。标识符是闭包参数,在模型运行前就已经确定,且不会出现在输入模式(inputSchema)中。没有任何输入能让模型将问题文件归因于其他用户,因为账户信息并非其输入参数之一。

每个返回值都值得单独审查一次。createIssue 返回问题编号、URL 和标题。postThreadReply 返回时间戳。两者都不会返回令牌,也不会返回原始提供方的响应内容,而令牌若存在,通常会隐藏在原始提供方的响应中。

提供方的调用本身是普通的 HTTP 请求:

code
export async function createIssue(token, owner, repo, { title, body }) {
  const response = await fetch(
    `https://api.github.com/repos/${owner}/${repo}/issues`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: 'application/vnd.github+json',
        'X-GitHub-Api-Version': '2022-11-28',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ title, body }),
    }
  );

  const json = await response.json();

  if (!response.ok) {
    // 403 here usually means the grant is missing the `repo` scope.
    throw new Error(
      `GitHub issue creation failed (${response.status}): ${json.message}`
    );
  }

  return { number: json.number, url: json.html_url, title: json.title };
}

第 4 步:读取频道

Slack 的 conversations.history 返回干净的 JSON 数据,但有一个缺口:消息中携带的是用户 ID,而非显示名称。将这些 ID 转换为名称需要对每个唯一作者执行一次 users.info 调用,这也是为什么 users:read 权限在作用域列表中。

code
export async function readChannel(token, channelId, limit = 20) {
  const { messages } = await slackCall(token, 'conversations.history', {
    channel: channelId,
    limit: String(limit),
  });

  const authors = await resolveAuthors(
    token,
    messages.filter((m) => m.user).map((m) => m.user)
  );

  return messages
    .filter((message) => message.text)
    .map((message) => ({
      author: authors.get(message.user) ?? 'unknown',
      userId: message.user,
      text: message.text,
      ts: message.ts,
    }))
    .reverse(); // oldest first
}

该函数中包含三个小决策:

  • 名称获取需要为每个唯一作者执行一次 users.info 调用。按每次运行缓存这些名称,可以防止一个频道中全是某人消息时产生二十次重复查询。查询失败时会回退到用户 ID 而非抛出错误,因为无法解析的名称不应成为终止运行的理由。
  • 没有文本的消息会被丢弃。频道加入和目的变更会以没有正文的消息对象形式出现,模型对此类消息无法进行任何处理。
  • .reverse() 并非只是装饰。Slack 默认按最新消息排序。模型若反向读取对话,可能会误解哪条消息是回复哪条消息的。

ts 字段随后承担双重职责。它既用于标识消息,也作为回复的线程锚点,同时还是记录代理已处理内容的关键:

code
const state = await loadState();
const processed = new Set(state[IDENTIFIER]?.processedTs ?? []);
const newMessages = messages.filter((m) => !processed.has(m.ts));

如代码片段所示,应根据标识符来键控该状态。使用单一扁平列表可能导致一个用户的已处理消息掩盖另一个用户的已处理消息,这会重新引入跨用户数据泄露的问题,而整个设计的初衷正是为了防止这种情况。

向模型提供两种工具并让其自行决定:

code
const { text } = await generateText({
  model: anthropic(process.env.MODEL),
  tools,
  stopWhen: stepCountIs(5),
  prompt: `你负责对开发团队的Slack频道消息进行分类处理。

来自${message.author}的消息:"${message.text}"
消息时间戳(线程_ts):${message.ts}

判断这条消息是否需要处理(如错误报告或具体行动项)还是仅属于噪音(闲聊、加入通知、已解决的讨论)。

如果需要处理:根据消息内容创建一个GitHub问题,包含清晰的标题和正文,然后在原始Slack线程中回复(使用上面精确的thread_ts),附上简短说明和创建的问题URL。

如果不需要处理:无需任何操作,并简要说明原因。`,
});

循环结构使得第二步成为可能。模型读取消息后可能会调用fileGithubIssue工具。AI SDK执行该工具,将结果与新生成的问题URL一并反馈到上下文中,然后再次调用模型。现在模型可以在线程中回复一个它在首次处理时无法预知的URL。之后流程停止。

stopWhen: stepCountIs(5)对循环次数进行了限制。如果没有限制,困惑的模型可能会无限次重试失败的工具。对于两个工具来说,五轮循环已经非常宽裕。

一个确定性的版本同样合理:使用结构化输出进行分类,然后在消息符合条件时按固定顺序自行调用两个工具。

固定顺序更容易测试但牺牲了灵活性。循环结构允许模型跳过回复,或仅创建问题而不回复,添加第三个工具也不需要新增分支逻辑。当操作集根据输入变化时选择循环结构,当操作集始终固定时选择固定顺序。

关于提供方行的说明,为了准确说明实际执行情况。上面的代码片段使用了@ai-sdk/anthropic,这适用于直接使用Anthropic API密钥的情况。我的测试是通过OpenAI兼容网关进行的,这仅改变了提供方构造方式:

code
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';

const gateway = createOpenAICompatible({
  name: 'gateway',
  baseURL: `${process.env.GATEWAY_BASE_URL}/v1`,
  apiKey: process.env.GATEWAY_API_KEY,
});
// 然后:model: gateway(process.env.MODEL)

无论哪种方式,工具、循环和令牌处理都完全相同,仅模型参数有所不同。

如何处理刷新和撤销

令牌以两种不同方式结束,其中只有一种情况需要你的代码处理 过期是常规且可恢复的。撤销是某人做出的决定,正确响应是再次请求授权。

本教程中的两个提供方分别位于范围的两端,这使它们成为有用的对比组合。

GitHub:不会过期(直到被撤销)的令牌

OAuth应用用户令牌没有过期时间戳。没有需要存储的刷新令牌,也没有需要调用的刷新接口,这就是为什么本项目中的github.refresh()仅执行自我解释操作:

code
async refresh() {
  throw new Error(
    'GitHub OAuth应用令牌不会过期。此处出现错误意味着授权已被撤销 —— 请让用户重新通过授权流程。'
  );
}

"不会过期"并不等同于"永久有效",GitHub会因以下原因撤销令牌,这些原因值得关注:

  • 用户从其账户设置中撤销了授权。
  • 令牌一年内未被使用。
  • 令牌被推送到公共仓库或代码片段中,此时GitHub会自动撤销该令牌。
  • 当应用为同一用户和作用域组合累积超过十个令牌时,最旧的令牌将被撤销。

第三个问题值得特别关注。GitHub 会扫描公共推送中的自定义令牌格式并将其销毁。这是一种安全防护机制,而非策略,但它无法保护私有仓库或日志文件中的令牌。

GitHub Apps 与 OAuth Apps 的行为存在差异,这是阅读 GitHub 文档时常见的混淆点。GitHub App 的用户访问令牌在八小时后过期,并附带一个有效期为六个月的刷新令牌。如果你选择基于 GitHub Apps 构建,那么下面这种 Slack 风格的刷新路径才是你需要的。

Slack:令牌轮换是可选且永久生效的

默认情况下,Slack 用户令牌也不会过期。启用令牌轮换会改变这一行为,且需要重复强调一个警告:一旦开启轮换功能,就无法再关闭。建议先在测试应用中启用。

启用轮换后,令牌有效期为十二小时,并会附带刷新令牌。刷新请求会复用初始交换的相同端点,但使用不同的授权类型:

code
async refresh(refreshToken) {
  const response = await fetch(this.tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: process.env.SLACK_CLIENT_ID,
      client_secret: process.env.SLACK_CLIENT_SECRET,
    }),
  });

  const json = await response.json();
  if (!json.ok) {
    throw new Error(`Slack token refresh failed: ${json.error}`);
  }

  return normalizeSlackTokens(json.authed_user ?? json);
}

务必存储新的刷新令牌,而不仅仅是新的访问令牌。刷新令牌也会轮换。如果只更新访问令牌而不更新刷新令牌,十二小时后你会持有已失效的刷新令牌,此时才得知问题已为时过晚。

这就是为什么 getAccessToken 在刷新后会调用 saveGrant 保存授权信息,而不是直接返回令牌继续执行。

将失效授权视为正常状态

被撤销的授权在异常处理中不应被视为例外。用户离职、管理员收紧作用域、用户改变对代理权限的授权意愿等情况都会导致授权失效。

有效的处理方式是 getAccessToken 已经采用的方案:捕获失败并展示新的授权链接,而非堆栈跟踪。使用相同标识符的 connect.js 允许用户重新授权,ON CONFLICT 会覆盖失效记录,用户记录中的其他信息不会发生变化。

如何添加第二个身份提供者

添加第二个提供者需要一个 OAuth 应用、providers 对象中的一个条目以及一个工具。将身份信息保留在单一字符串中正是获得折扣的原因。

代理一直使用了两个提供者。值得注意的是,第二个提供者不需要额外的开销:无需第二个身份、无需第二个授权服务器、无需第二个令牌表。

code
export const providers = {
  slack: { /* ... */ },
  github: { /* ... */ },
};

将 Google Calendar 作为第三个提供者意味着需要添加第三个条目,包含其专属的 authorizeUrl、tokenUrl、scopes 和 exchangeCode。授权服务器会遍历 Object.keys(providers),因此无需修改即可识别新条目。存储系统已基于 (identifier, provider) 进行键值设计,因此无需迁移。最后再添加一个工具:

code
const createCalendarEvent = tool({
  description: '创建日历事件',
  inputSchema: z.object({ summary: z.string(), start: z.string() }),
  execute: async ({ summary, start }) => {
    const token = await getAccessToken(identifier, 'google-calendar');
    // ...另一个提供者调用
  },
});

标识符不会改变,用户的表结构也不会改变,模型对世界的认知只会增加一个工具。

无法规模扩展的成本是特定于提供者的知识。每个新提供者都会带来自己的作用域词汇、自己的错误格式以及自己对令牌是否过期的回答。Slack 和 GitHub 在这三个方面意见不一致,第三个提供者可能会以不同的方式产生分歧。注册表模式将每个提供者特定的知识集中在一个对象中,而不是分散在代理中,但这并不意味着这些知识变得不必要。

关于授权的一个注意事项:授权是按用户和提供者分别进行的。Alice 连接了 Slack 但没有连接日历,意味着她的日历工具调用会失败,这种失败是正确的,因为她从未授权过。应将此视为一个连接提示,而非错误,故障模式部分会再次提到这一点。

完整操作指南

克隆仓库、安装依赖并填写 .env 文件:

code
git clone https://github.com/saif-shines/channel-watcher-agent.git
cd channel-watcher-agent
npm install
cp .env.example .env
# 填写两个客户端 ID 和密钥、加密密钥、频道 ID、仓库

然后进行连接。该命令启动回调服务器并为每个未连接的提供者打印一个链接:

code
npm run connect
code
[slack] 以 "channel-watcher-agent" 身份授权:
https://slack.com/oauth/v2/authorize?client_id=123.456&user_scope=channels%3Ahistory%2Cchat%3Awrite%2Cusers%3Aread&redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&state=1159699dbf1a808fd33ba31c7b643505
[slack] 已连接。

[github] 以 "channel-watcher-agent" 身份授权:
https://github.com/login/oauth/authorize?client_id=Iv1.abc&scope=repo&redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&state=e6d13461099c391367266235f8313630
[github] 已连接。

所有提供者已连接。运行:node src/index.js channel-watcher-agent

打开每个链接,授权后标签页会确认连接。这些 URL 中的 state 参数在回调时会被验证。携带其他内容的回调会收到 400 错误,且不会到达令牌交换阶段。

然后针对包含普通闲聊的频道运行代理:

code
node src/index.js

针对包含三条普通消息的频道运行结果:

code
[channel-watcher-agent] 已获取 3 条消息,其中 3 条为新消息。

--- Alex: "发送草稿消息" ---
该消息 "发送草稿消息" 是噪音——看起来像是测试或误操作发送,而非错误报告或具体行动项。

...

未调用任何工具,也未提交任何问题。负面案例的重要性比看起来更高。一个拥有写入权限但无法拒绝的代理会带来风险,而对普通闲聊的运行则是检验其克制能力的最经济测试。

现在在频道中发布一个实际的错误报告:

嘿,/export 端点在处理任何超过 50MB 的文件时都会超时,这种情况从昨天的部署开始就发生了

再次运行代理,状态文件会保留之前消息的处理记录,避免重复处理。

归属权是关键所在。标识符背后的 GitHub 账户通过该用户的 OAuth 授权发起请求,而非共享机器人。Slack 的回复也来自该用户。撤销其访问权限后,下一次运行会在 getAccessToken 阶段失败,这是正确的结果。

第二个用户会发生什么变化

标识符来自命令行,因此可以在不先构建登录界面的情况下测试隔离性:

code
node src/index.js                     # 你已授权的标识符
node src/index.js [email protected]   # 完全不同的用户

第二个命令不会读取频道。它会停止:

code
[slack] "[email protected]" 没有授权。
请先连接:node src/connect.js [email protected]

拒绝访问正是设计初衷。两次命令之间代理程序没有任何变化。使用相同的提供商、工具和代码,唯一不同的是标识符,而 Alice 尚未同意授权,因此没有可解密的记录,运行会在接触 Slack 之前停止。

共享机器人版本的行为会不同。第二个命令会读取频道并以机器人身份提交问题,因为从未涉及过用户专属授权。

一旦 Alice 同意授权,后续所有操作都会遵循她的授权。readGrant 会返回她的记录。getAccessToken 会解密她的令牌。Slack 读取会返回她可见的频道,而她的 GitHub 账户会创建问题。

生产环境会用会话查询替代 argv

code
const identifier = await lookupIdentifier(session.userId);

在没有凭证的情况下测试隔离性

该仓库包含一个测试套件,用模拟的 Slack 和 GitHub 接口替代 fetch,因此无需注册任何 OAuth 应用即可运行请求构建、响应解析、存储和刷新逻辑:

code
npm test

其中三个测试尤其值得关注,因为它们验证了本教程的主张,而非代码内部实现:

  • Slack 授权交换保留用户令牌并丢弃机器人令牌。测试用例返回两者,但测试断言存储的值应为 xoxp- 开头的令牌。
  • 两个用户使用相同的工具输入会获得两个不同的令牌。相同文本、相同 thread_ts、两个标识符,最终发送到提供商的两个不同 Authorization 请求头。
  • 为未连接用户构建的工具会失败而非回退。测试还断言未尝试任何提供商调用,因为失败后泄露请求的失败并不算真正的失败。

首次运行通过的测试反而值得怀疑,因此我故意破坏代码进行验证。将机器人令牌替换为用户令牌、在 buildTools 中忽略标识符、在模型可见的模式中暴露标识符,这些修改都会导致至少一个测试失败。

如何将该模式应用于其他使用场景

该模式本身与 Slack 问题分类无关。其结构是:从一个应用读取数据,用模型做决策,以另一个应用写入数据,所有操作都以单一用户身份进行。

更换提供商会产生不同的产品:

| 读取来源 | 写入目标 | 结果 | |---------|---------|-----| | Slack | GitHub | 将频道讨论转化为问题(如本教程所示) | | Gmail | Linear | 将支持邮件转化为可追踪任务 | | Google Calendar | Notion | 会议前准备笔记 | | Zendesk | Salesforce | 将支持信号记录到对应账户 |

每条记录都使用相同的三要素:提供商条目、通过标识符查找的令牌,以及工具。唯一变化的是三个部分:OAuth 应用注册、execute 中的 API 调用,以及为模型编写的输入内容。

输入是你的产品所处的位置。OAuth是基础设施。决定哪些消息值得创建问题,以及问题应该包含什么内容,这需要判断力,而判断力正是值得你投入数周时间的部分。

相同的代码支持两种部署形态:

  • 内部团队代理:标识符是触发运行的队友。按计划或命令运行。
  • 面向客户的代理:标识符来自你的租户和用户记录。在客户数据和客户账户内运行。

代码保持完全一致。错误标识符带来的后果则不同。

当我构建这个项目时出现的错误

这些问题是在构建项目时遇到的,大致按照出现顺序排列。如果你遇到相同的问题,通常修复方法很简单。

Slack 不会保存重定向 URL

在任何代码运行之前就会出现症状:Slack 应用配置页面拒绝接受 http://localhost:3000/callback。

Slack 要求重定向 URL 使用 HTTPS,对 localhost 没有例外。使用 mkcert 生成本地证书,注册 https:// 格式,将 TLS_CERT_PATH 和 TLS_KEY_PATH 指向生成的文件。GitHub 接受两种协议,因此相同的 HTTPS URL 适用于两个应用。

浏览器警告证书不受信任

mkcert -install 是将 mkcert 的本地证书颁发机构添加到系统信任存储的步骤,跳过此步骤会导致浏览器无法识别证书。

运行一次即可解决所有 mkcert 证书问题。使用 openssl 生成的自签名证书始终会发出警告,因为没有任何东西信任它。

重定向 URI 不匹配

两个提供商都会将你发送的 redirect_uri 与应用注册的 URI 进行精确比较。尾随斜杠、使用 127.0.0.1 替代 localhost、注册时使用 HTTPS 而实际使用 HTTP 或端口不一致都会导致失败。

错误出现在授权之前,显示在提供商自己的页面上,至少让问题容易被发现。将 OAUTH_REDIRECT_URI 作为单一来源,同时在授权 URL 和令牌交换中传递,就像提供商注册表那样处理。

回调端口已被占用

connect.js 会绑定 OAUTH_REDIRECT_URI 中的端口,而 3000 端口很常用。未处理的 EADDRINUSE 错误会产生与 OAuth 无关的堆栈跟踪,因此项目会捕获此错误并提示解决方法。

更改端口需要在三个地方修改:.env 文件、Slack 应用的重定向 URL,以及 GitHub 应用的回调 URL。漏掉任何一个都会导致前面的失败。

状态检查拒绝了合法的回调

状态值存储在内存中,使用后会被删除。在打开链接后重启 connect.js,或刷新回调标签页,都会导致状态不再存在于映射中。

这两种情况都是正确的拒绝。生成新的链接并重新开始即可。

工具调用返回权限错误或空结果

缺少权限范围或授权被撤销都会导致此类问题。

当授权缺少 repo 权限时,GitHub 会返回 403 错误并提示“Resource not accessible”;Slack 会返回 200 但包含 ok: false 和类似 missing_scope 的错误。修复权限范围列表后,需要再次引导用户通过授权,因为现有授权不会自动获得新权限。

部分权限的授权会在使用时而非连接时失败,这正是症状显得神秘的原因。授权页面已成功,令牌也已正确存储,但失败却会在数小时后的工具调用时发生。

代理读取了不应访问的频道

最可能的原因是在 Slack 交换过程中存储了 json.access_token 而非 json.authed_user.access_token。两者都是字符串,都为真值且都能工作(其中一个以应用身份而非个人身份工作)。

关键在于返回结果的范围。用户令牌仅返回该人员的频道。如果 conversations.history 返回了一个当前用户从未加入过的频道,则说明存储的是机器人令牌。

工具调用以错误用户身份运行

传递属于其他人的令牌或标识符会以正确的方式做错误的事。

两个习惯可以防止这种情况。在验证调用者后,应在服务器端解析标识符,而非从客户端输入获取。然后在 buildTools 中将标识符作为闭包参数传递,让每个执行自行获取自己的令牌,这样任何代码路径都无法传递多余凭证。

刷新功能仅执行一次后停止

刷新令牌会轮换。如果刷新操作写回了新的访问令牌但保留了旧的刷新令牌,会立即成功但下一次循环会失败,这会在错误和症状之间产生12小时的间隔。

saveGrant 因此需要接收整个标准化的令牌对象。写回刷新操作返回的所有内容。

代理文件重复提交问题

有两个原因。缺失或未写入的状态文件会导致每次运行都重新处理所有内容。或者 stopWhen 允许足够多的轮次,让困惑的模型重试一个已经成功的工具。

首先检查状态文件。然后检查工具的返回值是否明确表明成功,因为模糊的结果会引发重试。

结论

你已构建了一个代理,它读取 Slack 频道,判断哪些消息描述了实际工作,为这些消息创建 GitHub 问题,并通过线程回复完成闭环。每次调用都以特定用户的 OAuth 授权凭证身份运行,通过你自行编写的 OAuth 流程和令牌存储实现。

以下五个理念适用于任何服务提供商:

  • 工具是 API 调用加上对模型的解释,而解释是主要工作内容。
  • 标识符在你的代理代码中取代了令牌。除一个小型函数外的所有内容都处理对用户的引用而非凭证,因此令牌永远不会出现在模型输入或日志中。
  • 连接时间和运行时间是独立的流程。每个用户和应用只需授权一次。运行时会解析标识符并在后期获取令牌。
  • 授权权归你所有。令牌存储决定哪些令牌属于某个标识符。调用者是否可以以该标识符身份行动,只有你的代码才能回答。
  • 一旦身份信息存储在一个字符串中,多服务提供商支持是注册问题而非架构问题。

最具决定性意义的细节也是最小的:使用 authed_user.access_token 而非 access_token。一次属性访问决定了你的代理是尊重工作区已有的权限,还是悄悄绕过它们。

从这里开始,保持结构不变,只需替换服务提供商。将读取部分指向 Gmail,将写入部分指向 Linear,然后重写模型的输入。身份管道不需要改变。

完整源代码位于 github.com/saif-shines/channel-watcher-agent。

本文重建了我们在构建 Scalekit(你刚刚构建的令牌保险库的托管版本)时学到的内容。

阅读更多文章。

如果这篇文章对你有帮助,请分享。

免费学习编程。freeCodeCamp 的开源课程已帮助超过40,000人成为开发者。立即开始

ADVERTISEMENT