openbot
§ 00 · Source-level Observatory

openbot AI coworker with a computer

OpenBot 不是普通聊天机器人壳,而是把 AI coworker、浏览器电脑、工具授权、人工接管、知识源同步和审计轨迹放进一个 Bun/TypeScript monorepo。它值得拆,是因为源码里对“agent 能做真实动作”这件事给出了多层边界。

★ 1,693
GitHub Stars
~42K
LOC
463
Files
2h ago
Last commit
Person → Web App → Server/Gateway ↘ Copilot Runtime → Remote Bot ↘ Policy/Audit → Agent Computer ↘ Supervisor → per-bot container
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

OpenBot 的核心问题是:如果一个 AI coworker 拥有浏览器、文件、shell 和外部工具,它怎样既能做事,又不把权限边界交给模型文本?组合根在 server/src/index.ts:297 到 server/src/index.ts:367 把 runtime、computer gateway、policy store、plugin store、channel store 和审计 store 统一装配,这说明项目把“会聊天”和“会行动”视为同一个受治理系统,而不是两个孤立功能。

源码最强的地方是边界分层:身份用 server/src/index.ts:58 和 server/src/index.ts:100 统一解析,电脑动作必须穿过 server/src/computer/gateway.ts:309,真实浏览器动作在 agent-computer/src/index.ts:27 后的独立进程里执行,Docker socket 则被缩到 supervisor/src/index.ts:14 描述的 supervisor 词汇。这样的拆分让每个风险面都有明确所有者。

▸核心数据

架构模式
Bun monorepo + server gateway + per-bot computer process + Docker supervisor + React control surface。
核心抽象
Bot、Channel、Computer、Policy、Audit、Connector、Tenant package。
性能/精度数据
GitHub Search API 显示 1,693 stars;仓库最近 2 小时仍有提交,工程活跃度高。
下游依赖
Bun、Hono、Playwright、CopilotKit、Drizzle、React、TanStack Router/Query。
读法 — 这篇主页面看整体装配和关键边界;三篇子页面分别展开 computer gateway、human handoff 和 connector/worker 持久化。
§ 01 · ARCHITECTURE

从组合根看 OpenBot 的权限拓扑

server 的组合根不是薄启动脚本,它决定了谁能看 thread、谁能运行 bot、工具权限从哪里取、computer policy 怎样加载,以及 token 如何绑定 actor/bot/run。证据集中在 入口函数是 server/src/index.ts:58,session 从 server/src/index.ts:66 进入,角色在 server/src/index.ts:71 收口;用户投影落在 server/src/index.ts:82,actor 投影落在 server/src/index.ts:100;配置和数据库从 server/src/index.ts:109 开始,agent profile 在 server/src/index.ts:121 装配,事件监听在 server/src/index.ts:149 接入,tenant 同步位于 server/src/index.ts:155,policy store 则由 server/src/index.ts:166 建立,这些引用说明 OpenBot 不是让 remote bot 自己声明身份,而是在 server 侧签发和装配。

▸系统拓扑

User session → resolveRequestActor → identifyUser / identifyActor → mountCopilotRuntime → remote bot run assertion → grantedTools(pluginStore, botId, actorId) → computerGateway(policy + audit + provider) → agent-computer(browser/workspace/shell)

▸模块清单

模块边界能从文件职责直接看出来:server/src/index.ts:121 初始化 agent profile store,server/src/index.ts:137 建 channel event hub,server/src/index.ts:146 加载 component store,server/src/index.ts:183 打开 boot audit store,server/src/index.ts:204 创建 plugin store。每个 store 都在启动时注入,而不是在请求里随意 new。

server
身份解析、tenant package、agent profile、policy store、plugin store、Copilot runtime 和 computer gateway 的组合根。
agent-computer
每个 Bot 的浏览器、workspace、shell、ARIA snapshot、human handoff 和 secret entry 都在这个进程执行。
supervisor
唯一持有 Docker socket 的小服务,只暴露 ensure/stop/reset/list 四个 Bot 语义动作。
worker
connector runner 把外部知识源同步成变更,并把 cursor 提交放在成功持久化之后。
app
React UI 管理 channel、tool line、take-the-wheel 和人工接管体验。
tests
gateway/control/connector runner 等测试覆盖拒绝路径、审计路径和 cursor 提交边界。

