Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ beauticode-desktop all install

`all install` detects clients installed on your computer, sets up their background integration one by one, and creates Start menu shortcuts with background support for detected clients. It does not start the clients for you. For a client enabling CDP for the first time, launch it once using the new shortcut.

### Linux (x64, separate package `beauticode-linux`)

```sh
sudo apt install zenity # file picker (or kdialog on KDE)
npm install -g beauticode-linux --foreground-scripts
beauticode-linux all install
beauticode-linux all status
```

Supports Codex (ChatGPT desktop app), Cursor and WorkBuddy; requires Node.js 22+. Guardians go to `~/.config/autostart` and the "(背景)" launchers to `~/.local/share/applications`; no root needed. There is no tray on Linux, so built-in animated ambience themes (e.g. the rainy window) are not available. See [packages/beauticode-linux/README.md](packages/beauticode-linux/README.md).

### For DeepSeek Harness only

```sh
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,17 @@ beauticode-desktop all install

`all install` 会检测本机已安装的客户端,逐一套上后台接线,并为已检测到的客户端创建带背景的开始菜单快捷方式;它不会替你启动客户端。首次启用 CDP 的客户端请用新快捷方式启动一次。

### Linux(x64,独立包 `beauticode-linux`)

```sh
sudo apt install zenity # 文件选择器(KDE 可用 kdialog)
npm install -g beauticode-linux --foreground-scripts
beauticode-linux all install
beauticode-linux all status
```

支持 Codex(ChatGPT 桌面版)、Cursor、WorkBuddy;需要 Node.js 22+。守护写入 `~/.config/autostart`,「(背景)」启动项写入 `~/.local/share/applications`,不需要 root。Linux 没有托盘,雨窗等内置动态氛围主题暂不可用;详见 [packages/beauticode-linux/README.md](packages/beauticode-linux/README.md)。

### 只给 DeepSeek Harness 用

```sh
Expand Down
34 changes: 34 additions & 0 deletions docs/adr/0010-linux-desktop-identity-and-autostart-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Linux 桌面进程身份与启动项所有权

日期:2026-10-29。状态:采用。关联:[PR #98](https://github.com/starsstreaming/beautiCode/pull/98)。

## 背景与目标

Linux 桌面守护会在进程启动的十秒窗口内自动重启未开启 CDP 的客户端。
独立 Codex CLI 也叫 `codex`,只匹配文件名会误终止 CLI。
此外,Desktop Entry 的 Exec 值需要两层转义;拿原始路径做子串匹配会使含
`%`、`$`、引号或反斜杠的自身启动项被误判为外来文件。Cursor 原来的安装、
卸载路径则完全没有所有权检查。

## 决策与边界

- Codex 进程发现只接受 `codexInstallCandidates(..., "linux")` 中桌面安装路径的
规范化实际文件路径,继续排除 renderer、app-server 和同镜像子进程。
未识别的手工安装采用保守策略,不进入自动终止流程;新增安装布局时需要显式增加候选与回归测试。
- `processes.ts` 负责宿主身份和进程快照,`launch.ts` 负责端口、启动及连接编排,
`startup-policy.ts` 负责启动窗口与去重,`startup-repair.ts` 负责监听及自动修复。
包入口继续提供原有接口;删除内部旧路径的实现,不增加兼容转发层。
- `scripts/xdg-autostart.mjs` 是 Codex 与 Cursor 共用的启动项文件边界。
所有权必须同时匹配 Desktop Entry 分组中唯一的管理标记和完整 Exec 命令;
命令比较复用生成器的 `desktopExecQuote`,包括 host 与 PID 文件参数。
- 安装与卸载在停止守护、清理页面前先校验启动项。新文件用硬链接发布以避免覆盖
并发出现的外来文件;事务回滚也经过相同所有权检查。冲突向调用方报告,不静默删除。
- 打包与安装必须一并携带共享模块及其 portable-runtime 依赖;源码检出与独立分发
有不同目录布局,沿用本项目已有的运行时模块解析方式。

## 验证与回滚

回归覆盖独立 CLI、CLI 与桌面同时运行、未知同名程序、桌面子进程、特殊路径的
安装/重装/卸载,以及外来启动项保护。测试仅使用进程快照和临时用户目录。
全量测试、类型检查和两种平台的包暂存验证共同约束分发行为。
不改变持久化主题格式、稳定包入口或 Windows 启动协议;回滚本修复不需要数据迁移。
34 changes: 34 additions & 0 deletions docs/superpowers/plans/2026-10-29-pr98-linux-safety-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# PR #98 Linux 安全修复计划

> 使用 systematic-debugging、test-driven-development 和 executing-plans 顺序完成。

**目标:** 修复已审查确认的三项缺陷。
**设计:** [ADR 0010](../../adr/0010-linux-desktop-identity-and-autostart-ownership.md)。
**约束:** Node >=22;不新增依赖;不修改已有工作区;不操作真实客户端进程。

## 任务与验收

- [x] 对原 PR 编写并运行回归测试:独立 CLI、桌面/CLI 混合快照、同名未知程序;
特殊路径的 Codex 重装/卸载;Cursor 外来文件保护。原实现出现九个预期失败。
- [x] 进程识别收紧为已知桌面安装路径,并按发现、启动、修复、连接编排拆分超过 900 行的模块。
对应测试 `packages/adapter-codex/test/linux-processes.test.js` 从失败变为通过。
- [x] Codex 与 Cursor 复用 `scripts/xdg-autostart.mjs`,完整匹配经相同规则编码的 Exec,
冲突预检放在有副作用的步骤之前,回滚也保留外来文件。
- [x] 同步独立 Codex 与桌面聚合包、安装后的稳定运行时的依赖清单。
- [x] 类型检查、全量测试、Linux 分支集成、两种平台包暂存导入与最终独立复查。

## 验证记录(2026-10-30)

- Node 24.18.0:Windows 全量 478 项,453 通过、25 按平台或可选环境跳过、0 失败。
- Ubuntu 22.04 / WSL 的独立临时检出:478 项,440 通过、38 跳过、0 失败。
Linux 中实际运行了 Cursor install/uninstall 冲突预检测试,未触碰真实客户端。
- 两端类型检查通过。独立复查未发现新增可操作问题;源码与独立暂存包入口实际导入通过。
- Windows 首轮 DSH pack 测试出现一次 Node 测试执行器 IPC 反序列化错误;该文件
单独六项测试通过,随后完整重跑也通过,未为此修改产品代码。
- 复查范围:未知手工桌面安装路径按 ADR 保守跳过;既有 Windows 身份策略与独立
`beauticode-codex` 发布 manifest 的历史配置不在本次三个修复范围。聚合分发包继续验证完整运行时。

## 复查重点

CLI 身份不能进入自动终止流程;合法桌面继续被发现;转义后的完整命令可以重复识别;
外来文件在失败与回滚时仍然保留;安装后的代码不依赖源码目录。
91 changes: 85 additions & 6 deletions integrations/codex-desktop/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import path from "node:path";
import crypto from "node:crypto";
import { fileURLToPath, pathToFileURL } from "node:url";
import { recoverStaleCodexLock, stopInstalledCodexGuardians } from "./lifecycle.mjs";
import { createCodexRunKeyAdapter, createCodexTaskAdapter, uninstallCodexWiring } from "./task-wiring.mjs";
import { createCodexAutostartAdapter, createCodexRunKeyAdapter, createCodexTaskAdapter, uninstallCodexWiring } from "./task-wiring.mjs";
import { probeLocalCodexHealth } from "./health.mjs";

function findPackageRoot(startDir) {
Expand All @@ -34,6 +34,13 @@ const here = findPackageRoot(path.dirname(fileURLToPath(import.meta.url)));
function pluginHome() {
const local = process.env.LOCALAPPDATA || process.env.BEAUTICODE_DATA_ROOT;
if (local) return path.join(local, "beautiCode", "codex-plugin");
if (process.platform === "linux") {
// Same XDG data root as core's defaultDataRoot() and the guardian lease.
const xdg = process.env.XDG_DATA_HOME && path.isAbsolute(process.env.XDG_DATA_HOME)
? process.env.XDG_DATA_HOME
: path.join(os.homedir(), ".local", "share");
return path.join(xdg, "beautiCode", "codex-plugin");
}
return path.join(os.homedir(), ".beauticode", "codex-plugin");
}

Expand Down Expand Up @@ -63,7 +70,7 @@ async function loadPortableRuntime() {

function codexRuntimeFilter(relative, entry) {
const normalized = relative.replaceAll("\\", "/");
const prefixes = ["watch-host.mjs", "codex-watchdog.mjs", "lifecycle.mjs", "task-wiring.mjs", "health.mjs", "quick-launch.mjs", "package.json", "vendor/"];
const prefixes = ["watch-host.mjs", "codex-watchdog.mjs", "lifecycle.mjs", "task-wiring.mjs", "health.mjs", "quick-launch.mjs", "portable-runtime.mjs", "xdg-autostart.mjs", "package.json", "vendor/"];
return entry.isDirectory()
? prefixes.some((prefix) => prefix.startsWith(normalized) || normalized.startsWith(prefix))
: prefixes.some((prefix) => prefix.endsWith("/") ? normalized.startsWith(prefix) : normalized === prefix);
Expand Down Expand Up @@ -142,6 +149,60 @@ function startIndependentWindows(starter) {
);
}

/** Stop the previous guardian and wait until it released its lease. */
async function stopLinuxGuardians(home, timeoutMs = 6_000) {
const deadline = Date.now() + timeoutMs;
while (stopInstalledCodexGuardians(home, home) > 0 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 200));
}
}

// Same teardown the renderer runs on a host handoff: background stage, rail
// entry and console go away without touching the saved theme on disk.
const CODEX_PAGE_RESTORE = `(() => {
try { window.__BEAUTICODE_BG__?.dispose?.(); } catch {}
try { window.__beauticodeConsoleDispose?.(); } catch {}
for (const node of document.querySelectorAll('[id^="beauticode-"]')) node.remove();
// Drop the load guards too, or a later reinstall in this same page sees
// "already loaded" and never re-mounts the 背景 entry.
try { if (window.__beauticodeGalleryKeydown) document.removeEventListener("keydown", window.__beauticodeGalleryKeydown); } catch {}
for (const key of ["__beauticodeConsoleLoaded", "__beauticodeConsoleRev", "__beauticodeGalleryLoaded", "__beauticodeGalleryKeydown", "__BEAUTICODE_BG__"]) {
try { delete window[key]; } catch {}
}
return true;
})()`;

/** Linux uninstall: restore open Codex windows instead of waiting for a restart. */
async function restoreLinuxCodexPages(home) {
const adapterFile = [path.join(home, "vendor", "adapter-codex", "index.js"), path.join(here, "vendor", "adapter-codex", "index.js"),
path.resolve(here, "../../packages/adapter-codex/dist/index.js")].find((file) => fs.existsSync(file));
if (!adapterFile) return 0;
const adapter = await import(pathToFileURL(adapterFile).href);
const ports = [...new Set((await adapter.listCodexProcesses()).map((proc) => proc.port).filter(Number.isInteger))];
let restored = 0;
for (const port of ports) {
let targets = [];
try { targets = await (await fetch(`http://127.0.0.1:${port}/json/list`, { signal: AbortSignal.timeout(2_000) })).json(); }
catch { continue; }
for (const target of targets) {
if (target.type !== "page" || !String(target.url || "").startsWith("app://")) continue;
const debuggerUrl = new URL(target.webSocketDebuggerUrl);
if (debuggerUrl.hostname !== "127.0.0.1" || Number(debuggerUrl.port) !== port) continue;
await new Promise((resolve) => {
const ws = new WebSocket(debuggerUrl.href);
const done = () => { clearTimeout(timer); try { ws.close(); } catch {} resolve(); };
const timer = setTimeout(done, 3_000);
ws.addEventListener("open", () => ws.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression: CODEX_PAGE_RESTORE, returnByValue: true } })));
ws.addEventListener("message", (event) => {
if (JSON.parse(String(event.data)).id === 1) { restored += 1; done(); }
});
ws.addEventListener("error", done);
});
}
}
return restored;
}

