当你的AI智能体服务不止一个人时,每次工具调用都必须回答:这个智能体在代表谁行事?让我们通过构建一个连接Slack和GitHub的AI智能体来学习如何解决这个问题。
一次Slack读取使用该用户的工作区。GitHub issue以该用户的身份在其可访问的仓库中创建。智能体可能做出错误的判断,但绝不能用错误用户的权限行事。
解决方案有两个部分,两者都出现在本教程的前半部分:
每个用户分别授予访问权限。Alice为自己授权Slack。Bob为自己授权Slack。
你的智能体传递一个标识符,而不是令牌。像alice@example.com这样的字符串选择使用谁的授权。一个函数在调用时将其转换为令牌,该令牌永远不会到达你的模型输入、工具模式或日志。
大多数智能体教程在这两点之前就止步了。它们给你一个API密钥,接好一个函数,然后模型调用它。这种设计在第二个人出现之前都能正常工作。
为了让这个模式更加具体,你将构建一个命令行智能体,它监视一个Slack频道,自行判断哪些消息描述了实际工作,为这些消息创建GitHub issue,并在Slack线程中回复issue链接。每次调用都以用户自己的OAuth授权运行。
你将自己编写OAuth流程:同意重定向、state检查、令牌交换、加密存储和刷新路径。这些都不长,而且看到完整流程才能让身份论证可被检验,而不是你凭信念接受的主张。
这里有两个主题不在讨论范围内:我们不会涉及Model Context Protocol服务器或语音/实时主机。身份模式在两种环境中都适用,但周围的配套机制值得单独写一篇文章。
这个智能体叫做channel-watcher-agent。每次运行做四件事:
读取Slack频道中的最近消息。
逐条消息询问模型,文本描述的是bug还是具体的行动项。
为符合条件的消息创建GitHub issue。
在原始Slack线程中回复新issue的链接。
没有人点击按钮来启动这些操作。 Slack已经自带了一个“从这条消息创建issue”的功能,那是另一个产品。在这里,智能体读取频道,形成自己的判断,并且只对它认为值得处理的内容采取行动。
技术栈刻意保持精简:
| 组件 | 作用 |
|---|---|
| Node.js,纯ES模块 | 无Web框架,无队列 |
node:http |
OAuth回调服务器 |
node:crypto |
令牌加密 |
node:sqlite |
令牌存储,无需安装依赖 |
| Vercel AI SDK | 模型调用和工具循环 |
这五项中有三项随Node内置。你只需要安装AI SDK及其相关包。
到最后你将拥有:
两个OAuth应用,Slack和GitHub,用户只需同意一次。
按用户和提供商键控的加密令牌存储。
一个将当前用户解析为标识符并且绝不让令牌到达模型的智能体。
一个工具循环,模型在其中决定是否要创建issue。
一个演示,表明第二个用户的运行会停止,而不是读取第一个用户的数据。
完整的代码位于github.com/saif-shines/channel-watcher-agent。
账户和工具:
Node.js 22.13或更新版本,以及npm。令牌存储使用node:sqlite,从该版本开始它是稳定的。
一个Slack工作区,你可以在其中安装应用,以及一个要监视的频道。临时频道效果最好。
一个GitHub账户和一个可以接收测试issue的仓库。
AI SDK支持的模型提供商的API密钥。示例中使用的是Anthropic。
mkcert,用于颁发本地HTTPS证书。如何注册Slack和GitHub OAuth应用解释了为什么普通的http://localhost回调不行。
有用的背景知识,虽然不是硬性要求:
async和await,以及阅读小型Node脚本。
OAuth 2.0的高层概念:应用将用户重定向到提供商,用户同意,应用收到令牌。
工具调用,有时称为函数调用。下一节介绍本教程需要的内容。
开始前的一个警告: 这个智能体写入真实系统。它会打开真实的GitHub issue并发布真实的Slack消息。在你确认它只处理你预期的消息之前,请使用测试Slack频道和临时GitHub仓库。
工具是你连同输入一起交给模型的函数。 模型不能自己运行该函数。它只能请求:用这个标题和正文调用fileGithubIssue。你的代码执行调用、返回结果,模型使用该结果来选择下一步。
请求、执行、返回。这种交换就是整个机制,所有被称为“智能体”的东西都是围绕它的循环。
工具和API封装的是同一个调用,但面向不同的读者。
API是为你编写的。它假设你读过文档,并且知道thread_ts是将Slack消息变成线程回复的字段。
工具是为一个什么都没读过的模型编写的。所以工具自带解释:
一个模型可以推理的名称,比如fileGithubIssue。
用通俗语言写的描述,包括什么时候不使用该工具。
输入的模式,这样模型就知道title是必需的字符串。
下面是项目中的一个工具。大部分代码是解释而非逻辑:
const fileGithubIssue = tool({
description: 'File a GitHub issue for an actionable Slack message',
inputSchema: z.object({
title: z.string(),
body: z.string(),
}),
execute: async ({ title, body }) => {
// ... the actual API call goes here
},
});
description(描述)和 inputSchema(输入模式)是模型可见的部分。execute 函数则完全属于你自己。身份信息在 execute 内部得到确定,因此模型永远不会知道该调用是针对哪个账户执行的。
把 curl 命令粘贴到输入中并让模型填空是可行的。但这种方法会以可预见的方式失败。
工具效果更好的原因有三个:
在你的代码运行之前,模式就已经得到强制执行。格式错误的工具调用会被 SDK 拒绝并重试。而格式错误的 URL 则会在运行时失败。
结果会返回给模型。在 fileGithubIssue(提交GitHub问题)返回后,模型可以读取新的问题 URL,并在 Slack 回复中使用它。正是这种串联使得第二步成为可能。
凭据不会进入对话。模型按名称请求一个操作,却永远不会看到令牌。它从未见过的令牌不会泄漏到完成内容、日志行或提示注入载荷中。
第三个原因正是本教程其余部分的目标所在。你将有意让令牌远离模型:智能体持有一个标识符,而令牌只在提供商调用的那一刻出现。
很少有实用的智能体只与单个应用交互。一个支持智能体读取 Zendesk 并更新 Salesforce。一个站会智能体读取 GitHub 并发布到 Slack。一个日程智能体读取 Gmail 并写入 Google Calendar。
每个应用都有自己的 OAuth 注册、作用域名称、令牌生命周期和刷新行为。将该列表乘以智能体的每个用户,真正的问题就出现了。
对所有人使用一个共享凭据在演示中可行,但一旦第二个人出现就会失败。想象一下Slack部分的快速版本:创建一个 Slack 应用,安装它,把机器人令牌复制到 .env 中,然后让每次工具调用都使用它。
三个问题会同时出现。
首先,每次运行都使用相同的权限。无论谁触发了运行,机器人都会看到它被邀请进入的每个频道。如果你询问一个你从未加入过的频道,机器人照样会读取它。智能体已经成为绕过你自己工作区权限的一种方式。
其次,审计追踪也是错误的。每个 GitHub 问题都显示为机器人创建的。每条 Slack 回复都来自机器人。当被问及某个问题为何存在时,诚实的回答是“某个智能体为某人提交了它,但无法分辨是谁”。
第三,撤销功能失效。用户离开公司,他们的 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"
}
}
顶层的access_token是机器人的。嵌套的authed_user.access_token是刚刚授权的用户。使用前者读取conversations.history会返回应用被邀请加入的每个频道;使用后者则只返回用户已经能看到的频道。同样的区分也适用于写入操作:使用用户令牌调用chat.postMessage会以该用户的名义发布消息。
两个字段,前缀中仅一字之差,而你代理的整个权限模型就取决于你存储的是哪一个。本教程只请求用户范围,因此Slack根本不会颁发机器人令牌。
每个用户的令牌成为系统中敏感度最高的数据。有两个地方绝对禁止存放:
模型:不要让令牌出现在输入、工具描述或工具返回值中。模型一旦见过令牌就可能复述它,而提示注入会把任何工具结果变成不可信的输入。
你的日志:工具输入和输出正是你在调试代理时想记录的内容。随这些载荷一起传输的令牌会永久存入你的日志存储中。
本教程让令牌只走一条狭窄的路径。你的代码传递一个标识符,即对某个用户的稳定引用。一个辅助函数将该标识符转换为令牌,随后令牌直接进入提供商调用,不走任何其他路径。它永远不会出现在工具模式中,不会附加到模型可读取的任何内容上,也不会从工具返回。
自己编写流程的意义不在于管道本身,而在于控制谁能使用谁的授权。
在本教程中,用户就是团队成员。每个人连接自己的Slack和GitHub,代理以触发运行的用户的身份行事。当这些用户是你产品的客户时,同样的设计依然成立:每个人仍然拥有自己的授权,而错误的映射意味着某人的运行使用了另一个人的访问权限。变化的只是标识符的来源。对团队成员来说是会话,对客户来说是租户记录。
有两个流程很重要,它们发生在不同的时间。将它们分开是大部分工作的关键。
连接时间发生在每个用户、每个应用各一次。用户授权后,令牌落入你的存储中。此时代理没有运行。
运行时发生在每次执行时。代理将当前用户解析为标识符并执行工作。没有授权页面,也没有浏览器。
CONNECTION TIME (once per user, per app)
Your user connect.js Slack / GitHub
| | |
|-- "connect Slack" --->| |
|<--- consent link -----| |
|----------------------- OAuth consent ---------->|
| |<--- redirect + code ----|
| |---- exchange code ----->|
| |<---- tokens ------------|
| | |
| [encrypt, store |
| under (identifier, |
| provider)] |
| | |
RUNTIME (every agent run)
Your agent Token store Slack / GitHub
| | |
[resolve identifier | |
from your own session] | |
| | |
|-- getAccessToken( --->| |
| identifier, | |
| provider ) | |
|<---- token -----------| |
| | |
|------------------ API call as user ------------>|
|<----------------- result -----------------------|
| | |
[model sees result, | |
never a token] | |
这种形状引出了三个性质。
标识符取代了代理代码中的令牌。令牌存储之上的所有内容都处理像alice@example.com或user_8f21c这样的字符串。字符串本身毫无价值:没有存储及其加密密钥,它什么也打不开。
一个身份横跨多个应用。单个标识符下面有Slack行和GitHub行。第三个应用不会创建需要协调的第三个身份。
授权保留在你的代码中。存储回答哪些令牌属于某个标识符。存储无法知道请求是否应得到回答。决定调用者可以以该标识符身份行事是在任何调用之前发生的。
有一条规则,违反它会破坏整个设计:从经过身份验证的会话中在服务端解析标识符。绝不接受来自请求体、查询参数或浏览器的标识符。从客户端接受的标识符就是一个“以任意用户身份操作”的端点。
本教程从头到尾使用Slack和GitHub作为两个提供商。两者都需要相同的三样东西:已注册的应用、重定向URI和一组作用域。细节差异足够大,值得分别进行。
大多数涉及OAuth的教程都会给你http://localhost:3000/callback然后继续。Slack拒绝它。Slack的文档直截了当地说“重定向URL也必须使用HTTPS”,并且对localhost没有例外。GitHub更宽松,接受任一种,所以一个HTTPS回调就同时满足两者。
这条规则看起来有些迂腐,因为在localhost上,请求永远不会离开你的机器,线路上也没有任何东西可拦截。但Slack还是一视同仁地应用它,对于发放凭证的提供商来说,一条没有例外的统一规则是站得住脚的选择:每个例外都是一个分支,必须有人正确处理,而“这真的是localhost吗”这个问题以前曾被错误回答过。
mkcert会签发由本地机构签名的证书,该机构会添加到你的系统信任存储中,因此浏览器会毫无警告地接受它:
mkcert -install
mkcert localhost
这会将 localhost.pem 和 localhost-key.pem 写入当前目录。诸如 ngrok 之类的隧道服务也可以,但其免费 URL 会轮换,这意味着每次会话都需要重新编辑两个应用注册。
在 api.slack.com/apps 处,在你的工作区创建一个应用。然后打开 OAuth 与权限 并设置两件事。
在 重定向 URL 下添加 https://localhost:3000/callback。
然后找到作用域。页面有两个部分,选择错误的部分会静默地重建共享机器人设计:
| 部分 | 授予的权限 | 在这里使用? |
|---|---|---|
| 机器人令牌范围 | 一个充当应用的 xoxb- 令牌 |
否 |
| 用户令牌范围 | 一个代表用户的 xoxp- 令牌 |
是 |
在 用户令牌范围 下,添加:
channels:history:读取用户所属公共频道中的消息
chat:write:以用户身份发布
users:read:将用户 ID 转换为名称
将机器人令牌范围留空。从 基本信息 复制客户端 ID 和客户端密钥。
在 设置 → 开发者设置 → OAuth 应用 → 新建 OAuth 应用 下,将授权回调 URL 设置为相同的 https://localhost:3000/callback,然后生成客户端密钥。GitHub 完整记录了 Web 应用流程,如果你想要更详细的背景信息。
GitHub 用于创建议题的 作用域 取决于仓库:
repo 涵盖私有仓库,并同时授予代码的读写权限。
public_repo 是更狭窄的选择,当你的测试仓库为公共时足够。
在可行时选择更窄的那个。不需要的作用域,日后还得解释。
两个应用都生成客户端 ID 和客户端密钥,而存储需要一个加密密钥。首先生成密钥:
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))"
然后填写.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
那些客户端密钥用于向提供商验证你的应用程序。它们不是用户凭据,也绝不应出现在浏览器中。
所有特定于提供商的配置都集中在一处,这样以后添加第三个提供商时,只需添加一个条目,而不是新增一个分支。
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',
// These go in `user_scope`, not `scope`. Scopes listed under `scope` grant
// a bot token, and a bot token is what this project exists to avoid.
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 and refresh follow below
},
};
这个 user_scope 参数是这一行中的完整参数。 Slack 为机器人权限读取 scope,为用户权限读取 user_scope。此项目只设置了后者,因此响应返回时根本不含任何机器人令牌。
state 参数不是可选的。它是一个你生成的随机字符串,发送给提供者,并在返回时进行核对。没有它,互联网上的任何页面都可以通过附加攻击者的 code 将浏览器指向你的回调 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 answers HTTP 200 even when the exchange failed. The `ok` field
// is the real status.
if (!json.ok) {
throw new Error(`Slack token exchange failed: ${json.error}`);
}
return normalizeSlackTokens(json.authed_user);
}
该函数中有两个细节一旦遗漏,就会耗费大量调试时间。
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,
};
}
GitHub对同一函数的实现有两个值得注意的不同之处:
async exchangeCode(code) {
const response = await fetch(this.tokenUrl, {
method: 'POST',
// Without this header GitHub answers with a form-encoded body.
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 token exchange failed: ${json.error_description ?? json.error}`
);
}
// OAuth App tokens carry no expiry, so there is nothing to refresh.
return {
accessToken: json.access_token,
refreshToken: null,
expiresAt: null,
scope: json.scope,
};
}
这个 Accept: application/json 请求头很容易被忽略,并导致令人困惑的失败:response.json() 在返回体为 access_token=gho_...&scope=repo 时会抛出异常。
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(
`Could not read the TLS certificate (${err.code ?? err.message}).\n` +
'Generate a locally-trusted one with mkcert:\n' +
' mkcert -install\n' +
' mkcert localhost\n' +
'then point TLS_CERT_PATH and TLS_KEY_PATH at the two files it writes.'
);
}
}
缺少证书的情况总会发生在某个人身上,而 ENOENT 本身无法解释任何关于 OAuth 的信息。catch 块用了四行来说明应该运行什么。
handler 本身正是在那里对 state 进行检查:
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('Not found');
return;
}
const state = url.searchParams.get('state');
const entry = pending.get(state);
if (!entry) {
response.writeHead(400).end('State mismatch. Start the flow again.');
return;
}
pending.delete(state);
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);
}
这个 pending 映射就是 state 检查。 状态值只有在此进程生成它时才会进入该映射,并且一旦使用就会被删除。无法识别的状态意味着回调并非来自你启动的流程,而状态出现两次则意味着重放。这两种情况都源自一次 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(`${provider.label} connected. You can close this tab.
`);
resolve();
},
});
});
}
randomBytes(16) 而不是 Math.random()。可预测的 state 参数等同于没有 state 参数。
运行它将依次遍历每个未连接的提供者:
[slack] authorize as "alice@example.com":
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
请注意这个URL不包含任何scope参数。Slack没有指示要生成bot令牌,所以它不会这样做。
存储回答一个问题:对于这个提供商,哪个令牌属于这个用户?其他方面都源于确保这个答案的安全。
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
);
// One row per user, per provider. expires_at stays outside the ciphertext so
// a token's freshness can be checked without decrypting it.
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)
)
`);
复合主键就是隔离保证,白纸黑字写下来。 (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'));
}
三条规则支配着这一对操作,违反其中任何一条都比完全不加密更糟糕,因为它看起来像是加密成功了一样:
每次加密使用全新的IV: 对GCM重用初始化向量是灾难性的失败,而非轻微问题。每次调用都使用randomBytes(12),并将其存储在密文旁边。
保留认证标签: 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);
}
ON CONFLICT 子句的重要性远超其表象。重新授权必须替换已有授权,而不是失败或产生重复,而重新授权正是用户在撤销授权或更改权限范围后所做的操作。
加密密钥本身存放在此处的 .env 中,这对教程来说是正确的,但对生产环境则是错误的——在生产环境中,它应放在密钥管理器或 KMS 中。丢失密钥会使所有已存储的授权无法读取,并迫使每个用户重新授权。这确实是一次真实的中断,但总比另一种情况要好:数据库文件被盗,导致你智能体每个用户的有效令牌被泄露。
运行时包含三个步骤:解析标识符,用它获取令牌,并将整个过程封装为工具。
标识符是任意稳定的字符串,代表一个用户、一个电子邮件地址、一个用户 ID 或一个租户范围内的密钥。
// In a real app this comes from your authenticated session, resolved
// server-side. Never accept it from client input.
const IDENTIFIER = process.argv[2] ?? 'channel-watcher-agent';
从argv中读取标识符使演示无需登录即可运行,并且使本教程后面的隔离测试成为一条单一命令。真实应用程序会替换这一行:
// Real app: resolve from your authenticated session, server-side.
const session = await getSession(request); // your auth
const identifier = await lookupIdentifier(session.userId); // your database
这两行代码的顺序很重要。 先验证调用者身份,再查找该调用者可以代表哪个标识符进行操作。来自客户端的标识符会将端点变成任何用户 Slack 的读取者。
一个函数介于标识符与每次提供商调用之间:
const REFRESH_WINDOW_MS = 60_000;
export async function getAccessToken(identifier, providerName) {
const grant = readGrant(identifier, providerName);
if (!grant) {
throw new Error(
`[${providerName}] no grant for "${identifier}".\n` +
`Connect it first: 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}] token for "${identifier}" expired and no refresh ` +
'token is stored. The user has to consent again.'
);
}
const refreshed = await providers[providerName].refresh(grant.refreshToken);
saveGrant(identifier, providerName, refreshed);
return refreshed.accessToken;
}
在API调用之前立即调用此方法,而不是在启动时调用一次。长时间运行的agent可能会超过十二小时令牌的有效期,提前解析令牌意味着在最不方便的时刻才发现问题。延迟获取只需一次廉价的数据库读取,却消除了整类问题。
此外,六十秒的窗口期并非为了凑数而存在。一个只剩四秒的令牌能通过简单的过期检查,然后在运行中过期。在窗口期内刷新任何内容,意味着返回的令牌至少可以保证一分钟的工作。
最后,缺失授权时应抛出错误而不是回退。没有合理的回退方案。对于未连接的用户,正确的结果是停止运行,并显示提示信息告知如何连接。
身份信息在此处注入,位于模型可以影响的一切之下的一层:
export function buildTools(identifier) {
const [owner, repo] = process.env.GITHUB_REPO.split('/');
const fileGithubIssue = tool({
description: 'File a GitHub issue for an actionable Slack message',
inputSchema: z.object({
title: z.string(),
body: z.string(),
}),
execute: async ({ title, body }) => {
const token = await getAccessToken(identifier, 'github');
return createIssue(token, owner, repo, { title, body });
},
});
const replyInSlackThread = tool({
description:
'Reply in the original Slack thread (e.g. with the created issue link)',
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 };
}
比较模型能控制的内容与不能控制的内容。模型可以选择title、body和text。模型不能选择用户。identifier是一个闭包参数,在模型运行之前就已固定,并且它不出现在任何inputSchema中。没有任何输入能让模型文件以他人的身份提交问题,因为账户不是它的输入之一。
每个返回值都值得单独审核。createIssue返回问题编号、URL和标题。postThreadReply返回一个时间戳。两者都不会返回令牌,也都不会返回原始提供者响应——如果有令牌要隐藏的话,它就会藏在那里。
提供者调用本身是普通的HTTP:
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 };
}
Slack 的 conversations.history 返回干净的 JSON,但有一个缺口:消息携带的是用户 ID,而不是显示名称。将这些 ID 转换为名称意味着每次都要调用 users.info,这正是 users:read 出现在权限范围列表中的原因。
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而不是抛出异常,因为无法解析的名字不是放弃运行的理由。
没有 text 的消息会被丢弃。 频道加入和目的变更会以没有正文的消息对象形式到达,其中没有可供模型分类的内容。
.reverse() 并非装饰性操作。 Slack 按从新到旧的顺序返回。模型如果倒着阅读对话,会错误理解哪条消息回复了哪条。
ts 字段随后承担双重职责。它标识一条消息,因此既充当回复的线程锚点,又充当记忆代理已处理内容的键:
const state = await loadState();
const processed = new Set(state[IDENTIFIER]?.processedTs ?? []);
const newMessages = messages.filter((m) => !processed.has(m.ts));
以标识符作为该状态的键,正如代码片段所做的那样。单一的扁平列表会让一个用户已处理的消息遮蔽另一个用户的消息,从而重新引入跨用户串扰,而这正是整个设计所要防止的。
将两个工具都交给模型,让它自行决定:
const { text } = await generateText({
model: anthropic(process.env.MODEL),
tools,
stopWhen: stepCountIs(5),
prompt: `You triage messages from a dev team's Slack channel.
Message from ${message.author}: "${message.text}"
Message timestamp (thread_ts): ${message.ts}
Decide if this message is actionable (a bug report or concrete action item) or just noise (chit-chat, join notices, already-resolved chatter).
If actionable: file a GitHub issue with a clear title and body drafted from the message, then reply in the original Slack thread (use the exact thread_ts above) with a short note and the created issue's URL.
If not actionable: do nothing and briefly say why.`,
});
循环是使第二步成为可能的机制。模型读取消息,并可能调用fileGithubIssue。AI SDK运行该工具,将结果连同新的issue URL一起反馈到上下文中,并再次调用模型。现在模型可以在线程中回复一个它在第一轮时不可能知道的URL。然后停止。
stopWhen: stepCountIs(5)限制了轮数。如果没有边界,困惑的模型可能会无限重试失败的工具。对于两个工具来说,五轮已经很宽容了。
确定性版本也是合理的:使用结构化输出调用进行分类,然后在消息符合条件时按固定顺序自行调用这两个工具。
固定序列更容易测试,但放弃了真正的灵活性。循环允许模型跳过回复,或不回复就提交issue,而且添加第三个工具不需要新的分支。当每类输入对应的操作集合各不相同,且永远不会固定时,请选择循环;而在它们永不变化时,选择固定序列。
关于provider这一行,为了准确说明实际运行的内容。上面的代码片段使用了@ai-sdk/anthropic,这适用于直接使用Anthropic API密钥的情况。我自己的测试是通过一个兼容OpenAI的网关进行的,这仅改变了provider的构造方式:
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,
});
// then: model: gateway(process.env.MODEL)
无论哪种方式,工具、循环和令牌处理都是相同的。只有model参数会发生改变。
令牌以两种不同的方式结束,其中只有一种是你的代码的问题**。** 过期是常规且可恢复的。撤销是某人做出的决定,正确的响应是再次请求同意。
本教程中的两个提供方位于该范围的两端,因此它们是一对有用的组合。
OAuth 应用用户令牌没有过期时间戳。没有需要存储的刷新令牌,也没有需要发出的刷新调用,这就是为什么本项目中的github.refresh()除了自我说明之外什么都不做:
async refresh() {
throw new Error(
'GitHub OAuth App tokens do not expire. A failure here means the ' +
'grant was revoked — send the user through consent again.'
);
}
"不会过期"不等于"永久有效",GitHub出于几个值得了解的原因会撤销令牌:
用户在其账户设置中手动撤销授权。
令牌在一年内未被使用。
令牌被推送到公共仓库或gist,此时GitHub会自动撤销它。
应用为同一用户和作用域组合累积了超过十个令牌,最早的那些会被撤销。
第三种情况值得特别关注。GitHub会扫描公共推送中是否包含其自有令牌格式,一旦发现就会将其删除。这是一个安全网,而非一种策略,但它无法保护私有仓库或日志文件中的令牌。
GitHub Apps与OAuth Apps的行为有所不同,这在阅读GitHub文档时常常造成困惑。GitHub App的用户访问令牌在八小时后过期,并附带一个有效期为六个月的刷新令牌。如果你改用GitHub Apps进行构建,下面提到的Slack风格刷新路径正是你需要的。
默认情况下,Slack用户令牌同样不会过期。令牌轮换改变了这一状况,并附带一个值得重申的警告:轮换一旦开启,便无法关闭。请先在测试应用上启用它。
开启轮换后,令牌有效期为十二小时,并随附一个刷新令牌。刷新调用复用与初始交换相同的端点,但使用不同的授权类型:
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对象中的一个条目,以及一个工具。将身份保存在单个字符串中,正是获得这种折扣的关键。
代理一直使用两个提供商。值得注意的第二个提供商不需要的是:不需要第二个身份、不需要第二个同意服务器,也不需要第二个令牌表。
export const providers = {
slack: { /* ... */ },
github: { /* ... */ },
};
将 Google Calendar 作为第三种,意味着第三个条目,带有自己的 authorizeUrl、tokenUrl、作用域和 exchangeCode。同意服务器会遍历 Object.keys(providers),因此无需修改即可获取新条目。存储已经以 (identifier, provider) 为键,因此无需迁移。然后再加一个工具:
const createCalendarEvent = tool({
description: 'Create a calendar event',
inputSchema: z.object({ summary: z.string(), start: z.string() }),
execute: async ({ summary, start }) => {
const token = await getAccessToken(identifier, 'google-calendar');
// ...one more provider call
},
});
标识符不会改变,你的用户表不会改变,而模型对世界的认知恰好增加了一个工具。
无法随规模扩展而降低的成本是供应商特定的知识。每个新供应商都会带来自己的作用域词汇、自己的错误格式,以及自己对令牌是否会过期的回答。Slack 和 GitHub 在这三方面都意见不一,而第三个供应商也会以不同的方式存在分歧。注册表模式将这些知识封装在每家供应商一个对象中,而不是将其分散在你的代理中,但这并不会让这些知识变得多余。
关于同意的一个注意事项:授权是基于每个用户、每个供应商的。Alice 连接了 Slack 但未连接 Calendar,这意味着她的日历工具调用会失败,而失败在那里是正确的结果,因为她从未同意。应将其视为连接提示而非错误,这一点在故障模式部分会再次提及。
克隆仓库,安装,并填写.env:
git clone https://github.com/saif-shines/channel-watcher-agent.git
cd channel-watcher-agent
npm install
cp .env.example .env
# fill in both client IDs and secrets, the encryption key, channel ID, repo
然后进行连接。该命令会启动回调服务器,并为每个未连接的提供商打印一个链接:
npm run connect
[slack] authorize as "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] connected.
[github] authorize as "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] connected.
All providers connected. Run: node src/index.js channel-watcher-agent
打开每个链接,同意授权,标签页会确认。这些URL中的state参数会在返回时被检查。携带其他任何内容的回调都会收到400错误,且永远不会到达令牌交换环节。
然后针对一个包含普通聊天的频道运行代理:
node src/index.js
针对一个仅有三条普通消息的频道,该次运行的输出如下:
[channel-watcher-agent] 3 messages fetched, 3 new.
--- Alex: "Sending draft message" ---
The message "Sending draft message" is noise — it appears to be a test or
accidental send, not a bug report or concrete action item.
...
没有调用工具,也没有提交问题。 负面情况比看起来更重要。一个拥有写入权限却不会拒绝的智能体是一种负担,而对普通闲聊运行一次是最便宜的克制测试。
现在在频道中发布一个实际的错误报告:
嘿,/export 端点对于任何超过50MB的文件都会超时,从昨天的部署开始就出现了。
再次运行智能体,状态文件可以防止早先的消息被重复分类处理。
作者身份才是关键。 标识符背后的GitHub账户会打开问题,使用的是该用户自己的OAuth授权,而不是共享机器人。Slack回复也来自该人。撤销他们的访问权限后,下一次运行将在 getAccessToken 处失败,这是正确的结果。
标识符来自命令行,因此无需先构建登录即可测试隔离性:
node src/index.js # the identifier you already authorized
node src/index.js alice@example.com # a different user entirely
第二个命令从不读取通道。它会停止:
[slack] no grant for "alice@example.com".
Connect it first: node src/connect.js alice@example.com
拒绝才是关键所在。两条命令之间,agent 本身没有任何变化。提供方、工具和代码完全相同。只有标识符不同,而 Alice 尚未同意,因此不存在可解密的记录行,运行在访问 Slack 之前就停止了。
共享 bot 版本的行为则不同。第二条命令会以 bot 的身份读取频道并提交 issue,因为这其中从未涉及按用户授权。
一旦 Alice 同意,后续的一切都遵循她的授权。readGrant 返回她的记录行。getAccessToken 解密她的令牌。Slack 读取返回她能看到的频道,而她的 GitHub 账户则作为 issue 的作者。
生产环境中,argv 会被替换为会话查找:
const identifier = await lookupIdentifier(session.userId);
该仓库包含一个测试套件,用模拟的 Slack 和 GitHub 端点替换 fetch,因此请求构建、响应解析、存储和刷新逻辑全部在未注册任何 OAuth 应用的情况下运行:
npm test
其中三个测试值得点名,因为它们检验的是本教程的论断,而非代码内部实现:
Slack交换保留用户令牌并丢弃机器人令牌。测试夹具同时返回两者。测试断言存储的值是xoxp-开头的那个。
两个用户从相同的工具输入获得两个不同的令牌。相同的text、相同的thread_ts、两个标识符,到达提供商的两个不同Authorization头。
为未连接用户构建的工具会失败,而不是回退。它还断言零次提供商调用尝试,因为泄露请求后才失败算不上什么失败。
首次运行就通过的测试值得怀疑,所以我通过故意破坏代码来检查这些测试。用机器人令牌取代用户令牌、在buildTools中忽略标识符、以及在模型可见的模式中暴露identifier,每种情况都至少使一个测试失败。
该模式中没有任何内容是特定于Slack分类的。其形态是:从一个应用读取,用模型决策,写入另一个应用,全程以同一个用户身份进行。
更换提供商会产生不同的产品:
| 读取自 | 写入到 | 结果 |
|---|---|---|
| Slack | GitHub | 将频道闲聊分类为issue,如本教程所示 |
| Gmail | Linear | 将支持邮件转化为可跟踪的工作 |
| Google Calendar | Notion | 会议准备笔记,在会议前起草 |
| Zendesk | Salesforce | 针对正确的账户记录支持信号 |
| GitHub | Slack | 在关心的频道中生成变更摘要 |
每一行都使用相同的三个组成部分:一个提供商入口、按标识符查找令牌、以及一个工具。只有三个东西会变:OAuth应用注册、execute内部的API调用,以及你为模型编写的输入。
输入才是你的产品所在。OAuth是管道工程。决定哪些消息值得一个issue,以及issue应该说什么,这是判断力,而判断力才是值得你花数周的部分。
相同的代码支持两种部署形态:
内部团队代理:标识符是触发运行的队友。按计划或命令运行。
面向客户的代理:标识符来自你的租户和用户记录。在客户账户内,针对客户数据运行。
代码保持不变,但错误标识符的后果却不同。
这些是我在构建项目时遇到的问题,大致按它们出现的顺序排列。如果你遇到同样的问题,修复通常很小。
症状在任何代码运行之前就出现了: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生成的自签名证书总是会警告,因为没有任何信任它。
两个提供商都会将你发送的redirect_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中将标识符作为闭包参数,让每个execute获取自己的令牌,这样任何代码路径都无法传递游离的凭证。
刷新令牌会轮换。如果刷新时写回了新的访问令牌但保留了旧的刷新令牌,那么它会立即成功,但在下一个周期失败,这使得bug和症状之间间隔十二小时。
saveGrant正是因为这个原因而接收整个规范化后的令牌对象。将刷新返回的所有内容都写回。
原因有两个。状态文件缺失或未写入会导致每次运行都重新处理所有内容。或者stopWhen允许足够多的轮次,使困惑的模型重试已经成功的工具。
首先检查状态文件。然后检查工具的返回值是否明确表明成功,因为模糊的结果会招致重试。
你已经构建了一个智能体,它能读取 Slack 频道,判断哪些消息描述的是真实工作,为这些消息提交 GitHub issue,并通过线程回复完成闭环。每次调用都以某个特定用户自己的 OAuth 授权身份运行,整个过程依靠你自己编写的 OAuth 流程和令牌存储。
有五个理念可以沿用到任何提供商:
工具是一次 API 调用加上给模型的解释,而且解释占了大部分工作。
标识符取代了智能体代码中的令牌。一个小函数之上的所有代码处理的都是对用户的引用,而不是凭据,因此令牌永远不会进入你的模型输入或日志。
连接时和运行时是两个独立的流程。每个用户、每个应用只需同意一次。运行时在后期才解析标识符并获取令牌。
授权仍然由你掌控。令牌存储回答的是哪些令牌属于哪个标识符。调用者能否以该标识符的身份行事,这个问题只有你的代码能回答。
多提供商支持是一个注册表问题,而不是架构问题,一旦身份信息存在于一个字符串中。
分量最重的细节同时也是最小的:authed_user.access_token而不是access_token。一次属性访问就决定了你的智能体是尊重工作区已有的权限,还是悄然绕过它们。
从这里开始,保持整体形态,更换提供商。把读取端指向 Gmail,写入端指向 Linear,然后为模型重写输入。身份管道不需要改变。
完整源码位于 github.com/saif-shines/channel-watcher-agent。
这篇文章回顾了我们在构建 Scalekit(你刚刚构建的令牌库的托管版本)时学到的经验。
——
一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。