▸身份解析代码

下面这段来自 server/src/index.ts:58 到 server/src/index.ts:80,它把 session、role 和 fallback 错误处理集中起来。关键是 server/src/index.ts:66 读 session,server/src/index.ts:71 查询角色,server/src/index.ts:72 到 server/src/index.ts:74 fail closed。

src/server/src/index.ts · L58-80TypeScript
58async function resolveRequestActor(request: Request): Promise<{
59  id: string;
60  name: string;
61  role: OpenBotRole;
62}> {
63  if (config.devNoAuth) {
64    return { id: DEV_ACTOR.id, name: DEV_ACTOR.email, role: DEV_ACTOR.role };
65  }
66  const session = await auth?.api.getSession({ headers: request.headers });
67  const user = session?.user;
68  if (!user) {
69    throw new Error("A CopilotKit run requires a signed-in user.");
70  }
71  const roles = await roleRepository.rolesForUser(user.id);
72  if (!roles.includes("admin") && !roles.includes("user")) {
73    throw new Error("A CopilotKit run requires an authorized user.");
74  }
75  return {
76    id: user.id,
77    name: user.name ?? user.email ?? user.id,
78    role: roles.includes("admin") ? "admin" : "user",
79  };
80}
深度阅读 · Computer Gateway
§ 02 · GOVERNED ACTIONS

真实动作必须经过策略与审计

▸gateway 三段式:resolve、policy、audit

Gateway 的证据链是 工厂从 server/src/computer/gateway.ts:205 开始,地址定位在 server/src/computer/gateway.ts:229,snapshot 写入缓存由 server/src/computer/gateway.ts:266 完成;ref 解析是 server/src/computer/gateway.ts:294,govern 主流程是 server/src/computer/gateway.ts:309,策略判断在 server/src/computer/gateway.ts:356,审计先写于 server/src/computer/gateway.ts:357;拒绝由 server/src/computer/gateway.ts:369 截断,失败补行在 server/src/computer/gateway.ts:387,返回标签补充发生在 server/src/computer/gateway.ts:403。server/src/computer/gateway.ts:294 先从 server 持有的 snapshot 解析 ref,server/src/computer/gateway.ts:356 再评估策略,server/src/computer/gateway.ts:357 在真实动作前写审计,server/src/computer/gateway.ts:369 拒绝时直接抛错,server/src/computer/gateway.ts:387 则给允许后失败的动作补失败行。