export function parseCodexCliMode(argv) {
if (argv.length === 0) return "install";
if (argv.length !== 1) throw new Error("Unknown Codex option");
Expand Down Expand Up @@ -169,14 +230,19 @@ export async function runCli(argv = process.argv.slice(2)) {
runKey: createCodexRunKeyAdapter(path.join(home, "start-watch.ps1")),
stopOwned: async () => { stopInstalledCodexGuardians(home, home); },
});
} else if (process.platform === "linux") {
createCodexAutostartAdapter(path.join(home, "launcher.sh")).removeOwned();
await stopLinuxGuardians(home);
const restored = await restoreLinuxCodexPages(home).catch(() => 0);
if (restored) console.log(`已还原 ${restored} 个打开的 Codex 页面(保存的主题保留)。`);
}
console.log("已取消开机自动注入。打开 Codex 前请自行启动 beautiCode。");
return;
}
await fsp.mkdir(home, { recursive: true });
for (const name of ["watch-host.mjs", "codex-watchdog.mjs", "lifecycle.mjs", "task-wiring.mjs", "health.mjs", "quick-launch.mjs", "package.json"]) {
const source = name === "quick-launch.mjs" && !fs.existsSync(path.join(here, name))
? path.resolve(here, "../../scripts/quick-launch.mjs")
for (const name of ["watch-host.mjs", "codex-watchdog.mjs", "lifecycle.mjs", "task-wiring.mjs", "health.mjs", "quick-launch.mjs", "portable-runtime.mjs", "xdg-autostart.mjs", "package.json"]) {
const source = ["quick-launch.mjs", "portable-runtime.mjs", "xdg-autostart.mjs"].includes(name) && !fs.existsSync(path.join(here, name))
? path.resolve(here, "../../scripts", name)
: path.join(here, name);
await copyFileAtomic(source, path.join(home, name));
}
Expand All @@ -199,7 +265,9 @@ export async function runCli(argv = process.argv.slice(2)) {
}
const watch = path.join(home, "codex-watchdog.mjs");
const starter = path.join(home, "start-watch.ps1");
await portable.writeTextAtomic(starter, portable.renderCodexStarter({ launcher: runtime.launcher }));
if (process.platform === "win32") {
await portable.writeTextAtomic(starter, portable.renderCodexStarter({ launcher: runtime.launcher }));
}
const namespaceLock = path.join(home, "..", "hosts", "codex", "injector.lock");
await recoverStaleCodexLock(namespaceLock, home).catch(() => false);
if (process.platform === "win32") {
Expand All @@ -213,6 +281,17 @@ export async function runCli(argv = process.argv.slice(2)) {
`本次未能立即启动后台监视器:${error instanceof Error ? error.message : String(error)}。下次登录时会自动启动。`,
);
}
} else if (process.platform === "linux") {
// XDG autostart replaces the scheduled task / Run key; the guardian is
// also started now, detached from this terminal.
const autostart = createCodexAutostartAdapter(runtime.launcher);
autostart.write(portable.renderXdgDesktopEntry({
name: "beautiCode Codex background guardian",
comment: "Re-applies beautiCode backgrounds to Codex through loopback CDP",
launcher: runtime.launcher,
}));
await stopLinuxGuardians(home);
portable.startDetachedLauncher(runtime.launcher);
} else {
const node = await portable.resolveNodeExecutable({ stableRoot: runtime.hostRoot });
startHidden(node.path, watch);
Expand Down
4 changes: 3 additions & 1 deletion integrations/codex-desktop/codex-watchdog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ if (
fileURLToPath(import.meta.url).toLowerCase()
) {
try {
const root = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
const root = process.env.LOCALAPPDATA || (process.platform === "linux"
? (process.env.XDG_DATA_HOME && path.isAbsolute(process.env.XDG_DATA_HOME) ? process.env.XDG_DATA_HOME : path.join(os.homedir(), ".local", "share"))
: path.join(os.homedir(), "AppData", "Local"));
const lease = await acquireCodexGuardianLease(path.join(root, "beautiCode", "hosts", "codex"));
const watchdog = startCodexHelperWatchdog({
log: (message) => process.stderr.write(`[codex-watchdog] ${message}\n`),
Expand Down
19 changes: 15 additions & 4 deletions integrations/codex-desktop/health.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createCodexRunKeyAdapter, createCodexTaskAdapter } from "./task-wiring.mjs";
import { codexAutostartPath, createCodexRunKeyAdapter, createCodexTaskAdapter, isOwnedCodexAutostart } from "./task-wiring.mjs";
import { parseCodexLockOwner, processCommandLine } from "./lifecycle.mjs";

const here = path.dirname(fileURLToPath(import.meta.url));
const base = () => path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "beautiCode");
const localBase = () => process.env.LOCALAPPDATA || (process.platform === "linux"
? (process.env.XDG_DATA_HOME && path.isAbsolute(process.env.XDG_DATA_HOME) ? process.env.XDG_DATA_HOME : path.join(os.homedir(), ".local", "share"))
: path.join(os.homedir(), "AppData", "Local"));
const base = () => path.join(localBase(), "beautiCode");
const launcherName = () => (process.platform === "linux" ? "launcher.sh" : "launcher.ps1");

export function classifyEntry(node) {
if (!node?.exists) return "not-mounted";
Expand Down Expand Up @@ -85,7 +89,7 @@ export function isCodexRuntimeReady(baseRoot) {
const home = path.join(baseRoot, "codex-plugin");
const runtimeHome = home;
if (!fs.existsSync(path.join(home, "codex-watchdog.mjs")) ||
!fs.existsSync(path.join(runtimeHome, "launcher.ps1"))) return false;
!fs.existsSync(path.join(runtimeHome, launcherName()))) return false;
try {
const pointer = JSON.parse(fs.readFileSync(path.join(runtimeHome, "current.json"), "utf8"));
if (pointer.schema !== "beauticode.runtime/v1" || pointer.host !== "codex-plugin" ||
Expand All @@ -97,6 +101,13 @@ export function isCodexRuntimeReady(baseRoot) {
async function installed() {
const home = path.join(base(), "codex-plugin");
const runtimeReady = isCodexRuntimeReady(base());
if (process.platform === "linux") {
// The XDG autostart entry plays the scheduled task's role on Linux.
let taskOwned = false;
try { taskOwned = isOwnedCodexAutostart(await fsp.readFile(codexAutostartPath(), "utf8"), path.join(home, "launcher.sh")); }
catch { /* missing autostart is not ownership */ }
return { runtimeReady, taskOwned, runKeyOwned: false };
}
if (process.platform !== "win32") return { runtimeReady: false, taskOwned: false, runKeyOwned: false };
const starter = path.join(home, "start-watch.ps1");
const task = createCodexTaskAdapter(starter);
Expand Down Expand Up @@ -135,7 +146,7 @@ const ENTRY_EXPRESSION = `(() => {
})()`;

export async function probeLocalCodexHealth() {
if (process.platform !== "win32") return {
if (process.platform !== "win32" && process.platform !== "linux") return {
installation: "missing", taskOwned: false, runKeyOwned: false, guardian: "unknown", host: "unknown",
cdp: "unknown", entry: "not-checked", primaryPages: 0, action: "none", reason: "unsupported-platform",
};
Expand Down
Loading
Loading