第 9 章:接得上 — Artifacts → GitHub + Email 通知
一句话定位:你会让 agent 把 Artifacts 仓库当 origin、GitHub 当 mirror,自动开 PR,再用 Cloudflare Email Service 把 PR 链接发给提 issue 的人;用户回邮件就是 PR 评论。
想要什么
第 8 章的 SolveIssue 跑完一遍,Artifacts 仓库多了一个 fix/issue-42 分支,commit SHA 也回来了 —— 但用户的 reviewer 不在 Artifacts,他们在 GitHub;提 issue 的人不一定盯着前端,他们盯邮箱。
我们要的是这一串自动化:
curl -X POST 'http://localhost:8787/api/issues/42/solve?conversation=demo' \
-H 'content-type: application/json' \
-d '{
"title": "Fix README typo",
"body": "...",
"repo": "acme/agent-coder",
"reporterEmail": "[email protected]"
}'
# 几十秒后,alice 的邮箱收到:
# Subject: [agent-coder] PR ready: Fix README typo
# Body: https://github.com/acme/agent-coder/pull/77
# Reply to this email to comment on the PR.
agent 在沙箱里 git remote add github、push 当前分支、用 octokit 开 PR、调 env.EMAIL.send 通知用户。用户回那封邮件 → 路由进 agent 的 onEmail → 自动转成 PR comment。merge 那一下留给人,不自动按下。
为什么
不接 GitHub,coding agent 是个空转的工程师 —— 每天能 push 100 个分支,但都飘在自己的 Artifacts 里没人看。要“接得上“团队,只有 PR 是通用接口:有 reviewer、有 CI、有讨论、有 audit log。
不接 Email,通知通道就只剩前端 WebSocket。用户开着浏览器才能知道结果;关掉就石沉大海。Email 是异步、跨设备、谁都有的渠道。第 9 章之前我们没办法用它 —— Cloudflare Email Service 在 2026 Agents Week 才进 public beta,Workers binding env.EMAIL.send() 直接可用,不再需要外接 SendGrid/Resend(REAL_API_v2 §I.4)。
至于“为什么不复用人的 GitHub 账号“—— 那等于把整个用户的所有仓库权限都给了 agent,出事查不出是谁干的。我们用 GitHub App,1 小时短期 installation token、可吊销、bot 身份独立、commit author 显示 agent-coder[bot]。
方案选择
Artifacts vs GitHub:谁是 origin
| 维度 | Artifacts | GitHub |
|---|---|---|
| 写延迟 | binding 直连,毫秒级 | API 限流,百毫秒级 |
| Token 寿命 | 几分钟到几小时,内置 expiry | installation token 1 小时 |
| 人审 / CI | 没有 | 完整 PR 流 + Actions |
| 适合 | agent 高频写、版本化中间产物 | reviewer 看、merge、CI |
主线选择:Artifacts 是 origin、GitHub 是 mirror。Agent 只往 Artifacts 推,Workflow 在 verify 通过后再把对应 commit 镜像 push 到 GitHub 开 PR。这样 agent 写得快、不会被 GitHub API 限流卡;GitHub 那边只承担“人来看“的角色。
凭据方案
| 方案 | 作用域 | 寿命 | 我们用吗 |
|---|---|---|---|
| 用户 PAT | 全部仓库 | 几个月或永久 | 不用 |
| OAuth User Token | 用户授权范围 | 用户撤销 | 不用 |
| GitHub App + installation token | 仅安装到的仓库 | 1 小时 | 用 |
GitHub App 优势压倒性:短期 token、细粒度权限、独立 bot 身份、可批量吊销。我们沿用 v1 第 9 章的实现,只把“token 缓存“从手写 SqlStorage 换成 Think 的 this.sql(同一份能力,API 更顺手)。
在哪里调 git / GitHub API
git push github在 Sandbox 里跑 —— 沙箱已经 clone 好仓库,直接sandbox.exec一行命令。- 开 PR、评论、merge 在 Worker 里走
@octokit/core—— token 不进容器,降低泄漏面。
图 9-1:Artifacts → GitHub → Email 三段链路
落地
文件布局增量:
src/
├── github/
│ ├── app.ts # JWT → installation token,缓存到 this.sql
│ └── pr.ts # octokit 包装:openPullRequest / comment / merge
└── tools/
├── open-pr.ts # LLM 可调:push + 开 PR + 发邮件
└── send-mail.ts # 内部用:env.EMAIL.send 包装
第一步:建 GitHub App,配 secret
Settings → Developer settings → GitHub Apps → New GitHub App:
- Webhook 关掉(我们不接 webhook,靠 Email 做回路)
- Repository permissions:
Contents: Read & write、Pull requests: Read & write、Issues: Read & write、Metadata: Read - Generate a private key,下载
.pem
把 App ID 和 PEM 喂给 wrangler:
# Terminal
wrangler secret put GITHUB_APP_ID
# 粘贴数字 App ID
wrangler secret put GITHUB_APP_PRIVATE_KEY < private-key.pem
到目标 repo 的 Settings → GitHub Apps 把这个 App 装上,记下 installationId(URL 里就有)。
第二步:src/github/app.ts — JWT → installation token,缓存进 this.sql
GitHub App 认证两段:用 PEM 签 9 分钟 RS256 JWT 拿“App 身份“;拿 JWT 去 /app/installations/{id}/access_tokens 换 1 小时的 installation token。token 缓存到 Think 的 this.sql。
// src/github/app.ts
type Env = {
GITHUB_APP_ID: string;
GITHUB_APP_PRIVATE_KEY: string;
};
type TokenRow = { token: string; expires_at: number };
export class GithubApp {
// sql 直接传 Think 的 this.sql(模板字符串风格)
constructor(
private env: Env,
// ⚠️ 必须跟 Agent.sql 的签名对齐(Record + 窄 value union),
// 不然 open-pr.ts 里 `new GithubApp(env, agent.sql.bind(agent))` tsc 报
// "unknown vs string|number|boolean|null"
private sql: <T = Record<string, string | number | boolean | null>>(
s: TemplateStringsArray,
...v: (string | number | boolean | null)[]
) => T[]
) {
this.sql`CREATE TABLE IF NOT EXISTS gh_tokens (
installation_id TEXT PRIMARY KEY,
token TEXT NOT NULL,
expires_at INTEGER NOT NULL
)`;
}
async installationToken(installationId: string): Promise<string> {
const rows = this.sql<TokenRow>`
SELECT token, expires_at FROM gh_tokens WHERE installation_id = ${installationId}
`;
const row = rows[0];
// 提前 5 分钟刷新
if (row && row.expires_at - Date.now() > 5 * 60 * 1000) return row.token;
const jwt = await this.signAppJwt();
const res = await fetch(
`https://api.github.com/app/installations/${installationId}/access_tokens`,
{
method: "POST",
headers: {
authorization: `Bearer ${jwt}`,
accept: "application/vnd.github+json",
"user-agent": "agent-coder",
},
}
);
if (!res.ok) throw new Error(`installation token ${res.status}: ${await res.text()}`);
const body = (await res.json()) as { token: string; expires_at: string };
const expiresAt = new Date(body.expires_at).getTime();
this.sql`
INSERT OR REPLACE INTO gh_tokens (installation_id, token, expires_at)
VALUES (${installationId}, ${body.token}, ${expiresAt})
`;
return body.token;
}
// RS256 JWT,Workers runtime 自带 crypto.subtle
private async signAppJwt(): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const header = { alg: "RS256", typ: "JWT" };
const payload = { iat: now - 30, exp: now + 9 * 60, iss: this.env.GITHUB_APP_ID };
const enc = (o: unknown) => base64url(new TextEncoder().encode(JSON.stringify(o)));
const data = `${enc(header)}.${enc(payload)}`;
const key = await crypto.subtle.importKey(
"pkcs8",
pemToPkcs8(this.env.GITHUB_APP_PRIVATE_KEY),
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(data));
return `${data}.${base64url(new Uint8Array(sig))}`;
}
}
function base64url(bytes: Uint8Array): string {
let bin = "";
bytes.forEach((b) => (bin += String.fromCharCode(b)));
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function pemToPkcs8(pem: string): ArrayBuffer {
const body = pem
.replace(/-----BEGIN [^-]+-----/, "")
.replace(/-----END [^-]+-----/, "")
.replace(/\s+/g, "");
const bin = atob(body);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf.buffer;
}
GitHub 当前只接受 RS256 JWT —— ed25519 留给 deploy key 那条副线。两件事不要混。
第三步:src/github/pr.ts — octokit 包一层
# Terminal
npm install @octokit/core
// src/github/pr.ts
import { Octokit } from "@octokit/core";
import type { GithubApp } from "./app";
export class GithubClient {
constructor(private app: GithubApp, private installationId: string) {}
private async kit(): Promise<Octokit> {
const token = await this.app.installationToken(this.installationId);
return new Octokit({ auth: token, userAgent: "agent-coder" });
}
async openPullRequest(input: {
owner: string; repo: string; head: string; base?: string; title: string; body: string;
}): Promise<{ url: string; number: number }> {
const kit = await this.kit();
const res = await kit.request("POST /repos/{owner}/{repo}/pulls", {
owner: input.owner, repo: input.repo,
head: input.head, base: input.base ?? "main",
title: input.title, body: input.body,
});
return { url: res.data.html_url, number: res.data.number };
}
async commentOnPr(input: {
owner: string; repo: string; number: number; body: string;
}): Promise<void> {
const kit = await this.kit();
await kit.request("POST /repos/{owner}/{repo}/issues/{issue_number}/comments", {
owner: input.owner, repo: input.repo, issue_number: input.number, body: input.body,
});
}
// merge 故意不暴露成 LLM 工具 —— 走 HITL
async mergePullRequest(input: {
owner: string; repo: string; number: number; method?: "merge" | "squash" | "rebase";
}): Promise<void> {
const kit = await this.kit();
await kit.request("PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge", {
owner: input.owner, repo: input.repo,
pull_number: input.number, merge_method: input.method ?? "squash",
});
}
}
第四步:openPullRequest 工具(push + 开 PR + 发邮件)
合三件事到一个工具,避免 LLM 拆开调出现“开了 PR 没发邮件“这种半成品。
// src/tools/open-pr.ts
import { tool } from "ai";
import { z } from "zod";
import { getSandbox } from "@cloudflare/sandbox";
import { GithubApp } from "../github/app";
import { GithubClient } from "../github/pr";
import { sendPrReadyMail } from "./send-mail";
import type { CoderAgent } from "../agent";
import type { CoderSandbox } from "../sandbox";
type Env = {
Sandbox: DurableObjectNamespace<CoderSandbox>;
GITHUB_APP_ID: string;
GITHUB_APP_PRIVATE_KEY: string;
EMAIL: SendEmail; // wrangler.jsonc 的 send_email binding
EMAIL_SECRET: string; // HMAC reply 头签名密钥
};
export const openPullRequest = (agent: CoderAgent, env: Env) =>
tool({
description:
"Mirror the current Artifacts branch to GitHub as a pull request, " +
"then notify the issue reporter by email. Returns the PR URL.",
inputSchema: z.object({
repo: z.string().describe("owner/name on GitHub"),
installationId: z.string(),
branch: z.string().describe("e.g. fix/issue-42"),
base: z.string().optional(),
title: z.string(),
body: z.string(),
reporterEmail: z.string().email().optional(),
}),
execute: async ({ repo, installationId, branch, base, title, body, reporterEmail }) => {
const [owner, name] = repo.split("/");
const sandbox = getSandbox(env.Sandbox, agent.name);
// 1. 拿 token
const app = new GithubApp(env, agent.sql.bind(agent));
const token = await app.installationToken(installationId);
// 2. sandbox 里加 github remote(临时 URL,推完即删),push 当前分支
const remote = `https://x-access-token:${token}@github.com/${owner}/${name}.git`;
const pushScript = [
"cd /workspace/repo",
"git remote remove github 2>/dev/null || true",
`git remote add github ${remote}`,
`git push -u github ${branch}`,
"git remote remove github", // 删掉 alias,token 不留 .git/config
].join(" && ");
const push = await sandbox.exec(pushScript, { timeout: 2 * 60 * 1000 });
if (push.exitCode !== 0) {
return { ok: false, stage: "push", error: push.stderr.slice(-1000) };
}
// 3. 开 PR
const gh = new GithubClient(app, installationId);
const pr = await gh.openPullRequest({
owner, repo: name, head: branch, base, title, body,
});
// 4. 发邮件通知 reporter(可选)
let mailed = false;
if (reporterEmail) {
await sendPrReadyMail(agent, env, {
to: reporterEmail,
prUrl: pr.url,
prNumber: pr.number,
repo,
installationId,
title,
});
mailed = true;
}
return { ok: true, prUrl: pr.url, prNumber: pr.number, mailed };
},
});
几个细节:
git remote add github用临时 URL 把 token 直接拼进去,push 完立刻git remote remove github——.git/config不留任何凭据。- token 不进容器 env、不写盘,只在一次
sandbox.exec命令的字符串里出现。沙箱被回收时 token 一起消失。 agent.sql.bind(agent)把 Think 的模板字符串方法传给GithubApp—— GithubApp 不需要知道 agent,只需要一份能写 SQL 的句柄。
第五步:src/tools/send-mail.ts — Cloudflare Email Service
Email Service for agents 在 Agents Week 进 public beta,Workers binding 直接可用,domain 加进控制台后 SPF/DKIM/DMARC 自动配置(REAL_API_v2 §I.4)。
// src/tools/send-mail.ts
import type { CoderAgent } from "../agent";
type Env = {
EMAIL: SendEmail;
EMAIL_SECRET: string;
};
export async function sendPrReadyMail(
agent: CoderAgent,
env: Env,
args: {
to: string;
prUrl: string;
prNumber: number;
repo: string;
installationId: string;
title: string;
}
): Promise<void> {
const subject = `[agent-coder] PR ready: ${args.title}`;
const text = [
`Your fix is ready for review:`,
args.prUrl,
``,
`Reply to this email to leave a comment on the PR.`,
`(Replies are routed back to this conversation.)`,
].join("\n");
// sendEmail 来自 Think 基类(继承自 agents/Agent);from 用 { email, name } 对象传显示名,
// SendEmailOptions 没有顶层 fromName 字段(REAL_API_v2 §I.4)。
await agent.sendEmail({
binding: env.EMAIL,
from: { email: "[email protected]", name: "agent-coder" },
to: args.to,
subject,
text,
secret: env.EMAIL_SECRET, // HMAC 签名 reply 头,确保回信能路由回当前 agent
});
// 把 reporter 与 PR 的对应关系存下来,onEmail 时知道往哪条 PR comment
agent.sql`
INSERT INTO pr_threads (reporter_email, repo, pr_number, installation_id, ts)
VALUES (${args.to}, ${args.repo}, ${args.prNumber}, ${args.installationId}, ${Date.now()})
`;
}
agent.sendEmail({ ..., secret }) 用 HMAC-SHA256 签 reply 头(REAL_API_v2 §I.4),用户回的邮件能精确路由回当前 conversation 的 agent 实例,不会被攻击者伪造头部劫持到别的 agent。
第六步:onEmail — 用户回信 → PR comment
Agent.onEmail 是 Think 从底层 Agent 继承来的钩子,接收 AgentEmail(含 from / to / headers / getRaw / reply / ...,REAL_API_v2 §I.4)。
// src/agent.ts(增量,加在 CoderAgent 类内)
import type { AgentEmail } from "agents/email";
import { GithubApp } from "./github/app";
import { GithubClient } from "./github/pr";
async onEmail(email: AgentEmail) {
const rows = this.sql<{ repo: string; pr_number: number; installation_id: string }>`
SELECT repo, pr_number, installation_id
FROM pr_threads
WHERE reporter_email = ${email.from}
ORDER BY ts DESC LIMIT 1
`;
const thread = rows[0];
if (!thread) {
// 不认识的发件人,直接 reject 进 spam(不要 reply,避免 backscatter)
email.setReject("Unknown sender; this address only handles replies to PR notifications.");
return;
}
// 解析正文。轻量做法:只取 text/plain 第一段,去掉 quote line
const raw = await email.getRaw();
const text = new TextDecoder().decode(raw);
const reply = extractReplyBody(text);
const [owner, name] = thread.repo.split("/");
const app = new GithubApp(this.env, this.sql.bind(this));
const gh = new GithubClient(app, thread.installation_id);
await gh.commentOnPr({
owner, repo: name, number: thread.pr_number,
body: `**Comment from ${email.from} via email:**\n\n${reply}`,
});
// 回执
await this.replyToEmail(email, {
fromName: "agent-coder",
body: `Posted as a comment on ${owner}/${name}#${thread.pr_number}.`,
secret: this.env.EMAIL_SECRET,
});
}
// 极简正文抽取:取顶段、丢引用行
function extractReplyBody(raw: string): string {
const bodyStart = raw.indexOf("\r\n\r\n");
const body = bodyStart >= 0 ? raw.slice(bodyStart + 4) : raw;
return body
.split(/\r?\n/)
.filter((l) => !l.trimStart().startsWith(">"))
.join("\n")
.split(/On .+ wrote:/)[0]
.trim();
}
worker entry 里挂一个 email() handler,把 inbound 路由到 agent 实例:
// src/index.ts(增量)
import { routeAgentEmail } from "agents";
import { createSecureReplyEmailResolver } from "agents/email";
export default {
// 已有的 fetch 不动
async fetch(request: Request, env: Env) { /* ... */ },
// 新加 email handler
async email(message, env: Env) {
await routeAgentEmail(message, env, {
// 这个 resolver 验证 reply 头的 HMAC,只把信送到原 agent 实例
resolver: createSecureReplyEmailResolver({
agentName: "CoderAgent",
secret: env.EMAIL_SECRET,
}),
});
},
} satisfies ExportedHandler<Env>;
createSecureReplyEmailResolver 要 reply 头里的 HMAC 与 EMAIL_SECRET 一致才放行;伪造头的邮件会被丢到默认目录(可继续配 fallback resolver,这里略)。
第七步:Workflow 收尾时调 openPullRequest
回到第 8 章的 SolveIssue,在 commit step 之后再加一步:
// src/workflows/solve-issue.ts(增量)
// commit 成功之后:
if (event.payload.repo && event.payload.installationId) {
const pr = await step.do(
`pr-${attempt}`,
{ retries: { limit: 3, delay: "10 seconds", backoff: "exponential" } },
async () => {
// 通过 agent 上的 callable 入口直接调
return this.agent.publishPr({
repo: event.payload.repo!,
installationId: event.payload.installationId!,
branch: `fix/issue-${issue.id}`,
title: `Fix: ${issue.title}`,
body: `Resolves #${issue.id}\n\n${testSummary.slice(0, 1500)}`,
reporterEmail: event.payload.reporterEmail,
});
}
);
return { ok: true, attempts: attempt, changedFiles, artifactCommit: commit, testSummary, pr };
}
publishPr 是主 agent 上 @callable 包装的方法,内部直接复用 openPullRequest 工具的 execute,避免 Workflow 这一层重新组装 tool。SolveIssueParams 加上 repo? / installationId? / reporterEmail? 三个可选字段。
Merge 必须 HITL
mergePullRequest 不注册成 LLM 工具。两条路触发:
- 前端用户点 “Approve & merge” →
@callable方法 →gh.mergePullRequest - 客户端 confirmation 中间件(第 4 章建立的 HITL 机制):LLM 想 merge → 弹给前端 → 用户点确认才走
// src/agent.ts(增量)
@callable()
async approveMerge(input: { repo: string; pullNumber: number; installationId: string }) {
const app = new GithubApp(this.env, this.sql.bind(this));
const gh = new GithubClient(app, input.installationId);
const [owner, name] = input.repo.split("/");
await gh.mergePullRequest({ owner, repo: name, number: input.pullNumber });
return { ok: true };
}
任何让 LLM 自己按下 merge 按钮的设计,都是把生产仓库的钥匙交给概率模型。
wrangler 增量
// wrangler.jsonc(增量,只展示新增字段)
{
// ... 第 8 章的 ai / durable_objects / containers / r2_buckets / artifacts /
// workflows / migrations 不动
"send_email": [
{ "name": "EMAIL" }
]
// GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY / EMAIL_SECRET 走 wrangler secret put,
// 不进 wrangler.jsonc。
}
inbound email 走 Email Routing:在 Cloudflare 控制台把 @notify.your-domain.com 配成“Send to a Worker → agent-coder“。Worker 里的 email() handler 自动接管。
图 9-2:openPullRequest 端到端
验证
走一遍完整 issue → PR + Email 流程。前提:agent-coder GitHub App 已装到 acme/playground、notify.your-domain.com 在控制台已加进 Email Service 与 Email Routing。
# Terminal —— 启动一个 conversation 并初始化 Artifacts(沿用第 7 章)
curl -X POST 'http://localhost:8787/agents/coder-agent/demo/init' \
-H 'content-type: application/json' \
-d '{"sourceRepo":"https://github.com/acme/playground.git"}'
# Terminal —— 扔 issue,带上 repo / installationId / reporterEmail
curl -X POST 'http://localhost:8787/api/issues/42/solve?conversation=demo' \
-H 'content-type: application/json' \
-d '{
"title": "Fix README typo",
"body": "README.md 第 7 行 Wrokrs 应为 Workers。",
"repo": "acme/playground",
"installationId": "12345678",
"reporterEmail": "[email protected]"
}'
# {"instanceId":"wf_01HZX..."}
约 60 秒后,Workflow 完成,result 里有:
{
"ok": true,
"attempts": 1,
"changedFiles": ["README.md"],
"artifactCommit": { "sha": "9c4f...e1", "ref": "refs/heads/fix/issue-42" },
"pr": {
"ok": true,
"prUrl": "https://github.com/acme/playground/pull/77",
"prNumber": 77,
"mailed": true
}
}
去 GitHub,PR 在那儿:
- Author:
agent-coder[bot] - Branch:
fix/issue-42 → main - Files changed:
README.md,1 行
Alice 邮箱里有一封:
From: agent-coder <[email protected]>
Subject: [agent-coder] PR ready: Fix README typo
Your fix is ready for review:
https://github.com/acme/playground/pull/77
Reply to this email to leave a comment on the PR.
Alice 直接回复“LGTM, please squash & merge“,onEmail 把这段抽成 PR comment,几秒后她在 GitHub 上能看见。reviewer 一点 Approve、再点 merge —— 或前端调 approveMerge —— 整个闭环完成。
边界与坑
- Installation token 1 小时过期。
GithubApp.installationToken每次重取,别把 token 缓存到工具入参里。Workflow 跨小时长跑时,step.do("pr", ...)内每次都重取一次。 git push失败十有八九是凭据。先sandbox.exec("curl -u x-access-token:$TOKEN https://api.github.com/repos/owner/repo")验 token,再排 git。- Email Service 是 public beta(send),Routing(receive)是 GA(REAL_API_v2 §I.4)。生产前在控制台核对域已 verify、SPF/DKIM/DMARC 都绿。
onEmail必须设 reply HMAC secret(createSecureReplyEmailResolver+sendEmail({ secret })),否则攻击者伪造 reply 头能把信路由到任意 agent 实例 —— 这是“agent + email“ 类方案最大的安全坑。- 回信抽取要保守。我们的
extractReplyBody只丢明显的引用行;HTML-only 邮件、Outlook 风格的 quoted-printable 要走专门库(如postal-mime,Cloudflare 官方 Email Service 示例就用它)。 - PR body 里别贴 token / private key。LLM 可能误把 env 内容塞进去,在
openPullRequest入口正则扫一下:ghs_/ghp_/-----BEGIN直接拒。 - Bot commit 默认不算 verified。要 commit 签名,在 GitHub App 设置开 “Sign commits as the bot” 并改用 GraphQL
createCommitOnBranch(本章未涵盖)。 - PEM 是多行字符串。用
wrangler secret put GITHUB_APP_PRIVATE_KEY < private-key.pem喂文件最稳,部分终端粘贴会丢换行。
延伸阅读
- Email for Agents 公告 — Email Sending public beta、Agents SDK onEmail
- Agents SDK Email API —
routeAgentEmail/createSecureReplyEmailResolver/AgentEmail - Email Service 文档 — Send / domain 配置 / SPF&DKIM 自动化
- GitHub Apps overview — App 注册、installation、权限粒度
下一章预告
到这里 agent 已经会规划、写码、测试、入库 Artifacts、镜像 PR 到 GitHub、发邮件通知、接住回信评论。但它跑在你的 wrangler dev 上,没人鉴权、没限流、没监控、没灰度、没 OAuth 接下游。下一章我们让这个 agent 真正能上线 —— Managed OAuth for Access、Mesh 接 VPC、AI Gateway 计费缓存、Flagship 灰度 prompt、把自己暴露成 MCP server,以及 token / Secret Scanning 的最佳实践。