src/server/src/computer/gateway.ts · L302-405TypeScript
302  /**
303   * Decide, record, then act.
304   *
305   * The audit row is written before the action runs, not after it succeeds. An allowed action that
306   * later fails is still part of the audit sequence, and a trail that only contains successes cannot
307   * show that sequence.
308   */
309  async function govern<T>(
310    toolName: string,
311    botId: string,
312    actor: ActionActor,
313    subject: {
314      ref?: string;
315      filePath?: string;
316      targetUrl?: string;
317      key?: string;
318      /** The command a shell call is about to run, so a rule can be written against it. */
319      command?: string;
320      /** The person's Stop, on its way to the browser. See the acting methods below. */
321      signal?: AbortSignal;
322    },
323    run: () => Promise<T>,
324  ): Promise<T> {
325    const { ref, filePath } = subject;
326    const element = resolve(botId, ref);
327    const cached = snapshots.get(botId);
328    // For a navigation the relevant page is the one being opened, not the one already loaded. Using
329    // the cached URL would mean `page.host == "..."` could never match the destination, which is the
330    // only thing a rule about navigation would ever want to say.
331    const pageUrl = subject.targetUrl ?? cached?.url ?? "";
332
333    const intent = intentOf(toolName, subject.key);
334
335    const context: PolicyContext = {
336      tool: { name: toolName },
337      bot: { id: botId },
338      actor: { id: actor.id },
339      page: { url: pageUrl, host: hostOf(pageUrl) },
340      ...(intent ? { intent } : {}),
341      ...(subject.key ? { key: subject.key } : {}),
342      ...(element
343        ? {
344            element: {
345              ref: element.ref,
346              role: element.role,
347              name: element.name,
348              ...(element.type ? { type: element.type } : {}),
349            },
350          }
351        : {}),
352      ...(filePath ? { file: describeFile(filePath) } : {}),
353      ...(subject.command ? { command: subject.command } : {}),
354    };
355
356    const decision = evaluateActionPolicy(options.policy(), context);
357    await write(auditStore, {
358      toolName,
359      botId,
360      actor,
361      element,
362      ref,
363      ...(subject.key ? { key: subject.key } : {}),
364      ...(subject.command ? { command: subject.command } : {}),
365      filePath,
366      pageUrl,
367      decision,
368    });
369    if (!decision.forward) {
370      throw new ActionRefusedError(decision.reason, decision.matched);
371    }
372
373    let result: T;
374    try {
375      result = await run();
376    } catch (error) {
377      /**
378       * A permitted action that did not happen gets its own row.
379       *
380       * Without this the trail lies by omission. The row above says the policy allowed the call, and
381       * a reader takes "allowed" to mean "it happened".
382       *
383       * Writing the decision before acting is still right, because an allowed action may have partial
384       * effects before failing. The failure row records the outcome separately from the policy
385       * decision.
386       */
387      await write(auditStore, {
388        toolName,
389        botId,
390        actor,
391        element,
392        ref,
393        filePath,
394        pageUrl,
395        decision,
396        failure: error instanceof Error ? error.message : "The action failed.",
397      });
398      throw error;
399    }
400    // The element's label, attached on the way out, so the transcript can say what was acted on
401    // instead of quoting a ref. The computer cannot supply this: it knows the ref, and the resolved
402    // snapshot lives here. File calls carry their own path already, so there is nothing to add.
403    return element && result && typeof result === "object"
404      ? { ...result, element: { role: element.role, name: element.name } }
405      : result;

▸策略语言不是表格,而是 CEL

server/src/computer/policy.ts:21 定义 deny/allow/mode,server/src/computer/policy.ts:44 定义上下文字段,server/src/computer/policy.ts:87 把动作意图拆成 activate/type/navigate/read/write_file 等效果,server/src/computer/policy.ts:228 执行评估。这个设计允许企业写“不要点击外部页面的 Submit”这类业务句子。

src/server/src/computer/policy.ts · L21-67TypeScript
21export type ActionPolicy = {
22  /**
23   * `enforce` blocks. `dry-run` decides and records, and lets everything through.
24   *
25   * Dry-run exists so an operator can write a rule against real traffic and read the audit trail
26   * before it starts refusing anybody's work. A governance feature nobody dares switch on is not a
27   * governance feature.
28   */
29  mode: PolicyMode;
30  /** Evaluated first. Any expression true means refused, whatever `allow` says. */
31  deny: string[];
32  /** Any expression true means permitted. Empty means nothing is permitted. */
33  allow: string[];
34};
35
36/**
37 * The attributes a rule can be written against.
38 *
39 * `element` is resolved by the gateway from the snapshot the server itself fetched, never from what
40 * the caller claimed it was clicking. A policy that decides on an attacker-supplied label is
41 * decoration: the whole point is that "do not click Submit" cannot be evaded by calling it something
42 * else in the request.
43 */
44export type PolicyContext = {
45  tool: { name: string };
46  bot: { id: string };
47  page: { url: string; host: string };
48  actor: { id: string };
49  element?: {
50    ref: string;
51    role: string;
52    name: string;
53    type?: string;
54  };
55  /**
56   * The key a `computer_key` call is about to press.
57   *
58   * Without this, a rule about clicking is bypassed. An agent that meets a deny rule on
59   * clicking "Submit order" will press Enter in the form instead, and the order goes through: the
60   * click is refused and audited, the keypress is allowed, because nothing in the context could tell
61   * one keypress from another.
62   *
63   * A form has two doors and the policy could only see one. Now a rule can say
64   * `tool.name == "computer_key" && key == "Enter"`, and an operator blocking a submit button knows
65   * to block both routes, which the deny example in `.env.example` now does.
66   */
67  key?: string;
深度阅读 · Human Handoff
§ 03 · COMPUTER PROCESS

浏览器电脑是独立执行面

▸agent-computer 的持久浏览器与 ARIA snapshot

Computer 进程的关键引用是 computer token 从 agent-computer/src/index.ts:64 强制,文本上限在 agent-computer/src/index.ts:97,session map 是 agent-computer/src/index.ts:123;workspace 根位于 agent-computer/src/index.ts:154,profile 在 agent-computer/src/index.ts:168,snapshot 函数从 agent-computer/src/index.ts:217 开始并在 agent-computer/src/index.ts:227 递增版本;ref 定位在 agent-computer/src/index.ts:243,aria-ref selector 是 agent-computer/src/index.ts:257,空 ref 快速拒绝在 agent-computer/src/index.ts:272。它在 agent-computer/src/index.ts:64 要求 token,agent-computer/src/index.ts:154 固定 workspace,agent-computer/src/index.ts:168 创建持久 profile,agent-computer/src/index.ts:217 生成 AI snapshot,agent-computer/src/index.ts:243 和 agent-computer/src/index.ts:272 处理 stale ref。

src/agent-computer/src/index.ts · L217-285TypeScript
217async function snapshotPage(
218  session: BotSession,
219  target: Page,
220): Promise<{
221  snapshotId: number;
222  url: string;
223  title: string;
224  elements: SnapshotElement[];
225  truncated: boolean;
226}> {
227  session.snapshotId += 1;
228  const yaml = await target.ariaSnapshot({ mode: "ai" });
229  return {
230    snapshotId: session.snapshotId,
231    url: target.url(),
232    title: await target.title(),
233    ...parseAriaSnapshot(yaml),
234  };
235}
236
237/**
238 * Resolve a ref to a locator, refusing anything from a superseded snapshot.
239 *
240 * `aria-ref=` is a first-party Playwright selector engine, and it is the same one its MCP server uses.
241 * The generation check here is the caller-facing half; see the note on `snapshotId` for why both exist.
242 */
243function locateRef(
244  session: BotSession,
245  target: Page,
246  ref: string,
247  expectedSnapshotId: number | undefined,
248) {
249  if (
250    expectedSnapshotId !== undefined &&
251    expectedSnapshotId !== session.snapshotId
252  ) {
253    throw new StaleSnapshotError(
254      `That list of elements is out of date: it was taken for snapshot ${expectedSnapshotId} and the page is now at ${session.snapshotId}. Take a new snapshot and use the refs from it.`,
255    );
256  }
257  return target.locator(`aria-ref=${ref}`);
258}
259
260/**
261 * The element, or a refusal that says what to do about it.
262 *
263 * A generation check is not an existence check. A ref from
264 * the current snapshot that names nothing on the page, because a model invented it or because the
265 * page moved on without a new snapshot being taken, passes `locateRef` and then simply waits. The
266 * action times out, and the caller gets a generic failure carrying Playwright's internal call log
267 * instead of the actionable answer: take a fresh snapshot.
268 *
269 * `count()` resolves immediately rather than waiting, so a ref that names nothing is refused in
270 * milliseconds instead of holding the action open for the full timeout.
271 */
272async function resolveRef(
273  session: BotSession,
274  target: Page,
275  ref: string,
276  expectedSnapshotId: number | undefined,
277) {
278  const locator = locateRef(session, target, ref, expectedSnapshotId);
279  if ((await locator.count()) === 0) {
280    throw new StaleSnapshotError(
281      `Nothing on this page has the ref ${ref}. Take a new snapshot and use the refs it returns.`,
282    );
283  }
284  return locator;
285}

▸Shell 是 hands,不是 judgement

agent-computer/src/shell.ts:56 暴露 shell runner,但文件开头反复强调策略不在这里。agent-computer/src/shell.ts:79 使用 bash 是为了真实命令能力,agent-computer/src/shell.ts:95 到 agent-computer/src/shell.ts:112 处理超时、abort 和输出截断。

src/agent-computer/src/shell.ts · L56-125TypeScript
56export function createShell(workspaceDir: string) {
57  return {
58    async run(input: {
59      command: string;
60      timeoutMs?: number;
61      signal?: AbortSignal;
62    }): Promise<ShellResult> {
63      const started = Date.now();
64      const timeoutMs = Math.min(
65        input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
66        MAX_TIMEOUT_MS,
67      );
68
69      /*
70       * Through a shell on purpose. A Bot writes `apt-get install -y jq && jq --version`, and pipes,
71       * redirection and `&&` are most of why a shell is useful. Refusing them would leave something
72       * that runs one binary and calls itself a shell.
73       *
74       * This is the argument for the container boundary rather than for string parsing: there is no
75       * safe way to read a command's intent from its text, so nothing here tries. The policy decides
76       * whether this Bot may run commands at all and what they may say; the container decides what a
77       * command can reach.
78       */
79      const child = spawn("/bin/bash", ["-lc", input.command], {
80        cwd: workspaceDir,
81        env: { ...process.env, HOME: workspaceDir },
82      });
83
84      let stdout = "";
85      let stderr = "";
86      let timedOut = false;
87
88      child.stdout.on("data", (chunk) => {
89        stdout += String(chunk);
90      });
91      child.stderr.on("data", (chunk) => {
92        stderr += String(chunk);
93      });
94
95      const timer = setTimeout(() => {
96        timedOut = true;
97        child.kill("SIGKILL");
98      }, timeoutMs);
99
100      // The person's Stop reaches the command, not just the request that started it.
101      const onAbort = () => child.kill("SIGKILL");
102      input.signal?.addEventListener("abort", onAbort, { once: true });
103
104      const exitCode = await new Promise<number>((resolve) => {
105        child.on("close", (code) => resolve(code ?? -1));
106        child.on("error", () => resolve(-1));
107      });
108
109      clearTimeout(timer);
110      input.signal?.removeEventListener("abort", onAbort);
111
112      const out = clamp(stdout);
113      const err = clamp(stderr);
114      return {
115        command: input.command,
116        exitCode,
117        stdout: out.text,
118        stderr: err.text,
119        truncated: out.truncated || err.truncated,
120        timedOut,
121        elapsedMs: Date.now() - started,
122      };
123    },
124  };
125}
深度阅读 · Connectors & Worker
§ 04 · HUMAN HANDOFF

一个页面只能有一个驾驶者

接管状态机的主线是 状态机工厂是 agent-computer/src/control.ts:72,get 返回副本在 agent-computer/src/control.ts:83;求助入口是 agent-computer/src/control.ts:93,secret 请求入口是 agent-computer/src/control.ts:106,pendingSecret 由 agent-computer/src/control.ts:135 限定;take 位于 agent-computer/src/control.ts:162,release 位于 agent-computer/src/control.ts:180,Bot 动作拒绝是 agent-computer/src/control.ts:195,humanMayDrive 是 agent-computer/src/control.ts:200,返回类型收在 agent-computer/src/control.ts:203。agent-computer/src/control.ts:93 允许 Bot 请求帮助但不交出权限,agent-computer/src/control.ts:106 要求 secret 必须绑定字段 ref,agent-computer/src/control.ts:162 让人接管,agent-computer/src/control.ts:195 在人控制时拒绝 Bot 动作。

▸Control state machine

src/agent-computer/src/control.ts · L72-127TypeScript
72export function createControl(
73  now: () => string = () => new Date().toISOString(),
74) {
75  let state: ControlState = {
76    holder: "bot",
77    since: now(),
78    requested: false,
79  };
80
81  return {
82    /** The current state, as the surface polls it. A copy, so a caller cannot mutate the machine. */
83    get(): ControlState {
84      return { ...state };
85    },
86
87    /**
88     * The Bot asking for help.
89     *
90     * It does not take control: it says it is stuck and why, and a person decides. A Bot that could
91     * hand itself to a human could also hand a human a page they never asked to see.
92     */
93    requestHelp(reason: unknown): ControlState {
94      state = {
95        ...state,
96        requested: true,
97        reason:
98          typeof reason === "string" && reason.trim()
99            ? reason.trim()
100            : "The assistant needs a person to continue.",
101      };
102      return this.get();
103    },
104
105    /** The Bot asking for one value it must not be told, naming the field it goes in. */
106    requestSecret(input: {
107      label?: unknown;
108      ref?: unknown;
109      snapshotId?: unknown;
110    }): ControlState {
111      if (typeof input.ref !== "string" || !input.ref.trim()) {
112        throw new ControlRequestError(
113          "Say which field the value goes in, using a ref from your snapshot.",
114        );
115      }
116      state = {
117        ...state,
118        secretWanted:
119          typeof input.label === "string" && input.label.trim()
120            ? input.label.trim()
121            : "the value this page is asking for",
122        secretRef: input.ref.trim(),
123        secretSnapshotId:
124          typeof input.snapshotId === "number" ? input.snapshotId : undefined,
125      };
126      return this.get();
127    },

▸Supervisor 限缩 Docker socket 词汇

Docker supervisor 的引用是 端口配置在 supervisor/src/index.ts:37,token 缺失由 supervisor/src/index.ts:39 拦截;环境变量生成从 supervisor/src/index.ts:60 到 supervisor/src/index.ts:71,中间件认证是 supervisor/src/index.ts:88;ensure 路由在 supervisor/src/index.ts:106,身份注册是 supervisor/src/index.ts:113,容器确保是 supervisor/src/index.ts:115;stop 和 reset 分别在 supervisor/src/index.ts:137 与 supervisor/src/index.ts:151。它在 supervisor/src/index.ts:39 没有 token 就拒绝启动,supervisor/src/index.ts:60 只从环境生成 container env,supervisor/src/index.ts:106、supervisor/src/index.ts:137、supervisor/src/index.ts:151 和 supervisor/src/index.ts:165 只暴露 ensure/stop/reset/list。

src/supervisor/src/index.ts · L86-176TypeScript
86const app = new Hono();
87
88app.use("*", async (context, next) => {
89  // Health is open so an orchestrator can check it without holding the token.
90  if (context.req.path === "/health") return next();
91  if (context.req.header("authorization") !== `Bearer ${token}`) {
92    return context.json({ error: "Unauthorized." }, 401);
93  }
94  return next();
95});
96
97app.get("/health", async (context) =>
98  context.json({ status: "ok", docker: await reachable() }),
99);
100
101/** The Bot id in the path, validated before it becomes any kind of name. */
102function resolve(raw: string) {
103  return namesFor(raw);
104}
105
106app.post("/computers/:botId/ensure", async (context) => {
107  const parsed = resolve(context.req.param("botId"));
108  if (!parsed.ok) return context.json({ error: parsed.reason }, 400);
109
110  try {
111    // Registered before the computer is handed out, so it can prove which Bot it is from its first
112    // request.
113    const identity = await registerEntry(parsed.names);
114
115    const state = await ensure(parsed.names, {
116      image,
117      environment: environmentFor(parsed.names.botId),
118      ...(network ? { network } : {}),
119      ...(runtime ? { runtime } : {}),
120      ...(memoryBytes ? { memoryBytes } : {}),
121      ...(spireSocketVolume ? { spireSocketVolume } : {}),
122    });
123    return context.json({
124      ...state,
125      ...(identity.registered
126        ? { spiffeId: identity.spiffeId }
127        : { identity: identity.reason }),
128    });
129  } catch (error) {
130    if (error instanceof DockerUnavailableError) {
131      return context.json({ error: error.message }, 503);
132    }
133    throw error;
134  }
135});
136
137app.post("/computers/:botId/stop", async (context) => {
138  const parsed = resolve(context.req.param("botId"));
139  if (!parsed.ok) return context.json({ error: parsed.reason }, 400);
140  try {
141    const stopped = await stop(parsed.names);
142    return context.json({ stopped });
143  } catch (error) {
144    if (error instanceof DockerUnavailableError) {
145      return context.json({ error: error.message }, 503);
146    }
147    throw error;
148  }
149});
150
151app.post("/computers/:botId/reset", async (context) => {
152  const parsed = resolve(context.req.param("botId"));
153  if (!parsed.ok) return context.json({ error: parsed.reason }, 400);
154  try {
155    const wasThere = await reset(parsed.names);
156    return context.json({ reset: wasThere });
157  } catch (error) {
158    if (error instanceof DockerUnavailableError) {
159      return context.json({ error: error.message }, 503);
160    }
161    throw error;
162  }
163});
164
165app.get("/computers", async (context) => {
166  try {
167    return context.json({ computers: await listOwned() });
168  } catch (error) {
169    if (error instanceof DockerUnavailableError) {
170      return context.json({ error: error.message }, 503);
171    }
172    throw error;
173  }
174});
175
176serve({ port, fetch: app.fetch, idleTimeout: 120 });
§ 05 · CONNECTORS

知识源同步:cursor 不能先提交

worker runner 的引用是 runner 入口是 worker/src/connector-runner.ts:17,discover 调用位于 worker/src/connector-runner.ts:23,batch 路径由 worker/src/connector-runner.ts:27 选择;fallback 能力检查在 worker/src/connector-runner.ts:30,逐条 apply 在 worker/src/connector-runner.ts:35,cursor 提交在 worker/src/connector-runner.ts:36;成功记录是 worker/src/connector-runner.ts:40,失败记录和重抛分别是 worker/src/connector-runner.ts:41、worker/src/connector-runner.ts:42、worker/src/connector-runner.ts:43。worker/src/connector-runner.ts:23 调用 adapter.discover,worker/src/connector-runner.ts:27 优先 batch 持久化,worker/src/connector-runner.ts:35 逐条 apply,worker/src/connector-runner.ts:36 到 worker/src/connector-runner.ts:38 只在变更成功后提交 cursor,worker/src/connector-runner.ts:41 到 worker/src/connector-runner.ts:43 失败时记录失败并抛出。

▸runner 核心实现

src/worker/src/connector-runner.ts · L17-45TypeScript
17export async function runConnector(
18  adapter: ConnectorAdapter,
19  persistence: ConnectorPersistence,
20  mode: "sync" | "reconcile" = "sync",
21) {
22  try {
23    const discovered = await adapter.discover({
24      cursor: await persistence.cursor(),
25      mode,
26    });
27    if (persistence.persistBatch) {
28      await persistence.persistBatch(discovered.changes, discovered.nextCursor);
29    } else {
30      if (!persistence.apply || !persistence.commitCursor) {
31        throw new Error(
32          "Connector persistence must support batch or individual writes.",
33        );
34      }
35      for (const change of discovered.changes) await persistence.apply(change);
36      if (discovered.nextCursor !== null) {
37        await persistence.commitCursor(discovered.nextCursor);
38      }
39    }
40    await persistence.recordRun?.("succeeded");
41  } catch (error) {
42    await persistence.recordRun?.("failed");
43    throw error;
44  }
45}

▸测试把失败路径写清楚

worker/tests/connector-runner.test.ts:5 验证成功后提交 cursor,worker/tests/connector-runner.test.ts:27 验证失败不推进 cursor,worker/tests/connector-runner.test.ts:51 验证 reconcile mode 与 run status。这里的价值是同步层不会因为半批失败把游标推过头。

§ 06 · TESTS & RISKS

测试关注的是拒绝路径

▸Gateway test

server/tests/computer-gateway.test.ts:15 到 server/tests/computer-gateway.test.ts:23 把 gateway 保证写成四条性质,server/tests/computer-gateway.test.ts:224 验证拒绝动作不到 computer,server/tests/computer-gateway.test.ts:257 验证策略看 server 解析的 element,而不是调用者伪造的 label。

src/server/tests/computer-gateway.test.ts · L210-274TypeScript
210describe("the computer gateway", () => {
211  test("carries out an allowed action and records it", async () => {
212    const { gateway, calls, rows } = await gatewayWith(PERMISSIVE);
213    await gateway.click("bot-1", ACTOR, {
214      ref: "e9",
215      snapshotId: 7,
216    });
217
218    expect(calls).toEqual(["click"]);
219    expect(rows).toHaveLength(1);
220    expect(rows[0]?.eventType).toBe("computer.action_allowed");
221    expect(rows[0]?.targetId).toBe("bot-1");
222  });
223
224  test("a refused action never reaches the computer", async () => {
225    const { gateway, calls, rows } = await gatewayWith({
226      ...PERMISSIVE,
227      deny: ['contains(element.name, "submit")'],
228    });
229
230    await expect(
231      gateway.click("bot-1", ACTOR, { ref: "e9", snapshotId: 7 }),
232    ).rejects.toThrow(ActionRefusedError);
233
234    // The decision happens before the effect.
235    expect(calls).toEqual([]);
236    expect(rows).toHaveLength(1);
237    expect(rows[0]?.eventType).toBe("computer.action_refused");
238    expect(rows[0]?.targetId).toBe("bot-1");
239  });
240
241  test("the refusal names the rule, so an operator can find it", async () => {
242    const { gateway } = await gatewayWith({
243      ...PERMISSIVE,
244      deny: ['contains(element.name, "submit")'],
245    });
246
247    const error = await gateway
248      .click("bot-1", ACTOR, { ref: "e9", snapshotId: 7 })
249      .catch((caught: unknown) => caught);
250
251    expect(error).toBeInstanceOf(ActionRefusedError);
252    expect((error as ActionRefusedError).rule).toBe(
253      'contains(element.name, "submit")',
254    );
255  });
256
257  test("the policy sees the element the SERVER resolved, not what the caller claimed", async () => {
258    // The evasion this prevents: calling the Submit button something harmless in the request. The
259    // caller only ever sends a ref, and the gateway looks it up in the snapshot it fetched itself.
260    const { gateway, calls } = await gatewayWith({
261      ...PERMISSIVE,
262      deny: ['contains(element.name, "submit")'],
263    });
264
265    await expect(
266      gateway.click("bot-1", ACTOR, {
267        ref: "e9",
268        snapshotId: 7,
269        // Not part of the input contract, and must not influence anything even when supplied.
270        ...({ name: "Continue", element: { name: "Continue" } } as object),
271      }),
272    ).rejects.toThrow(ActionRefusedError);
273    expect(calls).toEqual([]);
274  });

▸亮点

源码亮点 1
Policy gateway 把 ref 解析、策略判断、审计写入和真实动作串成唯一通道,拒绝动作不会到达 computer。
源码亮点 2
Computer supervisor 把 Docker socket 缩成 Bot 语义词汇,API server 即使被打穿也不能任意操作 Docker。
源码亮点 3
Human handoff 是独立 state machine,拒绝两个驾驶者同时控制同一页面,secret value 不进状态。
源码亮点 4
Connector runner 支持 batch 或逐条持久化,并且失败时不推进 cursor,适合知识源同步。

▸风险

部署复杂度
OpenBot 横跨 server、worker、supervisor、agent-computer、app 和数据库,首次部署比单进程 agent harness 重。
策略表达能力
CEL 带来灵活性,也要求运营方理解规则失败会 fail-closed;误写规则可能阻塞真实工作。
共享电脑模式
provider.ts 明确指出 shared provider 会共享浏览器会话、文件和登录,生产应使用 per-bot supervisor。
源码体量快速增长
项目刚创建几天已有大量测试和子包,架构在快速变动,二次开发需要紧跟接口变化。
Generated · 2026-08-21 Source · CopilotKit/OpenBot ← 返回 Trending 索引