Skip to content

feat: Linux headless CLI support (vt100 + ab_glyph renderer)#48

Open
Shadow-Azure wants to merge 19 commits into
mainfrom
feat/linux-headless-support
Open

feat: Linux headless CLI support (vt100 + ab_glyph renderer)#48
Shadow-Azure wants to merge 19 commits into
mainfrom
feat/linux-headless-support

Conversation

@Shadow-Azure

@Shadow-Azure Shadow-Azure commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Problem

cli-box 仅支持 macOS。需要以无头 daemon 形态运行在云端 Linux 服务器(x86_64),保留键盘输入(PTY)与终端截图(不带 --with-frame)用于自动化反馈。现状:发布流水线仅 macOS、代码深度耦合 macOS API,非 macOS 分支全是错误桩。

Solution(方案 A:Rust 原生终端渲染器,无 Electron)

  • PTY 跨平台化process/mod.rs):portable-pty+nix 的真实 PTY 实现从 cfg(macos) 释放为 cfg(unix),删除错误桩。键盘输入在 CLI 模式本就走 PTY 写入,天然跨平台。
  • HeadlessTerminal 渲染器capture/headless.rs,新增):vt100 解析 PTY 字节 → 终端网格 → ab_glyph+image 渲染 PNG。每个 sandbox 常驻一个,reader 线程增量 feed。
  • 字体运行时加载(非嵌入):CLIBOX_FONT~/.cli-box/font.ttf → 系统字体。部署放 Sarasa/Noto 或 apt install fonts-noto-cjk
  • 截图/scrollback headless 路由daemon/mod.rs):daemon --headless 模式;headless 时走服务端渲染。macOS 行为不变。
  • CLI headlessmain.rs):Electron 发现限 macOS(非 macOS 不下载 macOS app、走 headless);daemon stdio 分离(不挂住 $(cli-box start | sed))。

流水线

  • CI 门禁rust-linux(ubuntu 编译/clippy/test)+ e2e-linux-headless(start→screenshot→scrollback),接入 gate-result
  • 发布build-linux release job + cli-box-linux-x64 npm 包 + skill optionalDependencies

明确不支持(Linux)

--with-frame/ScreenCaptureKit、app 模式、鼠标 click/scroll 输入、Electron GUI、ui-inspect

Test Plan

  • capture::headless 单测 5/5
  • daemon_integration headless 截图 IT;全套 12/12
  • macOS 全量门禁:fmt / clippy(workspace) / cargo test 全绿
  • rust-linux CI:Linux 编译/clippy/test 通过
  • e2e-linux-headless CI:Linux 端到端通过(修了 daemon stdio 分离 + Electron 发现限 macOS + sandbox-id 提取)
  • build-linux + npm publish:实际发布(待 GitHub Release 触发;构建已由 rust-linux/e2e 间接验证)

CI 调试历程(6 轮)

libc→无条件依赖 / spawn_app_with_window stub / cfg-gate macOS helper(编译+clippy)→ daemon stdio 分离(e2e 挂起)→ sandbox-id 提取 → Electron 发现限 macOS(Linux 走 headless)。

ZN-Ice added 19 commits June 24, 2026 23:23
Design for running cli-box as a headless daemon on cloud Linux servers,
keeping keyboard input (PTY) and frame-less screenshots.

- Plan A: Rust-native terminal renderer (vt100 + ab_glyph), no Electron
- PTY un-gating (portable-pty is cross-platform)
- Screenshot/scrollback headless routing when renderer not connected
- ui-inspect explicitly out of scope (GUI-app only, no value headless)
- Linux CI gate + headless E2E + release pipeline + npm package
Embed a CJK-capable monospace TTF (Sarasa Mono SC / Noto Sans Mono CJK,
~5-20MB) instead of a Latin-only font, so Chinese CLI output renders
correctly for the automation-feedback loop. Note residual fidelity losses
(emoji tofu, truecolor quantization, no ligatures/fallback) and binary-size
mitigations (pyftsubset subsetting / lazy on-disk font).
The non-macOS stubs return Err(...) (they compile). The real Linux
compile blocker is ungated code (SESSIONS static, list_processes(),
is_session_alive()) referencing the cfg(macos)-gated PtySession type.
Earlier 'Ok(()) type bug' claim was a misread; corrected. Design and
Task structure unchanged.
9 TDD tasks: deps+font, PTY cfg(unix) un-gating, HeadlessTerminal
(vt100+ab_glyph renderer), PTY-session mounting, daemon headless
screenshot/scrollback routing, CLI --headless, Linux CI gate, headless
E2E, release pipeline + npm package. vt100/ab_glyph APIs verified
against docs.rs.
Execution environment cannot reach GitHub release assets to fetch a
redistributable CJK font binary, and embedding 5-20MB bloats git/binary.
Renderer now loads the font at runtime (CLIBOX_FONT -> ~/.cli-box/font.ttf
-> system CJK font paths). Same goal (CJK renders on cloud Linux); smaller
binary, swappable font. macOS Arial Unicode used for local tests.
The portable-pty + nix based PTY implementation was cfg(macos)-gated
with error-returning stubs on other platforms; the ungated SESSIONS
static, list_processes(), and is_session_alive() referenced the gated
PtySession type (a Linux compile blocker). Move the real impl to
cfg(unix), drop the PTY stubs. App-mode (spawn_app/with_window,
find_pids_by_app_name) stays cfg(macos). No macOS behavior change.
Pure module: feed PTY bytes -> live vt100 grid -> render PNG. Replaces
the Electron xterm.js canvas path when no renderer is connected (headless).
Font loaded at runtime (CLIBOX_FONT -> ~/.cli-box/font.ttf -> system CJK
paths). 5/5 unit tests pass (feed, ANSI color, text, PNG dims, ink).
Each PTY session now holds a persistent HeadlessTerminal, fed
incrementally by the existing reader thread (raw bytes, alongside
PtyStore). Adds get_terminal(pid) accessor for the headless screenshot
path.
Add --headless daemon mode (DaemonState.headless). When headless,
/box/{id}/screenshot renders the PTY terminal grid via HeadlessTerminal
and /box/{id}/scrollback reads PtyStore/grid text — no Electron needed.
macOS (non-headless) behavior unchanged. Adds headless_screenshot IT
(spawns real PTY, asserts source=headless). Includes incidental rustfmt
of headless.rs long lines.
When no Electron app is found (Linux/cloud), the CLI starts the daemon
with --headless and no longer spawns or waits for a renderer.
Builds and tests cli-box-core/cli/daemon on ubuntu-latest to validate
cfg(unix) gating and the headless path on Linux (the authoritative check
that cfg(macos)->cfg(unix) didn't miss anything). Installs DejaVu Mono
(CLIBOX_FONT) so headless render tests run. Wired into gate-result.
End-to-end on ubuntu-latest: cli-box start printf -> screenshot
(default + --top) -> scrollback against a --headless daemon (no
Electron). Uses printf (non-compound, no zsh needed) and DejaVu Mono
(CLIBOX_FONT) for rendering. Only end-to-end exercise of HeadlessTerminal;
wired into gate-result.
Headless Linux binaries (cli-box + cli-box-daemon, no Electron) built on
ubuntu-latest, uploaded as cli-box-linux-x64.tar.gz and published as the
cli-box-linux-x64 npm package. Skill optionalDependencies now references
it; the macOS job bumps+commits its version for repo consistency.
…with_window stub

CI rust-linux failed: (1) libc was a macOS-only dep but is used in
cross-platform process-alive checks (daemon); (2) spawn_app_with_window
is cfg(macos) but called ungated by the daemon's app-mode handlers.
Move libc to unconditional deps (trivially cross-platform) and add a
non-macos stub for spawn_app_with_window (errors: app mode is macOS-only).
CI rust-linux round 2: clippy -D warnings failed on dead code on Linux —
CHROMIUM_BUNDLE_IDS/read_bundle_id/is_chromium_app (private, used only by
the macOS spawn_app_with_window) and cg_event's keycodes import (used only
by macOS CGEvent code). Gate them cfg(macos). cleanup_chromium_data stays
ungated (daemon calls it cross-platform).
CI e2e-linux-headless hung 11min then timed out: cli-box start spawns
the daemon, which inherits the CLI's stdout pipe; when the CLI exits the
daemon keeps the write-end open, so `Sandbox daemon already running on port 15801 (pid=86172)
Creating sandbox: mode=cli, command=zsh
Sandbox created: id=21f51147, pty_pid=Some(1016), window_id=Some(9466)
Daemon port: 15801
Waiting for renderer done
Waiting for terminal
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Waiting for terminal.  
Waiting for terminal.. 
Waiting for terminal...
Sandbox ready: id=21f51147` never sees
EOF. Redirect the daemon's stdin->null and stdout/stderr->~/.cli-box/
daemon.log so it no longer holds the CLI's pipes (logs preserved).
Round-4 e2e got 'started sandbox: None' — the bare `.*id=` sed greedily
matched window_id=None (contains 'id=') on headless. Anchor on the real
field and echo raw output for debuggability.
Round-5 e2e: find_electron_binary() auto-downloaded the macOS .app on the
Linux runner (cached at ~/.cli-box/bin/), returned Some, so the CLI did NOT
go headless — it tried to exec the macOS binary (Exec format error) and
looped 'Waiting for terminal' forever. Electron is a macOS .app; return None
on non-macOS so the headless short-circuit triggers (no download, --headless
to daemon, no renderer wait).
@github-actions

