Guide · 使用教程
Arcade.dev Python 教程:用户授权、连接归属与敏感操作审批
固定官方 SDK,演示可信用户映射、OAuth 身份确认、批准参数快照、持久化去重与超时待核对。
给 Agent 接上 Gmail 等第三方账户时,先验证登录身份与账户归属,再决定当前工具能否执行。本文以 Arcade.dev 为例,把用户 OAuth、生产身份确认和敏感操作审批串起来,用 Python 与 SQLite 保存一个明确的批准版本,并处理重复请求和结果不明的外部调用。
核对日期:2026 年 10 月 16 日。 示例固定 arcadepy 1.10.0。已用官方 SDK 和 httpx.MockTransport 做 12 组离线检查;没有创建真实 Arcade 账户、连接 Gmail、运行真实 OAuth 或发送邮件。示例是单机应用核心,不包含登录系统、公开回调路由或完整审批 UI。
1. 三种身份与两次决定
- 应用用户:从已验证的登录会话取得本地 user 和 tenant。
- Arcade 用户:由服务端数据库保存稳定映射,不能让浏览器或模型自行传 user_id。不同租户下的同名本地用户必须映射到不同身份。
- 第三方账户:用户在提供商页面选择并授权的账户。应用还应让用户确认它确实是业务所需的工作账户。
OAuth 决定应用可以访问哪些账户权限;业务审批决定这次操作是否按这些参数执行。前一次允许 Gmail.SendEmail,不代表后续任意收件人、正文和附件都已获批准。授权工具调用
2. 固定 SDK 并核对工具版本
python3 -m venv .venv
source .venv/bin/activate
python -m pip install arcadepy==1.10.0
通过服务端秘密管理设置 ARCADE_API_KEY。生产客户端限制自动重试:写操作请求超时可能已生效,自动重试不是业务幂等保证。
from arcadepy import Arcade
client = Arcade(max_retries=0, timeout=20.0)
definition = client.tools.get("Gmail.SendEmail")
version = definition.toolkit.version
if not version:
raise ValueError("无法确定工具版本,停止接入")
#在受控开发环境审核 definition.input,不把用户数据或凭据打印进公共日志。
这里的 get 是真实 SDK 读取入口,只有在你准备好的测试账户中才调用。本文离线测试中的 fixture-version 仅是模拟值,不能复制到生产。应保存审核过的工具定义和版本,升级后重新检查 schema、授权范围与行为。
官方当前 Quickstart 使用 recipient、subject、body 调用 Gmail.SendEmail。下文只开放这三个字段,拒绝额外参数,不开放附件、抄送、任意工具名或调度发送。实际接入仍应对照你所固定的版本定义。官方示例
3. 授权入口与生产用户验证
应用后端取得可信会话后,加载 principal 映射,再请求授权。授权链接只在该用户的受控界面展示,不放到共享日志或当作团队通用连接。用户必须亲自完成提供商同意页面。
生产环境按官方指南配置自定义 user verifier。回调中的 flow_id 是不可信输入;服务端会话决定 principal,不能采用 query string 中的 user_id。confirm_user 再向 Arcade 验证该会话身份与原流程是否匹配。生产授权指南
公开路由还要处理登录、回调与待办流程绑定、CSRF / 重放、短时效、失败限流和固定跳转目的地。不要把“客户端返回成功”直接写成账户授权完成。下面的 confirm_identity 是已鉴权服务端路由调用的核心,既不是可直接暴露的 HTTP 接口,也不是自建 OAuth 协议实现。
4. 保存候选参数与批准版本
把下文保存为 approval.py。Principal 只能由可信服务端会话与数据库映射构造;can_approve 是后端角色结果,不能接受前端传来的 true。示例让账户所有者在具备审核权限时批准自己的操作;多人复核应增加独立审核人表和角色检查。
stage 生成不可变快照,包含租户、用户、Arcade 身份、工具、版本和全部输入。approve 要求审核界面显示的 digest 与当前快照一致,有效期为示例设定的 15 分钟。修改候选内容应创建新任务并废弃旧候选,不复用旧批准。
"""Single-host example. Caller must supply authenticated server-side principals."""
from dataclasses import dataclass
import hashlib
import json
import sqlite3
import time
import uuid
TOOL = 'Gmail.SendEmail'
@dataclass(frozen=True)
class Principal:
tenant: str
user: str
arcade_user: str
can_approve: bool = False
def db(path):
connection = sqlite3.connect(path, timeout=10)
connection.row_factory = sqlite3.Row
return connection
def init(path):
with db(path) as connection:
connection.execute('''CREATE TABLE IF NOT EXISTS actions(
id TEXT PRIMARY KEY,tenant TEXT,user TEXT,request_id TEXT,
snapshot TEXT,digest TEXT,state TEXT,expires REAL,receipt TEXT,
UNIQUE(tenant,user,request_id))''')
def begin_auth(client, principal, version):
return client.tools.authorize(tool_name=TOOL, tool_version=version,
user_id=principal.arcade_user)
def confirm_identity(client, principal, flow_id):
if not flow_id or len(flow_id) > 512:
raise ValueError('invalid flow')
result = client.auth.confirm_user(flow_id=flow_id, user_id=principal.arcade_user)
status = client.auth.status(id=result.auth_id)
if status.user_id != principal.arcade_user:
raise ValueError('authorization belongs to another identity')
return status.status
def stage(path, principal, request_id, version, arguments, allowed_recipients):
if (not request_id or not version
or set(arguments) != {'recipient','subject','body'}
or not all(isinstance(v,str) and v.strip() for v in arguments.values())
or arguments['recipient'] not in allowed_recipients):
raise ValueError('input rejected')
snapshot = json.dumps({
'tenant':principal.tenant, 'user':principal.user,
'arcade_user':principal.arcade_user, 'tool':TOOL, 'version':version,
'input':arguments,
}, ensure_ascii=False,sort_keys=True,separators=(',',':'))
digest = hashlib.sha256(snapshot.encode()).hexdigest()
action_id = uuid.uuid4().hex
with db(path) as connection:
connection.execute('INSERT INTO actions VALUES (?,?,?,?,?,?,?,NULL,NULL)',
(action_id,principal.tenant,principal.user,request_id,
snapshot,digest,'pending'))
return action_id,digest
def approve(path, principal, action_id, displayed_digest):
if not principal.can_approve:
raise ValueError('review permission required')
with db(path) as connection:
changed = connection.execute('''UPDATE actions SET state='approved',expires=?
WHERE id=? AND tenant=? AND user=? AND digest=? AND state='pending' ''',
(time.time()+900,action_id,principal.tenant,principal.user,displayed_digest)).rowcount
if changed != 1:
raise ValueError('stale approval or wrong owner')
def execute(path, principal, action_id, allowed_recipients, client):
with db(path) as connection:
connection.execute('BEGIN IMMEDIATE')
row = connection.execute('''SELECT * FROM actions
WHERE id=? AND tenant=? AND user=?''',
(action_id,principal.tenant,principal.user)).fetchone()
if not row or row['state'] != 'approved' or row['expires'] < time.time():
raise ValueError('not approved or expired')
if hashlib.sha256(row['snapshot'].encode()).hexdigest() != row['digest']:
raise ValueError('snapshot changed')
snapshot = json.loads(row['snapshot'])
if (snapshot['arcade_user'] != principal.arcade_user
or snapshot['tool'] != TOOL
or snapshot['input']['recipient'] not in allowed_recipients):
raise ValueError('identity or policy changed')
# Claim locally before any external side effect.
connection.execute("UPDATE actions SET state='executing' WHERE id=?",(action_id,))
try:
auth = begin_auth(client,principal,snapshot['version'])
if auth.status != 'completed' or auth.user_id != principal.arcade_user:
raise ValueError('account authorization not completed')
result = client.tools.execute(
tool_name=snapshot['tool'],tool_version=snapshot['version'],
user_id=principal.arcade_user,input=snapshot['input'])
if result.success is not True or not result.output or result.output.error:
raise ValueError('execution failed or ambiguous')
receipt = json.dumps({'execution_id':result.execution_id,'id':result.id})
with db(path) as connection:
connection.execute("UPDATE actions SET state='completed',receipt=? WHERE id=?",
(receipt,action_id))
except Exception:
with db(path) as connection:
connection.execute("UPDATE actions SET state='unknown' WHERE id=?",(action_id,))
raise
return receipt
5. 从你的内部应用调用
下例中的 authenticated_principal 来自真实登录会话与服务端账户映射,reviewed_version 来自经过审核的工具定义。reviewed_arguments 应来自受控表单或经业务规则校验的候选内容。模型可以提出正文,但不能决定租户、Arcade 身份和审批权限。
from approval import init, begin_auth, stage, approve
init("actions.sqlite3")
auth = begin_auth(client, authenticated_principal, reviewed_version)
if auth.status != "completed":
#仅向当前用户展示 auth.url,等待其完成授权。
raise RuntimeError("账户授权尚未完成")
action_id, digest = stage(
"actions.sqlite3", authenticated_principal, business_request_id,
reviewed_version, reviewed_arguments, server_recipient_allowlist,
)
#在已鉴权 UI 展示完整身份、工具、版本和参数,用户明确确认后才调用。
approve("actions.sqlite3", authenticated_principal, action_id, digest)
这些变量是业务应用的接入点,不能在空数据库中随意填一个别人的 ID。收件白名单应按用户与租户从后端取得,而不是接受邮件正文、模型输出或浏览器参数。审批端点需要重新核验身份与角色,不能以“知道 action_id”为授权条件。
执行工作器读取批准快照,使用同一身份,并再次检查业务白名单和账户授权。真实发送属于外部副作用,先在测试账户确认需要发送的具体内容,再将真实 client 接到执行器。本文只用模拟传输运行该分支。
from approval import execute
#真实接入会调用外部 Gmail,执行前必须确认此 action_id 已按业务流程批准。
receipt = execute(
"actions.sqlite3", authenticated_principal, action_id,
server_recipient_allowlist, client,
)
示例把 OAuth 未完成、HTTP 异常和工具失败都保守记为 unknown,进入人工核对,未自动区分所有可安全重试的错误。生产系统可以细分 blocked、failed 和 unknown,但不要为了恢复任务把状态直接改回 approved。
6. 去重与超时的实际含义
| 情况 | 示例处理 | 生产补齐 |
|---|---|---|
| 同一个业务 request_id 重复提交 | 唯一约束拒绝第二条 | 调用方查询原任务,不创建新 request_id 绕过 |
| 未审批、审批过期或摘要错误 | 执行前拒绝 | UI 清楚显示待审版本与有效期 |
| 用户映射或收件策略改变 | 执行前拒绝 | 重新审核账户与内容 |
| 两个工作器同时读取批准任务 | 事务中只允许一个占位 | 多机改用共享数据库与事务 |
| 请求发出后超时 | unknown,拒绝再次执行 | 核对 Arcade 运行 ID 与上游实际结果 |
| 进程退出后停在 executing | 不自动重置 | 告警、核对、记录人工决定 |
本地 UNIQUE 和事务能减少重复领取,不能构成跨外部 API 的恰好一次保证。这里没有虚构一个供应商通用幂等头,也没有自动在外部请求上附加本地 request_id 并声称它会防重。
默认 SDK 重试已关闭,但运行时、工具内部和上游服务仍可能有各自的执行行为,需要逐层核对。账户撤销与业务审批也可能在调用前后发生变化;样例的检查不是分布式事务,更不能撤销已经发送的邮件。
7. 验收、日志与数据处理
本地 12 组测试覆盖:禁止额外输入和陌生收件人、旧摘要、无审核权限与跨租户、未批准不执行、SDK 原生请求与单次执行、账户授权未完成、授权身份不匹配、生产确认使用会话身份、超时不自动重试、并发占位、业务请求去重与审批过期、身份映射和策略变化。
测试使用虚构身份、example.test 地址和 MockTransport,验证 SQLite 状态与官方 SDK 请求结构;不证明真实 OAuth、Gmail 权限、投递或生产可靠性。正式验收应使用自己的测试账户,补充真实账户切换、撤销、版本变更、HTML 内容、异常回执和组织日志策略。
Arcade Tool Executions 是工具运行记录,Audit Logs 是管理记录。当前 Cloud 执行历史默认保留 7 天,输入输出可能带用户内容;不要把全量参数无差别写入自己的日志。业务台账可只留摘要、身份映射、审批与执行 ID,并按需要对待审正文加密、限权和清理。执行记录、管理审计
最后用实际账单核对授权事件与工具调用,不把模型 token、上游 API、数据库和审核人力误算成平台月费已包含的资源。选型可继续阅读 OAuth 与凭据管理 Best、Arcade.dev vs Composio 和 Arcade.dev vs Nango。
常见问题
- user_id 可以直接从模型或请求参数取得吗?
- 不应如此。后端先验证登录会话和租户,再从自己的数据库读取稳定 Arcade 身份映射。
- 账户授权 completed 就能执行任意操作吗?
- 不能。账户权限和本次业务批准不同,敏感操作仍要检查工具、版本、完整参数和有效期。
- 工具版本为什么也要进入审批快照?
- 同名工具升级可能改变 schema 或行为。把版本和参数一起审核,升级后重新验证,避免批准内容和执行内容不同。
- 重复点击会发送两次吗?
- 示例用业务 request_id 唯一约束和事务占位减少重复领取,但不承诺跨外部 API 的恰好一次效果。
- 超时后应该怎么恢复?
- 保留 executing / unknown 状态,先核对 Arcade 运行记录与上游实际结果。不要盲目重试或生成新业务 ID 绕过台账。
- 十二组测试包含真实 Gmail 发信吗?
- 没有。使用官方 arcadepy 1.10.0 和 MockTransport 验证请求与状态,真实 OAuth、账户撤销和外发需在自己的测试环境另行验收。