我怎样设计 Remote Codex:从手机远程控制本机 Codex
电脑上的 Codex 正在读代码、跑测试,人却临时离开了工位。这种时候我常有三个很具体的需求:拿出手机看一眼任务跑到哪一步,顺手补一句要求,或者点掉刚弹出来的命令审批。
手机和电脑连着同一个 Wi-Fi 时,本机监听一个局域网端口就能解决问题。一旦切到蜂窝网络,连接就要跨过运营商 NAT、动态公网地址、TLS 加密和访问鉴权。直接在家庭或办公路由器上做端口映射,相当于把一个能改本机代码、执行本机命令的入口挂到公网上,安全风险太高。
参考 dsh-go 的移动工作流思路,我做了一个轻量远程控制台 Remote Codex,并定下三条边界:项目文件、Codex 登录凭据和工具执行环境全部留在电脑上;手机端只运行一个 PWA 界面;外网访问统一走电脑主动发起的 HTTPS 出站隧道,不开路由器入站端口。中间用一层无第三方运行时依赖的 Node.js 服务,把手机浏览器的 HTTP 请求翻译成官方 codex app-server 协议。
这篇文章基于 0.1.0 版本源码(状态截至 2026 年 11 月 8 日)拆解这套实现与取舍。当前版本只面向单人临时远程使用,暂不涉及多用户权限、固定域名和生产级高可用。
三种运行模式与代码分层
Remote Codex 支持本机预览、局域网(--lan)和外网隧道(--remote)三种启动方式。把局域网和远程两条路径放在一起看,整体分层如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19手机 PWA(public/app.js)
│
├─ 局域网模式 (--lan):HTTP(S) + JSON + SSE
│
└─ 远程模式 (--remote):HTTPS + JSON + 长轮询
▼
Cloudflare 临时公网地址 (*.trycloudflare.com)
▼
本机 cloudflared 出站隧道 → http://127.0.0.1:4173
│
▼
Remote Codex 桥接服务(src/server.mjs)
│
│ stdin/stdout 换行分隔 JSON-RPC
▼
codex app-server 子进程
│
▼
本机 Codex 登录态、项目工作目录与沙箱执行环境
按这层关系切开后,手机浏览器不需要存代码仓库,也不用配置 OpenAI 登录态或 API Key。读文件、改代码、跑测试都在原来的电脑上完成,手机只负责查看流式进展并下发会话、插话、中断和审批指令。
OpenAI 官方提供的 app-server 本身就是给外部客户端准备的本地接入层,VS Code 插件和桌面端也基于同类协议驱动会话。客户端通过标准输入输出(stdio)收发换行分隔的 JSON-RPC 消息,完成握手后即可管理会话(thread)、启动任务轮次(turn)、订阅流式事件并响应人工审批。Codex app-server 文档
有了现成的 app-server,Remote Codex 不必自己重写一套 Agent 主循环,代码范围就能收窄到移动界面、协议转换、网络隧道和鉴权控制。整个仓库基于 Node.js 20+ 内置模块编写,package.json 没有引入任何第三方生产依赖,核心文件职责如下:
| 文件 | 职责 |
|---|---|
src/codex-client.mjs | 拉起 codex app-server 子进程,完成握手,按换行解析双向 JSON-RPC |
src/server.mjs | 提供静态资源与 /api/* 接口,执行令牌鉴权、RPC 白名单和审批暂存 |
src/event-buffer.mjs | 维护最近 1000 条内存事件序号,同时支撑 SSE 推送与长轮询等待 |
src/remote-tunnel.mjs | 管理 cloudflared Quick Tunnel 子进程,提取临时公网地址并联动退出 |
src/security.mjs | 使用常量时间比较校验 Bearer Token,并转义内联 JSON 中的 < |
src/terminal-qr.mjs | 在本机纯 JavaScript 计算 Reed-Solomon 纠错码并输出终端二维码 |
public/app.js、public/sw.js | 手机端 PWA 交互、会话恢复、审批表单及静态外壳缓存 |
用 codex app-server 承接双向 JSON-RPC
桥接服务启动时,CodexAppServer.start() 会以指定工作目录拉起 codex app-server 子进程,接管它的 stdin、stdout 和 stderr。进程就绪前要先完成一次握手:客户端发送带递增 id 的 initialize 请求,收到响应后再补一条不带 id 的 initialized 通知。src/codex-client.mjs 中的握手代码如下:1
2
3
4
5
6
7
8
9
10
11
12await this.request("initialize", {
clientInfo: {
name: "remote_codex",
title: "Remote Codex",
version: "0.1.0",
},
capabilities: {
experimentalApi: true,
requiredHumanApproval: true,
},
}, 15_000);
this.notify("initialized", {});
这里的 requiredHumanApproval: true 明确告诉 app-server:当前客户端具备人工确认能力,遇到越界写入或受限命令时不要直接静默失败,把审批请求发出来交给界面决定。
子进程 stdout 的 data 事件按字节块到达,一次回调可能只含半行 JSON,也可能连着多行。#consume() 先把字符拼进缓冲区,每次切出完整的换行分隔行再调用 JSON.parse()。解析后的消息在 #handle() 中按字段组合分成三路:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15if (Object.hasOwn(message, "id") && !message.method && this.pending.has(message.id)) {
const pending = this.pending.get(message.id);
this.pending.delete(message.id);
clearTimeout(pending.timer);
if (message.error) pending.reject(new CodexRpcError(message.error));
else pending.resolve(message.result);
return;
}
if (Object.hasOwn(message, "id") && message.method) {
this.emit("serverRequest", message);
return;
}
if (message.method) this.emit("notification", message);
三类消息各自对应一种交互方向:
| 消息形态 | 含义与桥接层动作 |
|---|---|
有 id、无 method 且命中 pending | 桥接层先前发起的 RPC 返回结果,清理超时计时器并结束对应 Promise |
同时有 id 和 method | Codex 主动发起的服务端请求(如命令审批、文件审批、向用户提问),等待写回同 id 响应 |
只有 method | 单向流式通知,包括 turn/started、item/agentMessage/delta、命令输出增量和 turn/completed |
这套双向通信决定了桥接层没法写成普通的一问一答代理。浏览器调用 turn/start 时,那次 POST /api/rpc 只负责拿到“本轮任务已创建”的回执;真正的模型回复增量、命令输出和中途审批,全都顺着异步事件流往外推。
为了防止模型列举或会话恢复耗时较长时被 Node.js 默认连接超时提前掐断,src/server.mjs 把单次 RPC 超时设为 120 秒,同时将 HTTP 服务的 requestTimeout 和 headersTimeout 放宽到 130 秒和 135 秒。如果 codex 子进程意外退出,#failAll() 会立刻拒绝所有挂起中的 RPC 请求,避免前端一直卡在等待状态。
OpenAI 在平台化架构文章里把这一层定位为客户端与 Agent 运行时之间的协议边界:外层应用自己决定界面形态和远程通道,会话状态、工具调用、沙箱与审批策略则统一留在 Codex 核心里。Codex 作为平台的架构说明
RPC 白名单、运行中插话与审批回传
如果 /api/rpc 把前端传来的任意 method 字符串原样写进 app-server,一旦配对链接外泄,对方就能直接调起高风险内部接口。src/server.mjs 在转发前先做请求体上限检查(最大 1MB)与方法白名单过滤:1
2
3
4
5
6
7
8
9
10
11
12
13const allowedMethods = new Set([
"model/list",
"thread/list",
"thread/start",
"thread/read",
"thread/resume",
"thread/turns/list",
"thread/items/list",
"thread/name/set",
"turn/start",
"turn/steer",
"turn/interrupt",
]);
不在集合内的方法一律返回 403。像 thread/shellCommand、command/exec 以及删除历史会话这类接口都没有对浏览器开放;后续 codex app-server 升级出新方法时,默认也处于关闭状态。
日常离开工位时很常见的一个动作是“任务还在跑,我想临时补一句约束”。public/app.js 在发送框里区分了空闲发送和运行中插话:当前没有活跃轮次时调用 turn/start;检测到 state.busy 且存在 activeTurnId 时,自动改调 turn/steer,并附上 expectedTurnId: state.activeTurnId。加上期望轮次编号后,就算手机点发送的那一刻上一轮恰好跑完,服务端也能拒绝这次错位插话,不会把补充说明误塞进别的上下文。
人工审批则走单独的暂存与回传接口,不混在普通 RPC 里:
codex发出serverRequest时,src/server.mjs以String(message.id)为键把原始请求存入内存pendingServerRequests表,再向事件流广播。- 手机端弹出底部审批卡片;用户点选后提交到
POST /api/respond。 - 桥接层核对该
id仍在待处理表中,先将其移除,再调用codex.respond()把结果写回子进程stdin,同时广播一条requestResolved事件,让其他已打开的页面同步收起弹窗。 - 手机中途锁屏或切网重连后,前端调用
GET /api/pending就能重新拉回还没点完的审批,不会因为错过了那一下推送而把任务卡死。
在 public/app.js 中,目前完整适配了三类高频服务端请求:命令执行审批(item/commandExecution/requestApproval)、文件修改审批(item/fileChange/requestApproval)以及工具向用户提问(item/tool/requestUserInput)。前两类会展示原因、待执行命令、工作目录或改动文件列表,提供“拒绝”“本次允许”“本会话允许”三个按钮;提问请求则会动态生成单选项、补充输入框或密码框。至于 mcpServer/elicitation/request,当前只实现了“取消”按钮;遇到其他尚未适配的服务端请求时,界面只会给出不支持提示。
新建会话时,前端默认把审批策略设为 on-request(按需确认),沙箱设为 workspace-write(仅可写当前工作目录);如果手动切到 danger-full-access,表单会弹出越界修改警告。白名单能挡住未开放的协议方法,却替不了人工判断:在手机上点下“本次允许”的那一刻,电脑上就会真实改动文件或运行命令,点之前仍得看清命令和路径。
配对令牌、本机二维码与前端存储边界
每次启动进程,服务端默认通过 randomBytes(24).toString("base64url") 生成一段 24 字节(192 位)随机令牌,并拼到配对地址的 # 号后面:1
https://example.trycloudflare.com/#token=<随机令牌>
把令牌放在 URL fragment(# 片段)有一个直接好处:浏览器请求 / 静态页面时,HTTP 请求行和 Referer 不会带上 #token=...,反向代理或隧道入口的访问日志里也不会留下这段凭据。终端打印的配对二维码同样由 src/terminal-qr.mjs 在本机直接计算生成(支持 QR Version 1 至 5、L 级纠错与 8 种掩码评估,用 Unicode 半高方块字符拼出图形),既省掉了外部依赖,也避免把完整配对地址发给任何在线二维码服务。
手机扫码打开页面后,public/app.js 的 boot() 读取 location.hash,把令牌存进当前源的 localStorage,紧接着调用 history.replaceState() 清掉地址栏尾巴。此后所有 /api/* 请求统一通过请求头携带凭据:1
Authorization: Bearer <随机令牌>
src/security.mjs 先把期望值与传入值转成 Buffer 比对字节长度,长度一致再调用 timingSafeEqual() 做常量时间比较;往 JSON 或事件流输出数据时,safeJson() 会把 < 统一转义为 \u003c。
src/server.mjs 同时给所有响应加上了 Content-Security-Policy(将脚本、样式、网络连接和表单目标限制在 'self',图片仅允许同源与 data:,禁止内联脚本与外部站点)、X-Frame-Options: DENY 和 Referrer-Policy: no-referrer,尽量缩小第三方脚本注入后读取本地存储的攻击面。
缓存策略也按公开外壳和私有数据拆成了两半。/api/* 响应统一带 Cache-Control: no-store,public/sw.js 中的 Service Worker 遇到 /api/ 路径直接放行不缓存;index.html、styles.css、app.js 等静态外壳资源则设为 Cache-Control: no-cache 并采用“网络优先、离线回退缓存”策略。这样桌面端升级代码后手机刷新就能拿到新界面,而且在局域网明文 HTTP 下(非 window.isSecureContext)前端会自动跳过 Service Worker 注册,并弹出“请勿在公共 Wi-Fi 使用”的提示。
这套方案的边界也很清楚:配对链接和终端二维码本身就是开门钥匙。一旦把带 #token= 的链接发进群聊、截进公开图片,或者手机端同源页面出现 XSS 漏洞,别人就能在当前进程存活期间控制会话。0.1.0 还没有做单设备撤销、令牌过期轮换和按 IP 的失败限速;怀疑链接泄露时,最直接的处置方式就是按 Ctrl+C 停掉进程,让临时隧道和当次随机令牌一起作废。
出站隧道与进程生命周期联动
开启 --remote 参数后,src/server.mjs 会无视其他网卡配置,强制把本地监听地址锁死在 127.0.0.1:1
const host = args.remote ? "127.0.0.1" : (args.lan ? "0.0.0.0" : (args.host || "127.0.0.1"));
这意味着远程模式下,连同一个局域网里的其他设备也没法直接连上电脑的 4173 端口,所有外部流量都必须经过隧道入口。本地服务就绪后,src/remote-tunnel.mjs 启动子进程:1
cloudflared tunnel --no-autoupdate --url http://127.0.0.1:4173
cloudflared 从电脑向外主动连上 Cloudflare 边缘节点,公网 HTTPS 请求到达 Cloudflare 后再顺着这条长连接转回本机 127.0.0.1:4173。电脑不需要公网 IP,也不用改路由器设置。Cloudflare 文档将这种模式总结为只靠出站连接暴露内网服务,源站无需开放任何入站端口。Cloudflare Tunnel 原理
startQuickTunnel() 同时监听 cloudflared 的 stdout 与 stderr,保留末尾 16384 个字符滚动匹配 https://*.trycloudflare.com 地址(超过 30 秒未拿到地址则判定启动超时并终止子进程)。拿到临时域名后,主进程拼上 #token=... 打印终端二维码。
管理外部隧道子进程时,最怕出现“一边死了、另一边还挂着”的半残状态,所以两边做了双向退出联动:
- 用户在终端按
Ctrl+C(收到SIGINT或SIGTERM)时,shutdown()按顺序停掉cloudflared(先发SIGTERM,3 秒未退出补SIGKILL)、唤醒并关闭所有挂起中的事件连接、关闭 HTTP 服务,最后关闭codex app-server的stdin(1.5 秒未退出再发SIGTERM)。 - 反过来,如果运行途中
cloudflared意外断开,onExit回调也会立即触发整套shutdown()并将进程退出码设为1,避免终端看起来还在运行,手机其实已经连不进来。
Cloudflare 对 Quick Tunnel 的定位是免账号、免域名的临时开发测试入口,每次启动域名都会变,也不提供可用性承诺;如果希望在应用层令牌前再挡一道身份校验,启动时可以重复传入 --allowed-mail 参数,让访问者先通过 Cloudflare 的邮箱一次性验证码。Quick Tunnel 文档
0.1.0 默认采用 Quick Tunnel,图的是装好 cloudflared 后一条 npm run remote 就能扫二维码出门。如果需要固定域名和长期在线,更稳妥的做法是建一条命名 Cloudflare Tunnel(或者用 Tailscale 组网)指向本机 http://127.0.0.1:4173,再用默认的 npm start 启动桥接服务。
绕开 Quick Tunnel 的 SSE 限制:双模事件与游标同步
Codex 执行任务时会密集吐出文本增量与命令输出。局域网模式下,我最初用 Server-Sent Events(SSE)来做实时推送:浏览器请求 GET /api/events 建立长响应,服务端每收到一条消息就按 text/event-stream 格式往下写,空闲时每 20 秒发一行 : keepalive 注释保活。
把同一套前端搬到 --remote 模式后,踩到了一个很实在的协议坑:首页能打开,会话列表也能加载,偏偏发送任务后手机上看不到流式输出。查对 Cloudflare 的限制说明才确认,Quick Tunnel 目前不支持 SSE。Quick Tunnel 限制
为了让局域网保留低开销推送、外网又能穿过 Quick Tunnel,src/event-buffer.mjs 抽出了一个共用的内存事件缓冲类 EventBuffer,默认保留最近 1000 条带递增序号 seq 的事件:1
2
3
4
5
6
7
8
9publish(kind, message) {
const event = { seq: ++this.sequence, at: Date.now(), kind, message };
this.items.push(event);
if (this.items.length > this.limit) this.items.splice(0, this.items.length - this.limit);
for (const waiter of [...this.waiters]) {
if (event.seq > waiter.after) waiter.finish(this.after(waiter.after));
}
return event;
}
局域网模式下,publish() 生成带序号的事件后直接写给所有在线 SSE 连接;远程模式下,/api/status 返回 eventTransport: "poll",前端改调长轮询接口 GET /api/events/poll?after=42。服务端调用 events.wait(cursor):缓冲区里已经有序号大于 42 的事件就立刻返回 JSON 数组;暂时没有新事件就挂起等待,一旦有新事件发布或满 20 秒超时就马上返回,客户端连接中途断开时则通过 AbortController 清理挂起监听器。前端收到响应更新本地游标,紧接着发出下一轮请求。
把流拆成轮询后,还要处理三个容易错位的小细节:
- 服务端重启后的旧游标失效:每次进程启动都会随机生成 8 字节的
eventSession放进/api/status。如果电脑端重启了服务,序号从0重新计数,手机重连时发现eventSession变了,就会丢掉旧游标重新对齐,不会拿着上一轮进程的after=350干等。 - 首屏加载期间的事件衔接:
connect()先请求/api/status记下此刻的eventCursor,再并发拉取会话列表与/api/pending,完成后从刚才记下的游标启动事件监听。拉取列表这几百毫秒里新产生的事件序号都大于初始游标,第一轮轮询就能补齐。 - 长时断线后的状态恢复:长轮询每轮都要新建 HTTP 请求,开销高于 SSE,而且 1000 条内存环形缓冲只管近期增量。手机切后台太久、漏掉超过 1000 条细粒度增量时,页面切回前台(
visibilitychange)会主动重刷会话列表与待审批表;点进具体会话时,openThread()会调用thread/resume从codex app-server重新拉取完整的turns和消息项,把最终状态对齐回来。
一次完整交互与当前版本的取舍
把上面的模块串起来,在蜂窝网络下让电脑“检查当前项目测试失败原因”,请求与事件会按下面几步走完:
- 电脑运行
npm run remote,拉起codex app-server、绑定127.0.0.1:4173并建立cloudflared隧道,终端输出带#token=...的二维码。 - 手机扫码打开页面,前端将 fragment 里的令牌存入
localStorage并清掉地址栏尾巴,随后带 Bearer Token 请求/api/status,拿到工作目录、eventSession以及eventTransport: "poll"。 - 用户新建或点开已有会话,桥接层通过
/api/rpc调用白名单内的thread/start或thread/resume。 - 用户输入任务,前端调用
turn/start;若看到测试输出方向不对,直接在输入框补一句说明,前端会自动改走turn/steer插话,点停止按钮则发送turn/interrupt。 - Codex 运行测试工具并通过
stdout持续输出item/agentMessage/delta和item/commandExecution/outputDelta,EventBuffer编号后唤醒挂起中的/api/events/poll请求返回手机。 - Codex 请求执行受限命令时,桥接层把
serverRequest放进pendingServerRequests;手机弹出审批卡片,用户点击“本次允许”后经/api/respond写回子进程stdin。 - 本轮结束收到
turn/completed后,前端收起忙碌状态并刷新会话列表。
整条链路上其实叠着两层独立的权限边界:电脑本机的 Codex 登录态和沙箱配置决定了 Agent 最多能动哪些目录、调哪些模型;Remote Codex 的随机配对令牌(以及可选的 Cloudflare 邮箱验证码)则决定了谁能操纵这个遥控器。
平时出门前临时挂起服务,命令只需要两行:1
2brew install cloudflared
npm run remote -- --allowed-mail you@example.com
回到电脑前按 Ctrl+C,临时公网地址和当次配对令牌一并失效。
在 0.1.0 里,我刻意没有把代码往“云端托管 IDE”方向做重:没有加持久化事件库、多用户隔离、审计日志和自动故障转移,手机端也还没做图片附件、语音输入与系统级推送通知。先把执行环境稳稳留在自己的电脑上,让协议桥接、出站隧道、方法白名单和配对鉴权各管一摊,后端桥接层只用了 900 多行代码就跑通了一个放心可用的随身遥控器;后续演进时,我会先补持久化、单设备撤销、请求限速和系统通知,同时继续把公网入口能触达的 Codex 方法收紧在最小集合里。