feat(rfc-030): anet node start --copresence — codex 人机共存一等公民命令 - #465
Conversation
…存 orchestration
Replaces .demo/setup-copresence.sh bash with a first-class `anet node start
<alias> --copresence` command that orchestrates the 3-piece codex共存 dance
(app-server + agent-node bridge + attachable TUI) in three tmux sessions,
plus a symmetric sweep from `anet node stop`.
Also folds the +14L displaySender() UX helper from rfc030-work into main's
codex-app-server-bridge.ts — brings the TUI prompt prefix from opaque
`from=api` to `控制台(dashboard)` when the dashboard originates a task, so
a human at the TUI can tell where it came from. Bridge is now byte-identical
to rfc030-work.
New wizard behaviour (agent-network/bin/cli.ts):
* `--copresence` flag on `anet node start` spawns 3 tmux sessions
`<alias>-appsrv`, `<alias>-桥`, `<alias>` (attachable). All bind loopback
only; app-server picks a free port in 24700-24799 (or `--port`).
* Per-node CODEX_HOME isolation (default `<nodesDir>/<nodeId>/codex-home/`,
chmod 700) — matches RFC-030 §10 MVP posture; setup-copresence.sh did
not previously isolate this.
* Risk C double safeguard for dangerous sandbox — NEVER silent:
- default sandbox is `read-only` + approval_policy `on-request`
- opt-in requires explicit `--dangerously-allow-full-access` CLI flag
AND a typed `yes` at start (any other input aborts)
- stderr banner every launch when danger mode is enabled
* `anet node stop <alias>` now also kills `<alias>-appsrv` and `<alias>-桥`
tmux sessions unconditionally (idempotent has-session guard); no impact
on non-copresence nodes.
* Inline WebSocket JSON-RPC thread creator so the shipped CLI no longer
depends on the un-published `agent-node/tests/rfc-030-create-thread.ts`
path; falls back to undici WebSocket under Node 20, uses global under
Node 22+ / Bun (same resolver approach as agent-node's own client).
Not included (out of scope):
* PGID identity-check teardown for the 3-piece — tmux kill-session alone
leaves the app-server node wrapper reparented on some paths. Belongs to
P3 (Docker E2E with Risk B negative test).
* Docker E2E (P3), preview publish (P4), docs (P5).
…l injection, TTY gate, parseOpts silent-drop
Six fixes routed by 通信龙 (security审 verdict CHANGES_REQUIRED):
必修1 — ntok_ argv leak. Token was passed as `export …=<val>` inside the
bash string handed to `execFileSync("tmux",[…,"bash","-lc",appsrvCmd])`,
so it appeared in /proc/*/cmdline and tmux `pane_start_command` for the
whole session lifetime. Fix: write to `<codexHome>/.anet-copresence.env`
with 0600, `.` (source) then `rm -f` inside the tmux child so the value
never reaches argv. Bearer stays in the child's environ, referenced by
name only via `bearer_token_env_var`.
必修2 — opts.hub shell injection. The hub URL comes from a cwd-relative
`.anet/nodes/<id>/config.json` and interpolated into a bash single-quoted
`-c mcp_servers.commhub.url="${opts.hub}/mcp"`; a hostile checkout could
plant single quotes / backticks / `$(…)` and get arbitrary exec inside
the appsrv tmux. Fix: assertSafeHubUrl(URL parse + http/https + char
reject) at entry, plus shellQuote the full `-c` TOML fragment (matches
the existing model/codexBin/codexHome/displayName quoting).
顺手3 — threadId comes from our own thread/start response but flows into
a bash resume command. Added SAFE_THREAD_ID `/^[A-Za-z0-9_-]+$/` check
before interpolation; server-generated ids match, junk aborts with a
clear internal-error message.
顺手4 — inline JSON-RPC creator's initialize catch was `catch { /* … */ }`
(swallows every failure). Aligned with codex-app-server-bridge.ts:
inline isAlreadyInitializedError() (code -32600 OR /already initialized/i)
— only that specific rejection is expected on the shared-server path.
设计裁5 — TTY gate + `--yes-danger-full-access`. `printf 'yes\n' |` was
bypassing the interactive prompt. TTY caller: type 'yes' (unchanged).
Non-TTY caller: must pass BOTH --dangerously-allow-full-access AND
--yes-danger-full-access — the second explicit flag closes the piped-yes
bypass while giving CI / Docker E2E an opt-in route.
设计裁6 — parseOpts silent-drop. `anet node start --copresence <alias>`
assigned `copresence=<alias>` because parseOpts's value-swallow heuristic
didn't know about BOOLEAN_FLAGS. Callers checking `=== "true"` silently
lost the flag. Fixed parseOpts to consult BOOLEAN_FLAGS (mirrors
positionalArgs's existing awareness), and startCommand now extracts id
via positionalArgs so a leading `--copresence` no longer becomes `id`.
Bonus: failure paths after appsrv is up now print the `anet node stop`
cleanup command alongside the debug attach hint, so users don't leave
orphan tmux behind while iterating.
Known limitation (unchanged, P3 territory): `anet node stop` still uses
`tmux kill-session` and does NOT reliably reap the codex app-server /
codex resume worker processes when tmux dies. P3 Risk B PGID identity
teardown test will turn this red; this commit does not attempt a fix.
…k + 3 顺手
Applies 通信龙-routed 复审 verdict (7f47a2f9) PASS-WITH-CONDITIONS:
必修 (merge blocker) — writeCodexCopresenceEnvFile TOCTOU + symlink follow
agent-network/bin/cli.ts writeCodexCopresenceEnvFile:
· pre-unlink envPath (rejects a planted symlink so writeFileSync
creates a fresh regular file rather than following the link and
writing the token to the attacker-controlled target)
· writeFileSync with { mode: 0o600, flag: "wx" } (no umask-race
window; refuses to reuse a pre-existing regular file too)
· chmodSync 0o600 retained as belt-and-suspenders
· added unlinkSync to the fs import list
Micro-verified with a symlink-attack repro: attacker-planted symlink
is replaced, target file never written, mode is 0600 at creation, and
a second writeFileSync with wx correctly fails EEXIST.
顺手2 — codexHome chmodSync no longer silently swallows failure
Picked option (b): specific error + operator guidance + process.exit(1).
Rationale: 0700 on the parent is load-bearing for the 0600 env file's
security envelope; silently degrading to whatever perms exist would
invalidate 必修1 without any signal. Aligns with the surrounding
console.error/process.exit failure idiom already used elsewhere in
startCommand for other fatal-preconditions.
顺手3 — three failure paths now clean up envFilePath
cli.ts:449 (app-server didn't bind), :461 (thread/start failed),
:475 (threadId shape check failed) now each run the same
rmSync(envFilePath, {force:true}) as the pre-existing :426 catch.
Grep now shows 4 cleanup sites (was 1). Defense-in-depth for the case
where the tmux bash chain crashes before reaching `rm -f` (e.g. `.`
fails), leaving the token file on disk.
顺手4 — attach .code to JSON-RPC-reject Error so -32600 branch is live
Picked option (a): mirror codex-app-server-client.ts:354 which does
`(e as Error & {code}).code = err.error.code`. cli.ts:186 was
constructing `new Error(...)` without a `.code`, making the
isAlreadyInitializedError code-based branch dead code that fell
through to the regex fallback. Now aligned with the bridge helper's
actual behavior and the comment "mirrors codex-app-server-bridge.ts"
is once again accurate.
Not in this commit (通信龙 backlog, 另开 non-security PR):
· 9 other BOOLEAN_FLAGS (accept-dev-channels/dev-open/dry-run/follow/
no-auto-self/no-yolo/resume-latest/self/f) still swallow the next
positional — same class as fix 6 in the previous commit, not a
regression, but worth收 in a separate PR.
· parseOpts has zero unit tests; add coverage alongside.
Not attempted (P3 territory, unchanged from previous commit): tmux
kill-session still does not reap the codex app-server / codex resume
workers; the P3 Risk B PGID identity teardown test is designed to turn
this red.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fa7d7d035f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| function copresenceTmuxSessions(displayName: string): { appsrv: string; bridge: string; tui: string } { | ||
| return { appsrv: `${displayName}-appsrv`, bridge: `${displayName}-桥`, tui: displayName }; |
There was a problem hiding this comment.
Isolate co-presence tmux names from node aliases
Because valid node names may themselves end in -appsrv or -桥, these derived names collide with ordinary node tmux sessions. For example, with nodes foo and foo-appsrv, starting or stopping co-presence node foo treats the normal foo-appsrv session as its app-server and kills that unrelated node, potentially terminating active work. Use a namespace that cannot be a node alias or verify ownership before replacing or stopping a session.
Useful? React with 👍 / 👎.
| // Tiny turn persists the rollout so `codex resume --remote` can adopt. | ||
| await request("turn/start", { | ||
| threadId, | ||
| clientUserMessageId: "anet-copresence:bootstrap", | ||
| input: [{ type: "text", text: "只回复一个词:READY" }], | ||
| }, 45_000); | ||
| await new Promise((r) => setTimeout(r, 3000)); |
There was a problem hiding this comment.
Wait for the bootstrap turn to complete
turn/start only acknowledges that the asynchronous turn was created; it does not mean the model has completed it. When the bootstrap response takes more than three seconds, this closes the creator socket and starts a bridge that considers the thread idle while the server still has an active turn. A task arriving in that window gets an active-turn rejection and is returned as a failed network task rather than queued. Wait for the matching turn/completed notification, with a timeout, before returning the thread.
Useful? React with 👍 / 👎.
| // path reads codexAppServerUrl / codexThreadId from the config we just | ||
| // wrote and spawns agent-node in adopt mode. Same launchAgent() | ||
| // codepath as the non-copresence case — no fork of the bridge dispatch. | ||
| const bridgeCmd = `unset COMMHUB_TOKEN ANET_CODEX_COMMHUB_TOKEN && exec anet node start ${shellQuote(displayName)}`; |
There was a problem hiding this comment.
Relaunch the bridge through the current CLI executable
When this command is run from repository source (for example, bun agent-network/bin/cli.ts ...) or through a transient npx installation, a login shell need not have a global anet on PATH. The tmux command then exits with status 127, but the parent only sleeps and subsequently reports the three-piece node as ready even though no network bridge exists. Reinvoke the current CLI entrypoint or pass a resolved executable instead of assuming a global install.
AGENTS.md reference: AGENTS.md:L14-L16
Useful? React with 👍 / 👎.
| codexBin: opts["codex-bin"] || "codex", | ||
| codexHome: opts["codex-home"] || codexHomeDefault, | ||
| model: opts.model, | ||
| port: opts.port ? Number(opts.port) : undefined, |
There was a problem hiding this comment.
Preserve the node's configured model
When a node was created with a custom model, starting it with --copresence passes only the start-command option and therefore falls back to hard-coded gpt-5.5; the persisted resolvedForCopresence.profile.model is silently ignored. This can select an unavailable model or change cost and experiment behavior unless users redundantly repeat --model on every start. Use the CLI value as an override and otherwise retain the profile model.
Useful? React with 👍 / 👎.
| rawCfg.codexAppServerPort = port; | ||
| rawCfg.codexAppServerUrl = wsUrl; | ||
| rawCfg.codexThreadId = threadId; |
There was a problem hiding this comment.
Avoid persisting the ephemeral server as a normal start target
This writes the temporary app-server URL into the ordinary runtime configuration without recording that it must be recreated by --copresence. After anet node stop, a reboot, or an app-server crash, a later plain anet node start or anet project up sees codexAppServerUrl and enters adopt mode against the dead port instead of spawning a server. Because this runtime connects lazily on its first task, project startup can even verify the agent PID as healthy before every dispatched task fails to connect. Clear or distinguish the ephemeral URL during teardown/startup, or persist enough mode information for normal project orchestration to recreate the three-piece topology.
Useful? React with 👍 / 👎.
| s.listen(port, "127.0.0.1", () => { | ||
| const addr = s.address(); | ||
| const chosen = typeof addr === "object" && addr ? addr.port : null; | ||
| s.close(() => resolve(chosen)); |
There was a problem hiding this comment.
Keep the selected port reserved until the app-server binds
The probe closes its listening socket before the Codex child is spawned, so two concurrent co-presence starts can both select the same first free port. One app-server then loses the bind race and its command waits the full 25-second readiness timeout instead of trying another port. Reserve the port through handoff where possible, or detect the failed bind and retry allocation and launch.
Useful? React with 👍 / 👎.
| // #P2fix必修2 — shellQuote every `-c` TOML fragment (including the hub | ||
| // URL fragment). assertSafeHubUrl was called above; shellQuote guards | ||
| // even in the face of a validator regression. | ||
| const hubMcpUrlToml = `mcp_servers.commhub.url="${opts.hub}/mcp"`; |
There was a problem hiding this comment.
Normalize the hub base before appending the MCP path
When the node's hub URL ends in /—which is accepted by assertSafeHubUrl and can be stored through node create --hub—this constructs http://host:port//mcp. CommHub dispatches MCP only when url.pathname === "/mcp" in server/src/index.ts, so the double-slash endpoint returns a non-MCP response and the newly created Codex thread has no CommHub tools. Strip trailing slashes before appending /mcp, as the existing agent-node runtime already does.
Useful? React with 👍 / 👎.
| const hubBase = opts.hub.replace(/\/+$/, ""); | ||
| console.log(""); | ||
| console.log(`[anet] ✅ 共存节点 ${displayName} 就绪`); | ||
| console.log(`[anet] attach: tmux attach -t ${shellQuote(displayName)}`); | ||
| console.log(`[anet] stop: anet node stop ${shellQuote(displayName)}`); | ||
| console.log(`[anet] dashboard: ${hubBase}/nodes/${encodeURIComponent(displayName)}`); |
There was a problem hiding this comment.
Print the actual dashboard origin
For the standard local setup opts.hub is the CommHub API at port 9200, while anet hub dashboard launches the Next.js dashboard separately on port 3000. The printed http://127.0.0.1:9200/nodes/<alias> therefore hits the API server, which has no dashboard route, instead of opening the UI. Derive the configured dashboard origin or print the same port-3000 URL used elsewhere in this CLI.
Useful? React with 👍 / 👎.
| // RFC-030 P2 — `anet node start <alias> --copresence` spawns the 3-piece | ||
| // codex co-presence dance (app-server + bridge + attachable TUI). | ||
| // Replaces .demo/setup-copresence.sh. See startCopresenceOrchestration | ||
| // for the Risk C double-safeguard (default read-only; danger requires | ||
| // explicit flag + typed confirm + stderr banner) and the per-node | ||
| // CODEX_HOME isolation. | ||
| if (opts.copresence === "true") { |
There was a problem hiding this comment.
Add the required Docker E2E suite and report
This introduces a multi-process tmux/app-server/bridge/TUI workflow but the commit adds neither an independent tests/testN-* Dockerfile and runner nor a saved docs/tests/report-testN.txt. The manual validation described in the commit cannot exercise the repository's required reproducible merge gate, including the failure and teardown paths that this orchestration depends on. Add the mandated Docker E2E suite and checked-in result before merging.
AGENTS.md reference: AGENTS.md:L7-L10
Useful? React with 👍 / 👎.
做了什么
给 codex 人机共存节点加一个一等公民启动命令:
anet node start <alias> --copresence。在此之前,起一个 codex 共存节点要手工跑 demo 目录里的 bash 脚本。本 PR 让 CLI 直接编排完整三件套(codex app-server + bridge + 可 attach 的 TUI),并打印 attach / stop 指引。
agent-network/bin/cli.ts—--copresenceflag + 编排 + 向导agent-node/src/runtime/codex-app-server-bridge.ts— displaySender UX(from=api→from=控制台(dashboard),+14 行,纯展示层)安全门(两轮独立审查 + 复核)
作者不自审。三轮把关:
第一轮 → CHANGES_REQUIRED,两个真实缺陷:
/proc/*/cmdline(全局可读)以及 tmuxpane_start_command(整个 session 生命周期驻留)。改为写入<codexHome>/.anet-copresence.env(0600,父目录 0700),tmux 侧. file && rm -f file && exec。opts.hub未转义拼进bash -lc,而 hub 可来自项目本地 node config —— 克隆一个植入恶意 hub 的仓库并启用共存即可任意命令执行。改为 URL 解析 + scheme 白名单 + 危险字符拒绝 + 整段shellQuote。同轮另修:threadId 形状校验、initialize 错误吞噬收窄、非 TTY 下启用 danger 模式必须两个显式 flag(堵掉
printf 'yes' |绕过)、parseOpts认 BOOLEAN_FLAGS(此前 flag 写在位置参数前会被静默吞掉)。第二轮 → PASS-WITH-CONDITIONS。
parseOpts改动是最高回归风险(约 40 个调用点),审查以枚举全部调用点 + 17 条命令行差分执行 + 全仓 grep 确认无任何调用点依赖旧行为。余一条必修:env 文件为「先写后 chmod」,存在权限窗口,且预置符号链接会被跟随(token 写到链接目标,rm -f只删链接)。第三轮(本次收口) → 预删除(ENOENT 安全)+
writeFileSync(..., { mode: 0o600, flag: "wx" }),O_EXCL使得符号链接被重建时创建直接失败(fail-closed);另修 codexHome 权限设置失败改为拒绝启动(该 0700 是 0600 env 文件的承重不变量,不能静默降级)、三条失败路径补 env 文件清理、修活-32600死分支。边界
anet node stop目前仍可能残留 node 进程,属 P3,已知且未掩盖parseOpts零单测)单独立项 [cli][P3] parseOpts: 9 个 boolean flag 未纳入 BOOLEAN_FLAGS 会吞掉位置参数 + parseOpts 零单测 #464,未在本 PR 扩 scope验证
三件套真实拉起:token 在
ps与 tmuxpane_start_command中均零命中、env 文件 source 后即消失;4 个恶意 hub URL 全部拒绝、正常 URL 放行;非 TTY 单 flag 中止 / 双 flag 放行 / 管道 yes 仍中止。