Skip to content

feat(rfc-030): anet node start --copresence — codex 人机共存一等公民命令 - #465

Merged
vansin merged 3 commits into
mainfrom
feat/rfc-030-copresence-p2-wizard
Jul 28, 2026
Merged

feat(rfc-030): anet node start --copresence — codex 人机共存一等公民命令#465
vansin merged 3 commits into
mainfrom
feat/rfc-030-copresence-p2-wizard

Conversation

@vansin

@vansin vansin commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

做了什么

给 codex 人机共存节点加一个一等公民启动命令:anet node start <alias> --copresence

在此之前,起一个 codex 共存节点要手工跑 demo 目录里的 bash 脚本。本 PR 让 CLI 直接编排完整三件套(codex app-server + bridge + 可 attach 的 TUI),并打印 attach / stop 指引。

  • agent-network/bin/cli.ts--copresence flag + 编排 + 向导
  • agent-node/src/runtime/codex-app-server-bridge.ts — displaySender UX(from=apifrom=控制台(dashboard),+14 行,纯展示层)

安全门(两轮独立审查 + 复核)

作者不自审。三轮把关:

第一轮 → CHANGES_REQUIRED,两个真实缺陷:

  1. 节点 token 落到 argv:token 被烤进 tmux 命令串,会出现在 /proc/*/cmdline(全局可读)以及 tmux pane_start_command(整个 session 生命周期驻留)。改为写入 <codexHome>/.anet-copresence.env(0600,父目录 0700),tmux 侧 . file && rm -f file && exec
  2. hub 参数 shell 注入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 死分支。

边界

验证

三件套真实拉起:token 在 ps 与 tmux pane_start_command 中均零命中、env 文件 source 后即消失;4 个恶意 hub URL 全部拒绝、正常 URL 放行;非 TTY 单 flag 中止 / 双 flag 放行 / 管道 yes 仍中止。

vansin and others added 3 commits July 28, 2026 21:58
…存 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.
@vansin
vansin merged commit a5f6ece into main Jul 28, 2026
6 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread agent-network/bin/cli.ts
Comment on lines +241 to +242
function copresenceTmuxSessions(displayName: string): { appsrv: string; bridge: string; tui: string } {
return { appsrv: `${displayName}-appsrv`, bridge: `${displayName}-桥`, tui: displayName };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread agent-network/bin/cli.ts
Comment on lines +221 to +227
// 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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread agent-network/bin/cli.ts
// 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)}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread agent-network/bin/cli.ts
Comment on lines +4211 to +4214
codexBin: opts["codex-bin"] || "codex",
codexHome: opts["codex-home"] || codexHomeDefault,
model: opts.model,
port: opts.port ? Number(opts.port) : undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread agent-network/bin/cli.ts
Comment on lines +492 to +494
rawCfg.codexAppServerPort = port;
rawCfg.codexAppServerUrl = wsUrl;
rawCfg.codexThreadId = threadId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread agent-network/bin/cli.ts
Comment on lines +119 to +122
s.listen(port, "127.0.0.1", () => {
const addr = s.address();
const chosen = typeof addr === "object" && addr ? addr.port : null;
s.close(() => resolve(chosen));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread agent-network/bin/cli.ts
// #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"`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread agent-network/bin/cli.ts
Comment on lines +536 to +541
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)}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread agent-network/bin/cli.ts
Comment on lines +4195 to +4201
// 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") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants