Skip to content

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

Merged
Shadow-Azure merged 22 commits into
mainfrom
feat/linux-headless-support
Jul 19, 2026
Merged

feat: Linux headless CLI support (vt100 + ab_glyph renderer)#48
Shadow-Azure merged 22 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

github-actions Bot commented Jun 25, 2026

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 测试覆盖率

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

详细报告见 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

ZN-Ice and others added 2 commits July 19, 2026 10:25
render_png set the vt100 parser's persistent scrollback_offset to render
a scrolled view but never restored it. The leaked offset corrupted later
rendered_text() calls (headless scrollback non-raw), showing scrolled-back
history instead of the current screen after any --top screenshot.

Reset offset to 0 before returning. Document that headless non-raw
scrollback returns the current screen only (full history via raw), and
add IT coverage for the raw/non-raw paths plus an end-to-end regression.

Co-Authored-By: Claude <noreply@anthropic.com>
Issue-4 verification: ab_glyph's FontVec::try_from_vec delegates to
try_from_vec_and_index(.., 0), so a .ttc collection loads face 0 and
resolves/outlines CJK glyphs correctly (verified with PingFang.ttc). The
NotoSansCJK .ttc candidate in load_font works as-is — document the
behaviour to remove the earlier "unverified" doubt.

Co-Authored-By: Claude <noreply@anthropic.com>
@Shadow-Azure

Copy link
Copy Markdown
Owner Author

Code Review — Linux headless CLI support

Reviewed e79c3ab..852203d (base v0.3.0 → head).

Strengths

  • PTY 跨平台化干净 — cfg(macos)cfg(unix), 删除 ~60 行重复错误桩
  • HeadlessTerminal 纯函数设计;调色板 (16 + 6×6×6 cube + grayscale) 正确;cell.inverse() 的 fg/bg 交换到位
  • daemon 侧 spawn_blocking 包装渲染,不阻塞 async runtime
  • CI 门禁闭环 — rust-linux + e2e-linux-headless 接入 gate-result 强制判定

Issues & Resolution

# Severity Issue Status
1 Important render_pngscrollback_offset 泄漏,污染后续 rendered_text() (headless scrollback non-raw) ✅ Fixed cf20c3f (+ 单测 + 端到端 IT)
2 Important headless non-raw scrollback 语义与 macOS (xterm.js 全 buffer) 不一致 ✅ Documented cf20c3f (raw=完整历史 / non-raw=当前屏幕)
3 Important headless scrollback 路径此前零 IT 覆盖 ✅ Added IT cf20c3f (raw + non-raw + offset 回归)
4 Minor→非bug .ttc collection 字体加载存疑 ✅ Verified non-bug 852203d (ab_glyph 取 face 0,实测 PingFang.ttc 渲染 CJK 正常)

Assessment

Ready to merge: Yes — offset-leak 修复已落地并经回归测试验证 (回退修复则单测与 IT 均失败)。核心架构扎实,CI 全绿,macOS 回归通过。

🤖 Generated with Claude Code

All 9 implementation tasks shipped on PR #48 (CI green, macOS regression
clean). Backfill the plan's step checkboxes so the doc reflects reality.

Co-Authored-By: Claude <noreply@anthropic.com>
@Shadow-Azure
Shadow-Azure merged commit 5ca9165 into main Jul 19, 2026
18 checks passed
@Shadow-Azure Shadow-Azure mentioned this pull request Jul 19, 2026
3 tasks
Shadow-Azure added a commit that referenced this pull request Jul 19, 2026
Ship Linux headless CLI support (#48) as a new release. v0.3.0 has no
Linux artifacts — the build-linux job landed in #48 after v0.3.0 was
tagged, so a new version is required.

Co-authored-by: ZN-Ice <zn-ice@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
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