Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 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

维度ArtifactsGitHub
写延迟binding 直连,毫秒级API 限流,百毫秒级
Token 寿命几分钟到几小时,内置 expiryinstallation 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 githubSandbox 里跑 —— 沙箱已经 clone 好仓库,直接 sandbox.exec 一行命令。
  • 开 PR、评论、merge 在 Worker 里走 @octokit/core —— token 不进容器,降低泄漏面。

图 9-1:Artifacts → GitHub → Email 三段链路

Artifacts 是 origin,GitHub 是 mirror,Email 是通知与回路入口 CoderAgent Workflow / Editor Artifacts origin (binding) Sandbox /workspace/repo git push github HEAD:branch GitHub mirror + PR env.EMAIL.send PR 链接 → reporter onEmail 回信 → PR comment 写流向 → | 邮件回路 ⇡⇣

落地

文件布局增量:

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 & writePull requests: Read & writeIssues: Read & writeMetadata: 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 工具。两条路触发:

  1. 前端用户点 “Approve & merge” → @callable 方法 → gh.mergePullRequest
  2. 客户端 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 端到端

openPullRequest 工具:拿 token、push 到 GitHub、开 PR、发邮件 Workflow step step.do pr-N GithubApp.installationToken 命中 this.sql 缓存或重签 Sandbox.exec git remote add github https://x-access-token:TOKEN@... git push -u github branch && git remote remove github Worker octokit POST /repos/.../pulls → html_url + number env.EMAIL.send to: reporter / subject + PR url + HMAC reply 头 token 走 sandbox.exec 命令字符串,不留盘 token 走 octokit auth,缓存在 this.sql

验证

走一遍完整 issue → PR + Email 流程。前提:agent-coder GitHub App 已装到 acme/playgroundnotify.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 喂文件最稳,部分终端粘贴会丢换行。

延伸阅读

下一章预告

到这里 agent 已经会规划、写码、测试、入库 Artifacts、镜像 PR 到 GitHub、发邮件通知、接住回信评论。但它跑在你的 wrangler dev 上,没人鉴权、没限流、没监控、没灰度、没 OAuth 接下游。下一章我们让这个 agent 真正能上线 —— Managed OAuth for Access、Mesh 接 VPC、AI Gateway 计费缓存、Flagship 灰度 prompt、把自己暴露成 MCP server,以及 token / Secret Scanning 的最佳实践。