Background Agents(Open-Inspect)可以把来自 Web UI、Slack、GitHub PR、Linear、Webhook、定时任务等入口的请求,转换成一个后台编码会话。系统里最重要的边界不是“哪个页面调用哪个接口”,而是下面几层职责是否分清:
text外部入口 ├─ Web UI ├─ Slack Bot ├─ GitHub PR / Issue / Comment ├─ Linear Issue ├─ Webhook └─ Cron / Sentry 等触发器 │ ▼ 控制平面 Control Plane - 登录、权限入口、会话编排 - 创建 session / task - 分配沙箱 - 颁发短期 token - WebSocket 实时状态 - GitHub App token broker │ ▼ 数据平面 Data Plane - sessions / messages / events - users / identities - repo metadata - sandbox 状态 - agent 输出、日志、审计数据 │ ▼ 沙箱运行时 Sandbox Runtime - clone / fetch / push - 运行 Agent - 执行命令、测试、浏览器自动化 - VS Code / dev tools - git credential helper 按需向控制平面取 token │ ▼ 代码仓库 / PR / 外部系统
项目 README 中强调了一个关键前提:Open-Inspect 当前按单租户模型设计。也就是说,所有使用者默认是同一个组织内的可信成员,控制平面通过共享 GitHub App 安装来访问仓库。
这会直接影响包结构和职责划分:系统不是每个用户独立持有完整仓库凭据,而是由控制平面统一 broker GitHub App token,沙箱按会话拿短期凭据。
控制平面负责“决定发生什么”,但不应该直接执行仓库里的代码。
典型职责:
| 职责 | 说明 |
|---|---|
| 身份认证 | GitHub / Google / SSO 等登录入口 |
| 会话创建 | 根据用户输入、Bot 事件或定时任务创建 session |
| 沙箱调度 | 为 session 分配或启动一个隔离运行环境 |
| Token 颁发 | 颁发 sandbox auth token、WebSocket token、GitHub App installation token |
| 状态同步 | 通过 HTTP API / WebSocket 向前端和 Bot 推送状态 |
| PR 创建协调 | 对 GitHub 登录用户优先使用用户 OAuth token 创建 PR,否则回退 GitHub App bot |
| 外部集成编排 | Slack、GitHub、Linear、Webhook 只把事件转成内部任务,不直接操作沙箱 |
控制平面需要保存长期配置,例如 GitHub App private key、OAuth client secret、数据库连接信息。沙箱运行时不应该持有这些长期密钥。
数据平面是系统状态的来源。它通常包括数据库、对象存储、日志流或事件表。
常见数据对象:
tstype Session = {
id: string;
repo: string;
branch: string;
createdByUserId: string;
source: "web" | "slack" | "github" | "linear" | "webhook" | "cron";
status: "queued" | "running" | "waiting" | "completed" | "failed";
sandboxId?: string;
createdAt: string;
};
type Message = {
id: string;
sessionId: string;
role: "user" | "agent" | "system" | "tool";
content: string;
createdAt: string;
};
type SessionEvent = {
id: string;
sessionId: string;
type:
| "session.created"
| "sandbox.started"
| "agent.output"
| "git.push"
| "pr.created"
| "session.failed";
payload: unknown;
createdAt: string;
};边界原则:
沙箱是实际跑代码的地方。它能访问完整开发环境,包括 Node.js、Python、git、浏览器自动化、VS Code、测试命令等。
典型职责:
| 职责 | 说明 |
|---|---|
| 拉取仓库 | 通过 git credential helper 向控制平面请求短期 GitHub App token |
| 执行 agent | 运行 Claude / Codex / OpenCode Zen 等 agent 后端 |
| 执行命令 | npm test、pytest、构建、lint、脚本 |
| 修改文件 | 在隔离目录中改代码 |
| 提交变更 | commit / push 到远端分支 |
| 回传状态 | 将日志、stdout、tool call、完成状态发回控制平面 |
| 子任务并行 | 为 parallel sub-task 启动独立 sandbox,避免工作区互相污染 |
沙箱不应该知道:
它只应该拿到当前 session 所需的短期 token。
各类 Bot 的共同模式是:接收外部事件 → 校验来源 → 提取上下文 → 调用控制平面创建 session 或追加 message。
适合场景:
@agent fix this bugSlack Bot 不应该自己 clone 仓库,也不应该自己创建 PR。它只需要把 Slack message、thread、user identity 转成内部任务。
tstype SlackTaskInput = {
source: "slack";
slackTeamId: string;
channelId: string;
threadTs: string;
text: string;
requestedBy: string;
};适合场景:
GitHub Bot 需要处理 webhook 事件,例如:
tstype GitHubTaskInput = {
source: "github";
installationId: number;
repository: string;
issueOrPrNumber: number;
commentBody: string;
requestedByGitHubLogin: string;
};注意:README 里明确说明,git clone/fetch/push 依赖共享 GitHub App installation token;PR 创建优先使用 GitHub 登录用户的 OAuth token,以保证 PR attribution。
适合场景:
Linear Bot 本质上也是任务入口,不应该绕过控制平面直接访问沙箱。
适合场景:
这类入口需要特别注意幂等性,避免同一个告警重复创建大量 session。
共享包通常承载跨进程、跨应用复用的内容:
| 共享内容 | 应放在共享包的原因 |
|---|---|
| API 类型 | Web、Bot、Sandbox 调用同一套 DTO |
| 事件类型 | 控制平面、WebSocket、前端、日志消费者需要一致 |
| Token claims | 控制平面签发,沙箱校验或携带 |
| repo/session schema | 多个入口都需要创建 session |
| 环境变量校验 | 启动时统一检查配置 |
| GitHub / Slack / Linear 通用工具 | 避免每个 Bot 重复解析 payload |
共享包不应该包含:
可以用下面的接口理解共享包的边界:
tsexport type TaskSource =
| "web"
| "slack"
| "github"
| "linear"
| "webhook"
| "cron";
export type CreateSessionRequest = {
source: TaskSource;
repo: string;
branch?: string;
prompt: string;
requestedByUserId: string;
metadata?: Record<string, unknown>;
};
export type SandboxAuthClaims = {
sessionId: string;
sandboxId: string;
exp: number;
};
export type AgentEvent =
| {
type: "agent.output";
sessionId: string;
content: string;
}
| {
type: "git.push";
sessionId: string;
branch: string;
commitSha: string;
}
| {
type: "pr.created";
sessionId: string;
url: string;
};实际仓库的目录命名可能会调整,但阅读或扩展项目时,可以按下面的逻辑理解包结构:
textbackground-agents/ apps/ web/ # Web UI,用户入口,实时 session 页面 control-plane/ # API、auth、session 编排、token broker、WebSocket sandbox-runtime/ # 沙箱内 agent runner、git helper、命令执行 worker/ # 后台队列、定时任务、异步事件处理 bots/ slack/ # Slack event adapter github/ # GitHub webhook adapter linear/ # Linear webhook adapter webhook/ # 通用 webhook adapter packages/ shared/ # 类型、协议、事件、常量 db/ # schema、query、migration auth/ # token claims、session auth、OAuth helper github/ # GitHub App、OAuth、PR helper integrations/ # Slack / Linear 等 SDK 封装 config/ # 环境变量校验和运行配置 logger/ # 结构化日志
重点不是目录名必须完全一致,而是依赖方向要清楚:
textBot / Web / Worker │ ▼ Control Plane │ ├── Data Plane │ ├── Shared Packages │ └── Sandbox Runtime API │ ▼ Sandbox Process
推荐依赖方向:
textapps/* -> packages/* control-plane -> db / auth / github / shared sandbox -> shared,不直接依赖 control-plane 内部实现 bots -> shared + control-plane client shared -> 不依赖 apps/* db -> 不依赖 web、bot、sandbox
不要出现这种依赖:
textsandbox-runtime -> control-plane/src/internal/db slack-bot -> sandbox-runtime/src/runner shared -> apps/web/components db -> apps/github-bot/handler
这些依赖会让系统变成一团:Bot 改动会影响沙箱,沙箱升级会影响数据库,最后无法独立部署。
README 中的 token model 可以简化成下面几条链路。
textSandbox git │ │ git credential helper ▼ Control Plane │ │ mint GitHub App installation token ▼ GitHub
特点:
textControl Plane │ ├─ 如果用户通过 GitHub 登录,并且有 OAuth token │ └─ 用用户 OAuth token 创建 PR │ └─ 否则 └─ 用 GitHub App bot 创建 PR
这样做的目的:
textSandbox │ │ Authorization: Bearer <sandbox-auth-token> ▼ Control Plane │ └─ 校验 token 只属于当前 session
sandbox token 的 scope 应该尽量小:
tstype SandboxTokenScope = {
sessionId: string;
sandboxId: string;
permissions: Array<
| "session:read"
| "event:write"
| "git-token:request"
| "artifact:upload"
>;
expiresAt: string;
};下面的示例不依赖任何第三方包,只用 Node.js 内置模块。它模拟一次 GitHub 评论触发后台 agent 的完整路径:
保存为 architecture-demo.mjs:
jsimport crypto from "node:crypto";
function id(prefix) {
return `${prefix}_${crypto.randomBytes(4).toString("hex")}`;
}
class DataPlane {
constructor() {
this.sessions = new Map();
this.events = [];
}
createSession(input) {
const session = {
id: id("sess"),
repo: input.repo,
branch: input.branch ?? "main",
source: input.source,
prompt: input.prompt,
requestedBy: input.requestedBy,
status: "queued",
sandboxId: null,
createdAt: new Date().toISOString(),
};
this.sessions.set(session.id, session);
this.addEvent(session.id, "session.created", {
source: session.source,
repo: session.repo,
});
return session;
}
updateSession(sessionId, patch) {
const current = this.sessions.get(sessionId);
if (!current) throw new Error(`session not found: ${sessionId}`);
const next = { ...current, ...patch };
this.sessions.set(sessionId, next);
return next;
}
addEvent(sessionId, type, payload) {
this.events.push({
id: id("evt"),
sessionId,
type,
payload,
createdAt: new Date().toISOString(),
});
}
printEvents() {
for (const event of this.events) {
console.log(
`[event] ${event.type} session=${event.sessionId} payload=${JSON.stringify(
event.payload,
)}`,
);
}
}
}
class ControlPlane {
constructor(dataPlane) {
this.dataPlane = dataPlane;
this.sandboxTokens = new Map();
}
createSession(request) {
return this.dataPlane.createSession(request);
}
startSandbox(sessionId) {
const sandboxId = id("sbox");
const token = id("sandbox_token");
this.sandboxTokens.set(token, {
sessionId,
sandboxId,
expiresAt: Date.now() + 5 * 60 * 1000,
permissions: ["session:read", "event:write", "git-token:request"],
});
this.dataPlane.updateSession(sessionId, {
status: "running",
sandboxId,
});
this.dataPlane.addEvent(sessionId, "sandbox.started", {
sandboxId,
});
return {
sandboxId,
sandboxAuthToken: token,
};
}
requestGitHubAppToken(sandboxAuthToken, repo) {
const claims = this.sandboxTokens.get(sandboxAuthToken);
if (!claims) {
throw new Error("invalid sandbox token");
}
if (claims.expiresAt < Date.now()) {
throw new Error("expired sandbox token");
}
if (!claims.permissions.includes("git-token:request")) {
throw new Error("sandbox token cannot request git token");
}
const session = this.dataPlane.sessions.get(claims.sessionId);
if (!session) {
throw new Error("session not found");
}
if (session.repo !== repo) {
throw new Error(
`repo mismatch: token belongs to ${session.repo}, requested ${repo}`,
);
}
this.dataPlane.addEvent(claims.sessionId, "git-token.issued", {
repo,
sandboxId: claims.sandboxId,
ttlSeconds: 60,
});
return {
token: id("gh_app_token"),
repo,
expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
};
}
writeAgentEvent(sandboxAuthToken, type, payload) {
const claims = this.sandboxTokens.get(sandboxAuthToken);
if (!claims) {
throw new Error("invalid sandbox token");
}
this.dataPlane.addEvent(claims.sessionId, type, payload);
}
createPullRequest(sessionId, requestedBy) {
const session = this.dataPlane.sessions.get(sessionId);
if (!session) throw new Error(`session not found: ${sessionId}`);
const actor =
requestedBy.githubOAuthToken != null
? requestedBy.githubLogin
: "github-app-bot";
const prUrl = `https://github.com/${session.repo}/pull/123`;
this.dataPlane.addEvent(sessionId, "pr.created", {
url: prUrl,
actor,
});
this.dataPlane.updateSession(sessionId, {
status: "completed",
});
return {
url: prUrl,
actor,
};
}
}
class SandboxRuntime {
constructor(controlPlane, input) {
this.controlPlane = controlPlane;
this.sandboxId = input.sandboxId;
this.sandboxAuthToken = input.sandboxAuthToken;
this.repo = input.repo;
}
run(prompt) {
console.log(`[sandbox] ${this.sandboxId} started`);
console.log(`[sandbox] prompt: ${prompt}`);
const gitToken = this.controlPlane.requestGitHubAppToken(
this.sandboxAuthToken,
this.repo,
);
console.log(
`[sandbox] received short-lived git token for ${gitToken.repo}, expiresAt=${gitToken.expiresAt}`,
);
this.controlPlane.writeAgentEvent(
this.sandboxAuthToken,
"agent.output",
{
content: "I inspected the failing test and changed the parser.",
},
);
this.controlPlane.writeAgentEvent(this.sandboxAuthToken, "git.push", {
branch: "agent/fix-parser",
commitSha: crypto.randomBytes(20).toString("hex"),
});
console.log("[sandbox] pushed branch agent/fix-parser");
}
}
class GitHubBot {
constructor(controlPlane) {
this.controlPlane = controlPlane;
}
handleIssueComment(event) {
console.log(
`[github-bot] comment from ${event.commentAuthor}: ${event.commentBody}`,
);
const session = this.controlPlane.createSession({
source: "github",
repo: event.repo,
branch: event.baseBranch,
prompt: event.commentBody,
requestedBy: event.commentAuthor,
metadata: {
issueNumber: event.issueNumber,
},
});
const sandbox = this.controlPlane.startSandbox(session.id);
return {
session,
sandbox,
};
}
}
const dataPlane = new DataPlane();
const controlPlane = new ControlPlane(dataPlane);
const githubBot = new GitHubBot(controlPlane);
const { session, sandbox } = githubBot.handleIssueComment({
repo: "acme/api-service",
baseBranch: "main",
issueNumber: 42,
commentAuthor: "octocat",
commentBody: "@agent fix the parser test failure",
});
const runtime = new SandboxRuntime(controlPlane, {
sandboxId: sandbox.sandboxId,
sandboxAuthToken: sandbox.sandboxAuthToken,
repo: session.repo,
});
runtime.run(session.prompt);
const pr = controlPlane.createPullRequest(session.id, {
githubLogin: "octocat",
githubOAuthToken: "user-oauth-token",
});
console.log(`[control-plane] created PR ${pr.url} as ${pr.actor}`);
console.log("\n--- data plane events ---");
dataPlane.printEvents();运行:
bashnode architecture-demo.mjs
示例输出:
text[github-bot] comment from octocat: @agent fix the parser test failure [sandbox] sbox_8df9c621 started [sandbox] prompt: @agent fix the parser test failure [sandbox] received short-lived git token for acme/api-service, expiresAt=... [sandbox] pushed branch agent/fix-parser [control-plane] created PR https://github.com/acme/api-service/pull/123 as octocat --- data plane events --- [event] session.created session=sess_... payload={"source":"github","repo":"acme/api-service"} [event] sandbox.started session=sess_... payload={"sandboxId":"sbox_..."} [event] git-token.issued session=sess_... payload={"repo":"acme/api-service","sandboxId":"sbox_...","ttlSeconds":60} [event] agent.output session=sess_... payload={"content":"I inspected the failing test and changed the parser."} [event] git.push session=sess_... payload={"branch":"agent/fix-parser","commitSha":"..."} [event] pr.created session=sess_... payload={"url":"https://github.com/acme/api-service/pull/123","actor":"octocat"}
这个小例子对应到真实系统时,类之间可以这样映射:
| 示例类 | 真实模块 |
|---|---|
GitHubBot |
GitHub webhook / comment adapter |
ControlPlane |
API server、session orchestrator、token broker |
DataPlane |
数据库、事件表、日志流 |
SandboxRuntime |
沙箱内 agent runner、git helper、命令执行环境 |
requestGitHubAppToken() |
GitHub App installation token broker |
createPullRequest() |
PR 创建服务,优先使用用户 OAuth token |
Open-Inspect 的 README 已经说得很明确:当前系统是 single-tenant only。
实际含义是:
因此部署时不要把它当成 SaaS 多租户系统开放给外部用户。
建议:
README 提到系统支持 parallel sub-tasks。这里的边界很重要:
text主 session ├─ subtask A -> sandbox A -> branch agent/a ├─ subtask B -> sandbox B -> branch agent/b └─ subtask C -> sandbox C -> branch agent/c
不要让多个子任务共享同一个工作目录,否则会出现:
控制平面可以把多个子任务归到同一个 parent session,但运行时应该分开。
一个合理的数据模型是:
tstype SubTask = {
id: string;
parentSessionId: string;
sandboxId: string;
repo: string;
branch: string;
prompt: string;
status: "queued" | "running" | "completed" | "failed";
};WebSocket 适合做:
不适合做:
README 中提到 WebSocket token 的 scope 是 single session。也就是说,一个 WebSocket token 不应该能订阅所有 session。
可以按这种方式设计:
tstype WebSocketTokenClaims = {
userId: string;
sessionId: string;
exp: number;
};服务端订阅时必须检查:
tsfunction canSubscribe(claims, sessionId) {
return claims.sessionId === sessionId && claims.exp > Date.now() / 1000;
}错误做法:
text启动 sandbox 时直接把 GitHub App private key 放进环境变量
正确做法:
textsandbox 通过 git credential helper 按需请求短期 installation token
长期密钥只应在控制平面。
错误做法:
textSlack Bot 收到消息后 SSH 到 sandbox 执行命令
正确做法:
textSlack Bot 调用控制平面创建 session,由控制平面调度 sandbox
否则 Slack、GitHub、Linear 每个集成都要重复一套运行时逻辑。
错误做法:
textpackages/shared/createSessionAndStartSandbox.ts
正确做法:
textpackages/shared/types.ts apps/control-plane/session-service.ts
共享包应放类型和协议,业务编排留在控制平面。
README 中说明:使用 Google 等方式登录的用户没有 SCM token,PR 创建会回退到 GitHub App bot。
所以 UI 和审计日志里最好明确显示:
textPrompted by: alice@example.com PR author: github-app-bot
否则排查代码来源时会混乱。
如果要做多租户,至少需要补齐:
当前 README 的安全模型不包含这些能力。