Copy link
Copy Markdown

🔒 门禁检查结果

检查项 状态
Rust 格式化 ✅ success
Rust Clippy ✅ success
Rust 测试 & 覆盖率 ✅ success
前端测试 & 覆盖率 ✅ success
Playwright E2E ✅ success
统一测试 (test.sh) ✅ success
安全检查 ✅ success
发布模拟验证 ✅ success
升级流程测试 ✅ success
cli-box-skill 单元测试 ✅ success

Rust 测试覆盖率

指标 覆盖率
行覆盖率 66.3%
分支覆盖率 0.0%
模块 行覆盖率
crates.cli-box-core.src.daemon ██████████░░░░░░░░░░ 50.2%
crates.cli-box-core.src.process ███████████░░░░░░░░░ 55.9%
crates.cli-box-core.src ███████████░░░░░░░░░ 55.3%
crates.cli-box-core.src.automation ███████████░░░░░░░░░ 58.3%
crates.cli-box-core.src.server █████████████████░░░ 87.7%
crates.cli-box-core.src.instance ███████████████████░ 99.1%
crates.cli-box-core.src.capture █████████████████░░░ 85.8%
crates.cli-box-core.src.sandbox ███████████████████░ 98.8%

详细报告见 Rust 覆盖率 artifact

前端测试覆盖率

指标 覆盖率
行覆盖率 ████████████████░░░░ 78.0%
分支覆盖率 70.4%
函数覆盖率 74.2%
语句覆盖率 77.5%
文件 行覆盖率
src/tests/mocks/websocket.ts ██████████████████░░ 88.0%
src/tests/mocks/xterm.ts ████████████████████ 100.0%
src/main/daemon-bridge.ts ████████░░░░░░░░░░░░ 40.0%
src/renderer/api.ts ██████████████░░░░░░ 68.2%
src/renderer/screenshotSync.ts ████████████████████ 100.0%
src/renderer/scrollback.ts ██████████████████░░ 88.9%
src/renderer/tabState.ts ████████████████████ 100.0%
src/renderer/terminalBuffer.ts ████████████████████ 100.0%

详细报告见前端覆盖率 artifact

✅ 所有检查通过,可以合入

点击 Squash and merge 合并此PR

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.

1 participant