From 4be860371640139b6b134166677438ecfa163fd1 Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Tue, 27 Oct 2026 21:58:35 -0400 Subject: [PATCH 01/13] feat(core): add read-only Linux /proc process helpers Linux counterpart of the Win32_Process reads: process snapshot, main-process selection (drops --type= children and same-image helpers) and an identity- checked signal helper. --- packages/core/src/index.ts | 1 + packages/core/src/linux-process.ts | 142 +++++++++++++++++++++++ packages/core/test/linux-process.test.js | 32 +++++ 3 files changed, 175 insertions(+) create mode 100644 packages/core/src/linux-process.ts create mode 100644 packages/core/test/linux-process.test.js diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 12bed4f..370c773 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -21,3 +21,4 @@ export * from "./apply-transaction.js"; export * from "./process-liveness.js"; export * from "./startup-repair-chain.js"; export * from "./skin-catalog.js"; +export * from "./linux-process.js"; diff --git a/packages/core/src/linux-process.ts b/packages/core/src/linux-process.ts new file mode 100644 index 0000000..6b16cac --- /dev/null +++ b/packages/core/src/linux-process.ts @@ -0,0 +1,142 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Read-only Linux process snapshot built from /proc. It is the Linux + * counterpart of the Windows `Get-CimInstance Win32_Process` reads: no shell, + * no `ps` parsing, and only the current user's readable entries. + */ +export interface LinuxProcessInfo { + pid: number; + parentPid: number; + /** Kernel short name (comm), at most 15 characters. */ + name: string; + /** Resolved executable path, or "" when /proc//exe is unreadable. */ + executablePath: string; + argv: string[]; + /** argv joined with spaces; arguments containing whitespace are double-quoted. */ + commandLine: string; + /** Process start time in epoch milliseconds, or null when unavailable. */ + createdAtMs: number | null; +} + +/** Linux exposes USER_HZ=100 on every mainstream architecture. */ +const CLOCK_TICKS_PER_SECOND = 100; + +let cachedBootTimeMs: number | null | undefined; + +export function linuxBootTimeMs(procRoot = "/proc"): number | null { + if (procRoot === "/proc" && cachedBootTimeMs !== undefined) return cachedBootTimeMs; + let value: number | null = null; + try { + const match = /^btime\s+(\d+)$/m.exec(fs.readFileSync(path.join(procRoot, "stat"), "utf8")); + value = match ? Number(match[1]) * 1_000 : null; + } catch { + value = null; + } + if (procRoot === "/proc") cachedBootTimeMs = value; + return value; +} + +/** Parse /proc//stat; the comm field may contain spaces and parentheses. */ +export function parseLinuxProcStat(raw: string): { name: string; parentPid: number; startTicks: number } | null { + const open = raw.indexOf("("); + const close = raw.lastIndexOf(")"); + if (open < 0 || close < open) return null; + const rest = raw.slice(close + 2).trim().split(/\s+/); + // rest[0] is field 3 (state); ppid is field 4, starttime is field 22. + const parentPid = Number(rest[1]); + const startTicks = Number(rest[19]); + if (!Number.isFinite(parentPid) || !Number.isFinite(startTicks)) return null; + return { name: raw.slice(open + 1, close), parentPid, startTicks }; +} + +export function joinLinuxCommandLine(argv: readonly string[]): string { + return argv.map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" "); +} + +export function readLinuxProcess(pid: number, procRoot = "/proc"): LinuxProcessInfo | null { + if (!Number.isInteger(pid) || pid < 1) return null; + const dir = path.join(procRoot, String(pid)); + let stat: ReturnType; + let argv: string[]; + try { + stat = parseLinuxProcStat(fs.readFileSync(path.join(dir, "stat"), "utf8")); + argv = fs.readFileSync(path.join(dir, "cmdline"), "utf8").split("\0"); + } catch { + return null; + } + if (!stat) return null; + while (argv.length && argv[argv.length - 1] === "") argv.pop(); + let executablePath = ""; + try { + executablePath = fs.readlinkSync(path.join(dir, "exe")).replace(/ \(deleted\)$/, ""); + } catch { + /* Kernel threads and other users' processes are unreadable. */ + } + const boot = linuxBootTimeMs(procRoot); + return { + pid, + parentPid: stat.parentPid, + name: stat.name, + executablePath, + argv, + commandLine: joinLinuxCommandLine(argv), + createdAtMs: boot == null ? null : Math.round(boot + (stat.startTicks * 1_000) / CLOCK_TICKS_PER_SECOND), + }; +} + +export function listLinuxProcesses( + filter: (info: LinuxProcessInfo) => boolean = () => true, + procRoot = "/proc", +): LinuxProcessInfo[] { + let entries: string[]; + try { + entries = fs.readdirSync(procRoot); + } catch { + return []; + } + const out: LinuxProcessInfo[] = []; + for (const entry of entries) { + if (!/^\d+$/.test(entry)) continue; + const info = readLinuxProcess(Number(entry), procRoot); + if (info && filter(info)) out.push(info); + } + return out; +} + +/** + * Electron/Chromium hosts re-exec their own binary for helpers (`--type=…`) + * and for ELECTRON_RUN_AS_NODE workers (a script path argument). The main + * process is the one whose parent is not the same executable. + */ +export function selectLinuxMainProcesses( + rows: readonly LinuxProcessInfo[], + isCandidate: (info: LinuxProcessInfo) => boolean, +): LinuxProcessInfo[] { + const candidates = rows.filter(isCandidate); + const images = new Map(rows.map((row) => [row.pid, row.executablePath])); + return candidates.filter((row) => + !/(?:^|\s)--type(?:=|\s)/.test(row.commandLine) && + images.get(row.parentPid) !== row.executablePath, + ); +} + +/** Same-user liveness check with an optional creation-time identity guard. */ +export function signalLinuxProcess( + pid: number, + signal: NodeJS.Signals, + expectedCreatedAtMs?: number | null, +): boolean { + if (expectedCreatedAtMs != null) { + const current = readLinuxProcess(pid); + if (!current || current.createdAtMs == null) return false; + if (Math.abs(current.createdAtMs - expectedCreatedAtMs) > 1_000) return false; + } + try { + process.kill(pid, signal); + return true; + } catch { + return false; + } +} diff --git a/packages/core/test/linux-process.test.js b/packages/core/test/linux-process.test.js new file mode 100644 index 0000000..d102bc1 --- /dev/null +++ b/packages/core/test/linux-process.test.js @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { joinLinuxCommandLine, parseLinuxProcStat, readLinuxProcess, selectLinuxMainProcesses } from "../dist/index.js"; + +test("parses /proc stat with spaces and parentheses in comm", () => { + const parsed = parseLinuxProcStat(`123 (Web (Content) x) ${["S", "45", ...Array(17).fill("0"), "98765", "0"].join(" ")}`); + assert.equal(parsed?.name, "Web (Content) x"); + assert.equal(parsed?.parentPid, 45); + assert.equal(parsed?.startTicks, 98765); +}); + +test("quotes argv entries that contain whitespace", () => { + assert.equal(joinLinuxCommandLine(["/opt/A B/app", "--x=1"]), '"/opt/A B/app" --x=1'); +}); + +test("main-process selection drops Chromium children and same-image helpers", () => { + const row = (pid, parentPid, commandLine, executablePath = "/usr/share/cursor/cursor") => + ({ pid, parentPid, name: "cursor", executablePath, argv: commandLine.split(" "), commandLine, createdAtMs: 1 }); + const rows = [ + row(10, 1, "/usr/share/cursor/cursor"), + row(11, 10, "/usr/share/cursor/cursor --type=renderer"), + row(12, 10, "/usr/share/cursor/cursor /usr/share/cursor/resources/app/out/server.js"), + ]; + assert.deepEqual(selectLinuxMainProcesses(rows, () => true).map((entry) => entry.pid), [10]); +}); + +test("reads the current process", { skip: process.platform !== "linux" }, () => { + const self = readLinuxProcess(process.pid); + assert.equal(self?.pid, process.pid); + assert.equal(self?.executablePath, process.execPath); + assert.ok(Math.abs((self?.createdAtMs ?? 0) - (Date.now() - process.uptime() * 1000)) < 2_000); +}); From 4b4ea38ee75055933b1c1f7dce496517662d2cbb Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Tue, 27 Oct 2026 21:58:55 -0400 Subject: [PATCH 02/13] feat(adapters): Linux discovery, launch and startup repair - desktop-cdp: spec.linux executable candidates, /proc main-process scan, argv-split CDP flags, SIGTERM/SIGKILL startup repair with identity guard - cursor: /usr/share/cursor, /opt/Cursor, ~/.local and override candidates - codex: ChatGPT/Codex Linux install paths, /proc scan, wait-for-exit restart so the relaunch keeps the CDP flags, zenity/kdialog media picker - workbuddy: /opt/WorkBuddy candidates, /proc scan that ignores ELECTRON_RUN_AS_NODE workers and reads the CDP port from its environment Windows code paths are unchanged. --- packages/adapter-codex/src/launch.ts | 108 +++++++++++++++++- packages/adapter-codex/src/native-picker.ts | 34 ++++++ .../adapter-codex/test/codex-health.test.js | 2 +- packages/adapter-cursor/src/spec.ts | 17 +++ packages/adapter-desktop-cdp/src/launch.ts | 58 +++++++++- .../adapter-desktop-cdp/src/startup-repair.ts | 84 +++++++++++++- packages/adapter-desktop-cdp/src/types.ts | 5 + packages/adapter-workbuddy/src/launch.ts | 50 ++++++++ 8 files changed, 350 insertions(+), 8 deletions(-) diff --git a/packages/adapter-codex/src/launch.ts b/packages/adapter-codex/src/launch.ts index 39a3b3d..5458e3e 100644 --- a/packages/adapter-codex/src/launch.ts +++ b/packages/adapter-codex/src/launch.ts @@ -5,6 +5,12 @@ import os from "node:os"; import path from "node:path"; import readline from "node:readline"; import { promisify } from "node:util"; +import { + listLinuxProcesses, + readLinuxProcess, + selectLinuxMainProcesses, + signalLinuxProcess, +} from "@beauticode/core"; import { DEFAULT_CDP_CANDIDATE_PORTS, discoverCdpEndpoints, @@ -140,14 +146,30 @@ export function looksLikeCodexMain( if (/\\resources\\/i.test(exePath) || /\\resources\\/i.test(commandLine)) { return false; } + // Linux/macOS bundle the Codex CLI app-server under resources/ as well. + if (/\/resources\//i.test(exePath)) return false; return true; } +/** Official Linux packages ship the Electron/Owl shell as ChatGPT (or Codex). */ +export const LINUX_CODEX_EXECUTABLE_NAMES = Object.freeze(["ChatGPT", "Codex", "chatgpt", "codex"]); + export function codexInstallCandidates( env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform, ): string[] { const home = env.HOME || env.USERPROFILE || os.homedir(); + if (platform === "linux") { + return [ + "/usr/lib/chatgpt/ChatGPT", + "/opt/ChatGPT/ChatGPT", + "/opt/chatgpt/ChatGPT", + "/usr/lib/codex/Codex", + "/opt/Codex/Codex", + path.posix.join(home, ".local", "share", "chatgpt", "ChatGPT"), + path.posix.join(home, ".local", "opt", "ChatGPT", "ChatGPT"), + ]; + } if (platform !== "win32") return []; const local = env.LOCALAPPDATA || path.join(home, "AppData", "Local"); const pf = env.ProgramFiles || "C:\\Program Files"; @@ -296,10 +318,53 @@ async function scanWindowsCodexProcesses(): Promise { } } +/** /proc counterpart of the Win32_Process scan: main ChatGPT/Codex binaries only. */ +export function scanLinuxCodexProcesses(): CodexProcess[] { + const isCodexImage = (exe: string) => + LINUX_CODEX_EXECUTABLE_NAMES.includes(path.posix.basename(exe)) && + !/\/resources\//i.test(exe); + const rows = listLinuxProcesses((row) => isCodexImage(row.executablePath)); + return selectLinuxMainProcesses(rows, (row) => + looksLikeCodexMain(row.commandLine, path.posix.basename(row.executablePath), row.executablePath), + ).map((row) => ({ + pid: row.pid, + name: path.posix.basename(row.executablePath), + executablePath: row.executablePath, + commandLine: row.commandLine, + port: parseRemoteDebuggingFlags(row.commandLine).port, + createdAtMs: row.createdAtMs, + })); +} + export async function listCodexProcesses(): Promise { + if (process.platform === "linux") return scanLinuxCodexProcesses(); return scanWindowsCodexProcesses(); } +/** + * Stop one exact Linux main process (PID + creation time) and wait for it to + * exit: a relaunch while the old instance is alive would just hand its + * arguments to the singleton and drop the CDP flags. + */ +async function stopLinuxCodexProcess( + proc: CodexProcess, + graceful: boolean, + timeoutMs = 8_000, +): Promise { + if (!proc.pid || proc.pid <= 0) return false; + if (!signalLinuxProcess(proc.pid, graceful ? "SIGTERM" : "SIGKILL", proc.createdAtMs)) { + return false; + } + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!readLinuxProcess(proc.pid)) return true; + await delay(100); + } + signalLinuxProcess(proc.pid, "SIGKILL", proc.createdAtMs); + await delay(250); + return !readLinuxProcess(proc.pid); +} + export async function isLoopbackPortFree(port: number): Promise { if (!Number.isInteger(port) || port < 1 || port > 65535) return false; return await new Promise((resolve) => { @@ -349,6 +414,10 @@ export async function pickAvailableCodexPort( export async function stopCodexProcesses( procs: readonly CodexProcess[], ): Promise { + if (process.platform === "linux") { + for (const proc of procs) await stopLinuxCodexProcess(proc, true); + return; + } for (const proc of procs) { if (!proc.pid || proc.pid <= 0) continue; await execFileAsync("taskkill.exe", ["/PID", String(proc.pid), "/T"], { @@ -498,7 +567,11 @@ async function repairFreshBlindCodexProcess( `Codex 新主进程 ${exact.pid} 未带 CDP 参数,立即使用 :${port} 修复性重启。`, ); - try { + if (process.platform === "linux") { + // SIGTERM is Chromium's fast clean exit; wait so the relaunch is not + // swallowed by the still-running singleton. + if (!(await stopLinuxCodexProcess(exact, true, 5_000))) return; + } else try { // Exact fresh PID only: no /T and no name-wide kill. At <10s there is no // established user task to preserve, and /F avoids the old 8s stop delay. await execFileAsync("taskkill.exe", ["/PID", String(exact.pid), "/F"], { @@ -520,6 +593,7 @@ async function repairFreshBlindCodexProcess( export function startCodexStartupRepairMonitor( opts: CodexStartupRepairMonitorOptions = {}, ): CodexStartupRepairMonitor { + if (process.platform === "linux") return startLinuxCodexStartupRepairMonitor(opts); if (process.platform !== "win32") return { close: () => {} }; let closed = false; @@ -634,6 +708,32 @@ export function startCodexStartupRepairMonitor( }; } +/** + * Linux fast lane: sample /proc every 300 ms (no unprivileged process-start + * event exists) and feed the same controller as the Windows monitor. + */ +function startLinuxCodexStartupRepairMonitor( + opts: CodexStartupRepairMonitorOptions, +): CodexStartupRepairMonitor { + const controllerOptions: CodexStartupRepairControllerOptions = { + repair: (proc) => repairFreshBlindCodexProcess(proc, opts), + }; + if (opts.repairWindowMs !== undefined) { + controllerOptions.repairWindowMs = opts.repairWindowMs; + } + const controller = new CodexStartupRepairController(controllerOptions); + const timer = setInterval(() => { + for (const proc of scanLinuxCodexProcesses()) { + void controller.observe(proc).catch((error) => { + opts.log?.warn( + `Codex 启动快速修复失败:${error instanceof Error ? error.message : String(error)}`, + ); + }); + } + }, 300); + return { close: () => clearInterval(timer) }; +} + async function waitForCodexCdp( port: number, timeoutMs: number, @@ -728,7 +828,11 @@ async function ensureCodexCdpUnqueued( log?.warn( `Codex 新主进程 ${proc.pid} 未带 CDP 参数,立即修复性重启…`, ); - try { + if (process.platform === "linux") { + if (!(await stopLinuxCodexProcess(proc, true, 5_000))) { + throw new Error("Codex 新主进程已退出,取消本次自动重启。"); + } + } else try { await execFileAsync("taskkill.exe", ["/PID", String(proc.pid), "/F"], { windowsHide: true, timeout: 2_000, diff --git a/packages/adapter-codex/src/native-picker.ts b/packages/adapter-codex/src/native-picker.ts index 5af348d..f60a760 100644 --- a/packages/adapter-codex/src/native-picker.ts +++ b/packages/adapter-codex/src/native-picker.ts @@ -42,9 +42,43 @@ function buildWindowsPickerScript(kind: "image" | "video"): string { ].join("; "); } +/** zenity first, then kdialog. Exit 1 with no output is a user cancel for both. */ +async function pickLinuxMedia(kind: "image" | "video"): Promise { + const patterns = kind === "video" ? "*.mp4 *.mov" : "*.jpg *.jpeg *.png *.webp *.avif"; + const title = "选择 beautiCode 背景文件"; + const candidates: Array<[string, string[]]> = [ + ["zenity", ["--file-selection", `--title=${title}`, `--file-filter=${kind === "video" ? "Video" : "Image"} | ${patterns}`]], + ["kdialog", ["--title", title, "--getopenfilename", ".", patterns]], + ]; + for (const [command, args] of candidates) { + const result = await new Promise<{ missing: boolean; code: number | null; stdout: string; stderr: string }>((resolve) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => child.kill("SIGKILL"), PICKER_TIMEOUT_MS); + child.stdout?.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; }); + child.stderr?.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; }); + child.once("error", (error) => { + clearTimeout(timer); + resolve({ missing: (error as NodeJS.ErrnoException).code === "ENOENT", code: null, stdout, stderr: String(error) }); + }); + child.once("close", (code) => { clearTimeout(timer); resolve({ missing: false, code, stdout, stderr }); }); + }); + if (result.missing) continue; + const selected = result.stdout.trim(); + if (result.code === 0 && selected) { + return { ok: true, kind, path: path.resolve(selected), name: path.basename(selected) }; + } + if (result.code === 1 && !selected) return { ok: true, cancelled: true }; + throw pickerUnavailable(result.stderr.trim() || `文件选择器退出异常(${result.code})。`); + } + throw pickerUnavailable("没有找到 zenity 或 kdialog,无法打开文件选择器。请安装 zenity(例如 sudo apt install zenity)。"); +} + export async function pickLocalMedia( kind: "image" | "video", ): Promise { + if (process.platform === "linux") return pickLinuxMedia(kind); if (process.platform !== "win32") { throw pickerUnavailable("当前系统不支持 Windows 原生文件选择器。"); } diff --git a/packages/adapter-codex/test/codex-health.test.js b/packages/adapter-codex/test/codex-health.test.js index 9fe331c..16c48e9 100644 --- a/packages/adapter-codex/test/codex-health.test.js +++ b/packages/adapter-codex/test/codex-health.test.js @@ -89,7 +89,7 @@ test("runtime health requires a valid pointer, launcher and active entrypoint", await fs.writeFile(path.join(helper, "codex-watchdog.mjs"), ""); assert.equal(isCodexRuntimeReady(root), false); await fs.mkdir(path.join(runtime, "versions", "v1"), { recursive: true }); - await fs.writeFile(path.join(runtime, "launcher.ps1"), ""); + await fs.writeFile(path.join(runtime, process.platform === "linux" ? "launcher.sh" : "launcher.ps1"), ""); await fs.writeFile(path.join(runtime, "current.json"), JSON.stringify({ schema: "beauticode.runtime/v1", host: "codex-plugin", version: "v1", entrypoint: "codex-watchdog.mjs" })); assert.equal(isCodexRuntimeReady(root), false); await fs.writeFile(path.join(runtime, "versions", "v1", "codex-watchdog.mjs"), ""); diff --git a/packages/adapter-cursor/src/spec.ts b/packages/adapter-cursor/src/spec.ts index f07e972..ac103a1 100644 --- a/packages/adapter-cursor/src/spec.ts +++ b/packages/adapter-cursor/src/spec.ts @@ -38,11 +38,28 @@ export function cursorExecutableCandidates(options: CandidateOptions = {}): stri return [...new Set(values.filter(Boolean).map((value) => path.win32.normalize(value)))]; } +/** Official .deb/.rpm installs and common manual/AppImage-extracted layouts. */ +export function cursorLinuxExecutableCandidates(env: NodeJS.ProcessEnv = process.env): string[] { + const home = env.HOME || ""; + const values = [ + env.BEAUTICODE_CURSOR_EXECUTABLE || "", + "/usr/share/cursor/cursor", + "/opt/Cursor/cursor", + "/opt/cursor/cursor", + home ? path.posix.join(home, ".local", "share", "cursor", "cursor") : "", + home ? path.posix.join(home, "Applications", "cursor", "cursor") : "", + ]; + return [...new Set(values.filter((value) => path.posix.isAbsolute(value)))]; +} + export const CURSOR_CDP_SPEC: DesktopCdpHostSpec = Object.freeze({ kind: "cursor", displayName: "Cursor", processName: "Cursor.exe", executableCandidates: Object.freeze(cursorExecutableCandidates()), + linux: Object.freeze({ + executableCandidates: Object.freeze(cursorLinuxExecutableCandidates()), + }), defaultPort: 9341, candidatePorts: Object.freeze([9351, 9361, 9371]), popupTopInset: 44, diff --git a/packages/adapter-desktop-cdp/src/launch.ts b/packages/adapter-desktop-cdp/src/launch.ts index 8613081..209248d 100644 --- a/packages/adapter-desktop-cdp/src/launch.ts +++ b/packages/adapter-desktop-cdp/src/launch.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import net from "node:net"; import path from "node:path"; import { promisify } from "node:util"; +import { listLinuxProcesses, selectLinuxMainProcesses } from "@beauticode/core"; import { parseRemoteDebuggingFlags, probeDesktopCdp } from "./cdp.js"; import type { DesktopCdpHostSpec, DesktopProcess } from "./types.js"; @@ -23,6 +24,7 @@ export function isDesktopMainProcess( executablePath: string, platform = process.platform, ): boolean { + if (platform === "linux") return isLinuxDesktopMainProcess(spec, commandLine, executablePath); if (platform !== "win32") return false; if (name.toLowerCase() !== spec.processName.toLowerCase()) return false; if (/(?:^|\s)--type(?:=|\s)/.test(commandLine)) return false; @@ -32,6 +34,45 @@ export function isDesktopMainProcess( ); } +function canonicalLinuxPath(value: string): string { + try { + return fs.realpathSync(value); + } catch { + return path.posix.normalize(value); + } +} + +/** Linux identity is the resolved Electron binary, never a process name. */ +export function isLinuxDesktopMainProcess( + spec: DesktopCdpHostSpec, + commandLine: string, + executablePath: string, +): boolean { + const candidates = spec.linux?.executableCandidates ?? []; + if (!candidates.length || !executablePath) return false; + if (/(?:^|\s)--type(?:=|\s)/.test(commandLine)) return false; + const actual = canonicalLinuxPath(executablePath); + return candidates.some((candidate) => canonicalLinuxPath(candidate) === actual); +} + +/** Linux snapshot from /proc; helper and ELECTRON_RUN_AS_NODE children are excluded by parent image. */ +export function listLinuxDesktopProcesses(spec: DesktopCdpHostSpec): DesktopProcess[] { + const rows = listLinuxProcesses(); + return selectLinuxMainProcesses(rows, (row) => + isLinuxDesktopMainProcess(spec, row.commandLine, row.executablePath), + ).map((row) => { + const flags = parseRemoteDebuggingFlags(row.commandLine); + return { + pid: row.pid, + name: row.name, + executablePath: row.executablePath, + commandLine: row.commandLine, + port: flags.safe ? flags.port : null, + createdAtMs: row.createdAtMs, + }; + }); +} + export interface DesktopProcessSnapshotRow { pid: number; parentPid?: number; @@ -68,6 +109,7 @@ export function selectDesktopMainProcesses( export async function listDesktopProcesses( spec: DesktopCdpHostSpec, ): Promise { + if (process.platform === "linux") return listLinuxDesktopProcesses(spec); if (process.platform !== "win32") return []; const escapedName = spec.processName.replace(/'/g, "''"); const script = [ @@ -111,8 +153,16 @@ export async function listDesktopProcesses( } } +export function desktopExecutableCandidates( + spec: DesktopCdpHostSpec, + platform = process.platform, +): readonly string[] { + if (platform === "linux") return spec.linux?.executableCandidates ?? []; + return spec.executableCandidates; +} + export function findDesktopExecutable(spec: DesktopCdpHostSpec): string | null { - for (const candidate of spec.executableCandidates) { + for (const candidate of desktopExecutableCandidates(spec)) { try { if (fs.existsSync(candidate)) return candidate; } catch { @@ -215,7 +265,11 @@ export async function launchDesktopWithCdp( ); return; } - const child = spawn(command.file, command.args, { + // POSIX spawn has no shell: each preserved flag must be its own argv entry. + const args = command.args.flatMap((arg) => + arg.match(/--[A-Za-z0-9][A-Za-z0-9_.-]*(?:=(?:"[^"]*"|'[^']*'|[^\s;&|<>]+))?/g) ?? [arg], + ); + const child = spawn(command.file, args, { detached: true, stdio: "ignore", windowsHide: false, diff --git a/packages/adapter-desktop-cdp/src/startup-repair.ts b/packages/adapter-desktop-cdp/src/startup-repair.ts index 518db0a..bef8b8a 100644 --- a/packages/adapter-desktop-cdp/src/startup-repair.ts +++ b/packages/adapter-desktop-cdp/src/startup-repair.ts @@ -2,7 +2,7 @@ import { execFile, spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs"; import readline from "node:readline"; import { promisify } from "node:util"; -import { StartupRepairChain } from "@beauticode/core"; +import { StartupRepairChain, signalLinuxProcess } from "@beauticode/core"; import { discoverDesktopCdp, parseRemoteDebuggingFlags } from "./cdp.js"; import { buildWindowsDesktopProcessStartScript, isDesktopMainProcess, launchDesktopWithCdp, listDesktopProcesses, originalDesktopArguments, pickAvailableDesktopPort } from "./launch.js"; import type { DesktopCdpEndpoint, DesktopCdpHostSpec, DesktopEnsureLog, DesktopProcess } from "./types.js"; @@ -246,10 +246,23 @@ function defaultRepairOperations( return false; } }; + // Linux: SIGTERM is Electron's graceful quit; SIGKILL is the force path. + // Both re-check the creation time so a recycled PID is never signalled. + const signalForProcess = async ( + processInfo: DesktopProcess, + action: "close" | "force", + ): Promise => + processInfo.createdAtMs != null && + signalLinuxProcess( + Math.trunc(processInfo.pid), + action === "close" ? "SIGTERM" : "SIGKILL", + processInfo.createdAtMs, + ); + const stop = process.platform === "linux" ? signalForProcess : runPowerShellForProcess; return { listProcesses: () => listDesktopProcesses(spec), - terminate: (processInfo) => runPowerShellForProcess(processInfo, "close"), - forceTerminate: (processInfo) => runPowerShellForProcess(processInfo, "force"), + terminate: (processInfo) => stop(processInfo, "close"), + forceTerminate: (processInfo) => stop(processInfo, "force"), pickPort: () => pickAvailableDesktopPort(spec), launch: (executable, port, originalArguments) => launchDesktopWithCdp(executable, port, originalArguments), @@ -371,6 +384,7 @@ export function startDesktopStartupRepairMonitor( spec: DesktopCdpHostSpec, log?: DesktopEnsureLog, ): DesktopRepairMonitor { + if (process.platform === "linux") return startLinuxDesktopStartupRepairMonitor(spec, log); if (process.platform !== "win32") return { close() {} }; let closed = false; let child: ChildProcess | null = null; @@ -470,6 +484,70 @@ export function startDesktopStartupRepairMonitor( }; } +function createStartupRepairPipeline( + spec: DesktopCdpHostSpec, + log?: DesktopEnsureLog, +): (rows: DesktopProcess[]) => Promise { + const controller = new DesktopStartupRepairController((row) => + repairDesktopProcess(spec, row, log, { + onVerification: (endpoint) => { + if (endpoint) controller.markVerified(); + }, + }), + Date.now, + DEFAULT_DESKTOP_REPAIR_WINDOW_MS, + spec.repairSuppressionMs ?? 0, + ); + const tracker = new DesktopStartupSnapshotTracker(async (row) => { + const result = await controller.observe(row); + if (result === "repair-suppressed") { + log?.info(`${spec.displayName} 本次启动已在已验证的 CDP 修复窗口内,跳过重复修复。`); + } else if (result === "repair-failed") { + log?.warn(`${spec.displayName} CDP 修复未通过端口和目标页验证;本次进程不再自动重启。`); + } + }, { minStableSamples: 1 }); + return async (rows) => { + controller.observeSnapshot(rows); + await tracker.observeSnapshot(rows); + }; +} + +/** + * Linux has no process-start event without privileges; a 500 ms /proc + * snapshot matches the Windows monitor's sampling interval and feeds the same + * controller, so the one-repair-per-fresh-process invariant is shared. + */ +function startLinuxDesktopStartupRepairMonitor( + spec: DesktopCdpHostSpec, + log?: DesktopEnsureLog, +): DesktopRepairMonitor { + if (!spec.linux?.executableCandidates.length) return { close() {} }; + const observe = createStartupRepairPipeline(spec, log); + let closed = false; + let busy = false; + const tick = () => { + if (closed || busy) return; + busy = true; + void listDesktopProcesses(spec) + .then((rows) => { + // Like the Windows monitor, a repair transaction must not block sampling. + void observe(rows).catch((error) => { + log?.warn(`${spec.displayName} 启动修复失败:${String(error)}`); + }); + }) + .catch((error) => log?.warn(`${spec.displayName} 快照扫描失败:${String(error)}`)) + .finally(() => { busy = false; }); + }; + const timer = setInterval(tick, 500); + tick(); + return { + close() { + closed = true; + clearInterval(timer); + }, + }; +} + export async function waitForDesktopCdp( spec: DesktopCdpHostSpec, ports: readonly number[], diff --git a/packages/adapter-desktop-cdp/src/types.ts b/packages/adapter-desktop-cdp/src/types.ts index 5d9972f..e95be4a 100644 --- a/packages/adapter-desktop-cdp/src/types.ts +++ b/packages/adapter-desktop-cdp/src/types.ts @@ -78,6 +78,11 @@ export interface DesktopCdpHostSpec { displayName: string; processName: string; executableCandidates: readonly string[]; + /** Linux install layout. Absent means the host has no supported Linux build. */ + linux?: Readonly<{ + /** Electron main binaries, e.g. /usr/share/cursor/cursor. */ + executableCandidates: readonly string[]; + }>; defaultPort: number; candidatePorts: readonly number[]; /** Suppress a second repair when this host replaces its launcher and drops CDP flags. */ diff --git a/packages/adapter-workbuddy/src/launch.ts b/packages/adapter-workbuddy/src/launch.ts index 552d93a..3d78727 100644 --- a/packages/adapter-workbuddy/src/launch.ts +++ b/packages/adapter-workbuddy/src/launch.ts @@ -4,6 +4,7 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; +import { listLinuxProcesses, selectLinuxMainProcesses } from "@beauticode/core"; import { DEFAULT_WORKBUDDY_CDP_PORT, DEFAULT_WORKBUDDY_CDP_PORTS, @@ -133,6 +134,15 @@ export function workBuddyInstallCandidates( "/Applications/WorkBuddy.app/Contents/MacOS/WorkBuddy", ]; } + if (platform === "linux") { + // Official Tencent .deb installs to /opt/WorkBuddy. + return [ + "/opt/WorkBuddy/workbuddy", + "/opt/workbuddy/workbuddy", + "/usr/lib/workbuddy/workbuddy", + path.posix.join(home, ".local", "share", "WorkBuddy", "workbuddy"), + ]; + } return []; } @@ -303,8 +313,48 @@ async function scanUnixWorkBuddyProcesses(): Promise { } } +/** + * Linux reads /proc so ELECTRON_RUN_AS_NODE workers (`workbuddy `, + * same binary, no --type) are excluded by parent image instead of counting + * as extra main processes. + */ +function scanLinuxWorkBuddyProcesses(): WorkBuddyProcess[] { + const rows = listLinuxProcesses((row) => path.posix.basename(row.executablePath).toLowerCase() === "workbuddy"); + return selectLinuxMainProcesses(rows, (row) => + isWorkBuddyMainProcess(row.commandLine, row.name, row.executablePath, "linux"), + ).map((row) => { + const flags = parseRemoteDebuggingFlags(row.commandLine); + // WorkBuddy enables CDP from its environment, not argv. A same-user + // /proc//environ read tells a CDP launch apart from a blind one, so a + // just-started CDP process is never mistaken for a repair candidate. + const envPort = linuxProcessEnvPort(row.pid); + return { + pid: row.pid, + name: "WorkBuddy", + executablePath: row.executablePath, + commandLine: row.commandLine, + port: flags.safe && flags.port != null ? flags.port : envPort, + createdAtMs: row.createdAtMs, + }; + }); +} + +function linuxProcessEnvPort(pid: number): number | null { + try { + const prefix = `${WORKBUDDY_CDP_ENV_KEY}=`; + const entry = fs.readFileSync(`/proc/${pid}/environ`, "utf8") + .split("\0") + .find((value) => value.startsWith(prefix)); + const port = Number(entry?.slice(prefix.length)); + return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null; + } catch { + return null; + } +} + export async function listWorkBuddyProcesses(): Promise { if (process.platform === "win32") return scanWindowsWorkBuddyProcesses(); + if (process.platform === "linux") return scanLinuxWorkBuddyProcesses(); return scanUnixWorkBuddyProcesses(); } From 5960435dc33966115379da75e6900663be3a27cd Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Tue, 27 Oct 2026 21:58:55 -0400 Subject: [PATCH 03/13] feat(scripts): Linux stable runtime, sh launcher and XDG autostart - portable-runtime: XDG data root, POSIX launcher.sh (current.json resolver, PATH-independent node lookup, --quick-launch), XDG .desktop renderer with X-BeautiCode-Managed marker, detached launcher start - desktop-cdp setup/runner: Cursor on Linux via autostart entry, /proc owner checks, SIGTERM stop, zenity/kdialog picker, xdg-open - WorkBuddy setup/runner: stable runtime + autostart launcher, identity-checked owner record, --machine-status, XDG config/log paths - quick-launch: Linux state file and unsupported-platform failure code - pack: os [win32, linux]; rewrite core imports in every adapter dist file --- scripts/desktop-cdp-runner.mjs | 30 +++++- scripts/desktop-cdp-setup.mjs | 81 +++++++++++--- scripts/guardian-process.mjs | 26 +++++ scripts/pack-desktop-aggregate.mjs | 19 ++-- scripts/portable-runtime.mjs | 164 ++++++++++++++++++++++++++++- scripts/quick-launch.mjs | 9 +- scripts/wb-cdp-runner.mjs | 13 ++- scripts/wb-setup.mjs | 54 +++++++--- 8 files changed, 354 insertions(+), 42 deletions(-) diff --git a/scripts/desktop-cdp-runner.mjs b/scripts/desktop-cdp-runner.mjs index f08a41b..fbc0e4c 100644 --- a/scripts/desktop-cdp-runner.mjs +++ b/scripts/desktop-cdp-runner.mjs @@ -111,7 +111,10 @@ const log = { debug: (...parts) => { if (args.verbose) process.stderr.write(`[${ts()}] [${args.host}:debug] ${parts.join(' ')}\n`); }, }; -const dataRoot = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'beauticode', 'hosts', args.host); +// Keep in sync with portable-runtime.mjs beautiCodeLocalRoot(): Linux shares core's XDG data root. +const dataRoot = process.platform === 'linux' + ? path.join(process.env.LOCALAPPDATA || (process.env.XDG_DATA_HOME && path.isAbsolute(process.env.XDG_DATA_HOME) ? process.env.XDG_DATA_HOME : path.join(os.homedir(), '.local', 'share')), 'beautiCode', 'hosts', args.host) + : path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'beauticode', 'hosts', args.host); const stateFile = path.join(dataRoot, 'state.json'); fs.mkdirSync(dataRoot, { recursive: true }); @@ -218,8 +221,25 @@ async function evaluate(connection, expression) { return reply.result?.value; } +/** Linux: zenity first, kdialog as the KDE fallback (same order as the WorkBuddy runner). */ +function pickFileLinux() { + const title = '选择背景图片或视频'; + const filter = '*.png *.jpg *.jpeg *.webp *.gif *.bmp *.avif *.mp4 *.mov *.webm *.m4v'; + return new Promise((resolve, reject) => execFile( + 'zenity', ['--file-selection', '--title', title, '--file-filter', `媒体 | ${filter}`], + { maxBuffer: 1024 * 1024 }, + (error, stdout) => { + if (!error) return resolve(String(stdout).trim()); + if (error.code !== 'ENOENT') return resolve(''); + execFile('kdialog', ['--getopenfilename', os.homedir(), filter, '--title', title], { maxBuffer: 1024 * 1024 }, + (fallbackError, fallbackOut) => fallbackError ? reject(fallbackError) : resolve(String(fallbackOut).trim())); + }, + )); +} + async function pickFileNative() { - if (process.platform !== 'win32') throw new Error('本轮仅支持 Windows 原生文件选择器。'); + if (process.platform === 'linux') return pickFileLinux(); + if (process.platform !== 'win32') throw new Error('本轮仅支持 Windows 与 Linux 原生文件选择器。'); const ps = [ "$ErrorActionPreference='Stop'", "$OutputEncoding=New-Object System.Text.UTF8Encoding($false)", @@ -313,6 +333,12 @@ async function setFileInput(connection, file, meta) { } function openExternal(url) { + if (process.platform === 'linux') { + const child = spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }); + child.once('error', () => {}); + child.unref(); + return; + } if (process.platform !== 'win32') return; const child = spawn('rundll32.exe', ['url.dll,FileProtocolHandler', url], { detached: true, stdio: 'ignore', windowsHide: true, diff --git a/scripts/desktop-cdp-setup.mjs b/scripts/desktop-cdp-setup.mjs index 5ea2a9b..e12a51e 100644 --- a/scripts/desktop-cdp-setup.mjs +++ b/scripts/desktop-cdp-setup.mjs @@ -14,14 +14,18 @@ import { } from './desktop-runner-identity.mjs'; import { activateStableRuntime, + beautiCodeLocalRoot, discardStableRuntime, hostRuntimeRoot, renderWindowsStartupVbs, + renderXdgDesktopEntry, resolveNodeExecutable, stableRuntimeRoot, + startDetachedLauncher, startupDirectory, syncStableRuntime, writeTextAtomic, + xdgAutostartPath, } from './portable-runtime.mjs'; const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -41,14 +45,19 @@ function parseArgs(argv) { return out; } -if (process.platform !== 'win32') throw new Error('Cursor/豆包背景守护当前仅支持 Windows。'); +const IS_LINUX = process.platform === 'linux'; +if (process.platform !== 'win32' && !IS_LINUX) throw new Error('Cursor/豆包背景守护当前仅支持 Windows 与 Linux。'); const args = parseArgs(process.argv.slice(2)); -const dataDir = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'beauticode', 'hosts', args.host); +if (IS_LINUX && args.host !== 'cursor') throw new Error('豆包没有官方 Linux 桌面版;Linux 仅支持 Cursor。'); +const dataDir = path.join(beautiCodeLocalRoot(process.env, os.homedir()), 'hosts', args.host); const pidFile = path.join(dataDir, 'runner.pid'); const logFile = path.join(dataDir, 'runner.log'); const stableRoot = stableRuntimeRoot(process.env, os.homedir()); const stableHostRoot = hostRuntimeRoot(stableRoot, args.host); -const startupVbs = path.join(startupDirectory(process.env, os.homedir()), `beauticode-${args.host}-runner.vbs`); +// Windows: hidden VBS in the Startup folder. Linux: XDG autostart entry. +const startupVbs = IS_LINUX + ? xdgAutostartPath(`beauticode-${args.host}-runner`, process.env, os.homedir()) + : path.join(startupDirectory(process.env, os.homedir()), `beauticode-${args.host}-runner.vbs`); const legacyStartupVbs = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), `beauticode-${args.host}-runner.vbs`); const runnerArgs = ['--host', args.host, '--watchdog', '--pid-file', pidFile]; const runnerContext = { host: args.host, stableHostRoot, sourceRunner: RUNNER, pidFile }; @@ -100,8 +109,25 @@ function readPidRecord() { } } -function readWindowsProcess(pid) { +let linuxCore = null; +async function loadLinuxCore() { + if (IS_LINUX && !linuxCore) { + linuxCore = await import(pathToFileURL(path.join(REPO, 'packages', 'core', 'dist', 'index.js')).href); + } +} +await loadLinuxCore(); + +function readLinuxRunnerProcess(pid) { + const info = linuxCore.readLinuxProcess(pid); + return info ? { + pid: info.pid, name: info.name, image: info.executablePath, + commandLine: info.commandLine, createdAtMs: info.createdAtMs, + } : null; +} + +function readRunnerProcess(pid) { if (!Number.isInteger(pid) || pid < 1) return null; + if (IS_LINUX) return readLinuxRunnerProcess(pid); const script = [ "$ErrorActionPreference='SilentlyContinue';", `$p=Get-CimInstance Win32_Process -Filter \"ProcessId=${pid}\";`, @@ -125,19 +151,21 @@ function readWindowsProcess(pid) { function managedRunnerProcess() { const record = readPidRecord(); if (!record) return null; - const current = readWindowsProcess(record.pid); + const current = readRunnerProcess(record.pid); return isManagedDesktopRunner(record, current, runnerContext) ? current : null; } function stopRunner() { const record = readPidRecord(); - const current = record ? readWindowsProcess(record.pid) : null; + const current = record ? readRunnerProcess(record.pid) : null; if (isManagedDesktopRunner(record, current, runnerContext)) { - const result = spawnSync('taskkill.exe', ['/PID', String(record.pid), '/T', '/F'], { windowsHide: true, timeout: 8_000 }); + const result = IS_LINUX + ? stopLinuxRunner(record.pid) + : spawnSync('taskkill.exe', ['/PID', String(record.pid), '/T', '/F'], { windowsHide: true, timeout: 8_000 }); // Do not discard the only ownership record when taskkill failed. The // next install/status must still be able to identify and stop this exact // runner instead of starting a second watchdog beside it. - const after = readWindowsProcess(record.pid); + const after = readRunnerProcess(record.pid); if (result.error || result.status !== 0 || isManagedDesktopRunner(record, after, runnerContext)) { const detail = result.error?.message || (result.status == null ? 'taskkill timed out' : `exit ${result.status}`); throw new Error(`无法停止现有 beautiCode ${args.host} 守护(${detail})。`); @@ -146,7 +174,23 @@ function stopRunner() { try { fs.unlinkSync(pidFile); } catch {} } +/** SIGTERM lets the watchdog stop its child; SIGKILL only after 5 s. */ +function stopLinuxRunner(pid) { + const alive = () => { try { process.kill(pid, 0); return true; } catch { return false; } }; + try { process.kill(pid, 'SIGTERM'); } catch (error) { return { status: error?.code === 'ESRCH' ? 0 : 1, error }; } + const deadline = Date.now() + 5_000; + while (alive() && Date.now() < deadline) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); + if (alive()) { try { process.kill(pid, 'SIGKILL'); } catch {} } + return { status: 0 }; +} + function cleanPage() { + if (IS_LINUX) { + const launcher = path.join(stableHostRoot, 'launcher.sh'); + if (!fs.existsSync(launcher)) return; + spawnSync('/bin/sh', [launcher, '--host', args.host, '--clean', '--no-repair'], { timeout: 10_000, stdio: 'ignore' }); + return; + } const launcher = path.join(stableHostRoot, 'launcher.ps1'); if (!fs.existsSync(launcher)) return; spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', launcher, '--host', args.host, '--clean', '--no-repair'], { @@ -207,7 +251,7 @@ if (args.command === 'uninstall') { stopRunner(); cleanPage(); try { fs.unlinkSync(startupVbs); } catch {} - try { fs.unlinkSync(legacyStartupVbs); } catch {} + if (!IS_LINUX) { try { fs.unlinkSync(legacyStartupVbs); } catch {} } process.stdout.write(`${args.host} 背景守护已卸载;独立主题状态保留在 ${dataDir}\n`); process.exit(0); } @@ -225,6 +269,10 @@ if (fs.existsSync(path.join(REPO, 'package-lock.json'))) { const stableRuntime = await prepareStableRuntime(); const previousRunning = managedRunnerProcess(); const startFromStartup = () => { + if (IS_LINUX) { + if (!startDetachedLauncher(stableRuntime.launcher, runnerArgs)) throw new Error('startup-launch-failed'); + return; + } const result = spawnSync('wscript.exe', ['//B', startupVbs], { windowsHide: true, timeout: 5_000, stdio: 'ignore' }); if (result.error || result.status !== 0) throw new Error('startup-launch-failed'); }; @@ -237,15 +285,22 @@ try { run: async () => { stopRunner(); await activateStableRuntime(stableRuntime, { startupLogPath: logFile }); - await writeVerifiedExecutableRecord(args.host, dataDir); + if (!IS_LINUX) await writeVerifiedExecutableRecord(args.host, dataDir); fs.mkdirSync(path.dirname(startupVbs), { recursive: true }); - await writeTextAtomic(startupVbs, renderWindowsStartupVbs({ launcher: stableRuntime.launcher, args: runnerArgs })); + await writeTextAtomic(startupVbs, IS_LINUX + ? renderXdgDesktopEntry({ + name: `beautiCode ${args.host} background guardian`, + comment: `Re-applies beautiCode backgrounds to ${args.host} through loopback CDP`, + launcher: stableRuntime.launcher, + args: runnerArgs, + }) + : renderWindowsStartupVbs({ launcher: stableRuntime.launcher, args: runnerArgs })); try { return await startDesktopGuardian({ context: runnerContext, start: startFromStartup, readRecord: readPidRecord, - readProcess: readWindowsProcess, + readProcess: readRunnerProcess, }); } catch (error) { if (managedRunnerProcess()) stopRunner(); @@ -260,6 +315,6 @@ try { } throw error; } -if (path.resolve(legacyStartupVbs) !== path.resolve(startupVbs)) fs.rmSync(legacyStartupVbs, { force: true }); +if (!IS_LINUX && path.resolve(legacyStartupVbs) !== path.resolve(startupVbs)) fs.rmSync(legacyStartupVbs, { force: true }); process.stdout.write(`${args.host} 背景守护已安装并启动(PID ${guardian.pid})。\n`); } diff --git a/scripts/guardian-process.mjs b/scripts/guardian-process.mjs index f6d5706..65413d3 100644 --- a/scripts/guardian-process.mjs +++ b/scripts/guardian-process.mjs @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; const normalized = (value) => path.win32.normalize(String(value || "")).toLowerCase(); @@ -24,6 +25,31 @@ export function readWindowsProcess(pid) { } catch { return null; } } +/** Linux /proc read with the same shape as readWindowsProcess(). */ +export function readLinuxProcess(pid) { + if (!Number.isInteger(pid) || pid < 1) return null; + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); + const fields = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/); + const btime = Number(/^btime\s+(\d+)$/m.exec(fs.readFileSync("/proc/stat", "utf8"))?.[1]); + const argv = fs.readFileSync(`/proc/${pid}/cmdline`, "utf8").split("\0").filter(Boolean); + let image = ""; + try { image = fs.readlinkSync(`/proc/${pid}/exe`).replace(/ \(deleted\)$/, ""); } catch { /* unreadable */ } + return { + pid, + name: stat.slice(stat.indexOf("(") + 1, stat.lastIndexOf(")")), + image, + commandLine: argv.map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" "), + // USER_HZ is 100 on every mainstream Linux architecture. + createdAtMs: Number.isFinite(btime) ? Math.round(btime * 1000 + Number(fields[19]) * 10) : 0, + }; + } catch { return null; } +} + +export function readGuardianProcess(pid) { + return process.platform === "linux" ? readLinuxProcess(pid) : readWindowsProcess(pid); +} + export function isManagedWorkBuddyRunner(record, current, context) { if (!record || !current || !context || record.schema !== "beauticode.wb-runner/v1") return false; if (Number(record.pid) !== Number(current.pid)) return false; diff --git a/scripts/pack-desktop-aggregate.mjs b/scripts/pack-desktop-aggregate.mjs index 7df4ca4..08ef7dd 100644 --- a/scripts/pack-desktop-aggregate.mjs +++ b/scripts/pack-desktop-aggregate.mjs @@ -93,6 +93,14 @@ function rewriteCodexWatchHost(text) { .replaceAll("@beauticode/adapter-codex", "Codex adapter"); } +async function rewriteJsTree(dir) { + for (const entry of await fsp.readdir(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) await rewriteJsTree(file); + else if (entry.name.endsWith(".js")) await fsp.writeFile(file, rewriteDesktopAdapter(await fsp.readFile(file, "utf8")), "utf8"); + } +} + async function copyDesktopRuntime(destRoot) { const desktop = path.join(destRoot, "runtime", "desktop"); await copyText( @@ -120,8 +128,8 @@ async function copyDesktopRuntime(destRoot) { ); await copyJsTree(path.join(repoRoot, "packages", "core", "dist"), path.join(desktop, "packages", "core", "dist")); await copyJsTree(path.join(repoRoot, "packages", "adapter-desktop-cdp", "dist"), path.join(desktop, "packages", "adapter-desktop-cdp", "dist")); - const startupRepair = path.join(desktop, "packages", "adapter-desktop-cdp", "dist", "startup-repair.js"); - await fsp.writeFile(startupRepair, rewriteDesktopAdapter(await fsp.readFile(startupRepair, "utf8")), "utf8"); + // launch.js (Linux /proc scan) and startup-repair.js both import core. + await rewriteJsTree(path.join(desktop, "packages", "adapter-desktop-cdp", "dist")); await copyJsTree(path.join(repoRoot, "packages", "adapter-cursor", "dist"), path.join(desktop, "packages", "adapter-cursor", "dist")); await copyJsTree(path.join(repoRoot, "packages", "adapter-doubao", "dist"), path.join(desktop, "packages", "adapter-doubao", "dist")); for (const name of ["adapter-cursor", "adapter-doubao"]) { @@ -153,8 +161,7 @@ async function copyWorkbuddyRuntime(destRoot) { ); await copyJsTree(path.join(repoRoot, "packages", "core", "dist"), path.join(workbuddy, "packages", "core", "dist")); await copyJsTree(path.join(repoRoot, "packages", "adapter-workbuddy", "dist"), path.join(workbuddy, "packages", "adapter-workbuddy", "dist")); - const workbuddyLaunch = path.join(workbuddy, "packages", "adapter-workbuddy", "dist", "launch.js"); - await fsp.writeFile(workbuddyLaunch, rewriteDesktopAdapter(await fsp.readFile(workbuddyLaunch, "utf8")), "utf8"); + await rewriteJsTree(path.join(workbuddy, "packages", "adapter-workbuddy", "dist")); await copyTree( path.join(repoRoot, "assets", "themes", "internal-beyond"), path.join(workbuddy, "assets", "themes", "internal-beyond"), @@ -167,8 +174,8 @@ export async function stageDesktopAggregate(destRoot = defaultStageDir(), opts = await fsp.rm(destRoot, { recursive: true, force: true }); await fsp.mkdir(destRoot, { recursive: true }); const manifest = JSON.parse(await fsp.readFile(path.join(sourceRoot, "package.json"), "utf8")); - // Source workspaces must install on Linux CI; only the distributable has a Windows-only runtime contract. - manifest.os = ["win32"]; + // Source workspaces must install anywhere; the distributable supports Windows and Linux (x64 desktop clients). + manifest.os = ["win32", "linux"]; await fsp.writeFile(path.join(destRoot, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); for (const name of ["README.md", "LICENSE"]) { const source = name === "LICENSE" ? path.join(repoRoot, name) : path.join(sourceRoot, name); diff --git a/scripts/portable-runtime.mjs b/scripts/portable-runtime.mjs index 10e1638..f40e681 100644 --- a/scripts/portable-runtime.mjs +++ b/scripts/portable-runtime.mjs @@ -3,15 +3,45 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; export const MIN_NODE_MAJOR = 22; export const RUNTIME_SCHEMA = "beauticode.runtime/v1"; -function localAppData(env = process.env, home = os.homedir()) { +/** + * Per-user machine-local data base. Windows keeps %LOCALAPPDATA%; Linux uses + * the XDG data home so every beautiCode file lives under ~/.local/share. + */ +export function localDataBase(env = process.env, home = os.homedir(), platform = process.platform) { + if (platform === "linux" && !env.LOCALAPPDATA) { + return env.XDG_DATA_HOME && path.isAbsolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : path.join(home, ".local", "share"); + } return env.LOCALAPPDATA || path.join(home, "AppData", "Local"); } +function localAppData(env = process.env, home = os.homedir()) { + return localDataBase(env, home); +} + +/** + * Host state directory used by the Cursor/Doubao/WorkBuddy guardians. Windows + * keeps its historical lower-case folder (the filesystem is case-insensitive); + * Linux shares core's `beautiCode` data root instead of creating a twin. + */ +export function beautiCodeLocalRoot(env = process.env, home = os.homedir(), platform = process.platform) { + return path.join(localDataBase(env, home, platform), platform === "linux" ? "beautiCode" : "beauticode"); +} + +export function xdgConfigHome(env = process.env, home = os.homedir()) { + return env.XDG_CONFIG_HOME && path.isAbsolute(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : path.join(home, ".config"); +} + +/** XDG autostart entry path (~/.config/autostart/.desktop). */ +export function xdgAutostartPath(name, env = process.env, home = os.homedir()) { + if (!/^[A-Za-z0-9._-]{1,80}$/.test(String(name))) throw new Error("无效的 autostart 名称。"); + return path.join(xdgConfigHome(env, home), "autostart", `${name}.desktop`); +} + export function stableRuntimeRoot(env = process.env, home = os.homedir()) { return path.join(localAppData(env, home), "beautiCode", "runtime"); } @@ -130,6 +160,117 @@ export function renderRuntimeLauncherPs1({ host, startupLogPath } = {}) { ].join("\r\n"); } +function shQuote(value) { + return `'${String(value).replaceAll("'", `'\\''`)}'`; +} + +/** + * POSIX counterpart of launcher.ps1: resolve the active runtime from + * current.json, find a Node.js >=22 that does not depend on the login PATH, + * and exec the entrypoint. Launcher failures leave one sanitized log line. + */ +export function renderRuntimeLauncherSh({ host, startupLogPath, nodePath } = {}) { + const safeHost = String(host).replace(/[^a-z0-9_-]/gi, ""); + if (!safeHost) throw new Error("无效的 host runtime 名称。"); + if (startupLogPath && !path.posix.isAbsolute(startupLogPath)) throw new Error("startup log path must be absolute"); + const quickEntry = safeHost === "codex-plugin" ? "quick-launch.mjs" : "scripts/quick-launch.mjs"; + const resolver = [ + "const fs=require('fs'),path=require('path');", + "const [pointerPath,hostName,quick]=process.argv.slice(1);", + "let p;try{p=JSON.parse(fs.readFileSync(pointerPath,'utf8'))}catch{process.exit(2)}", + "if(p.schema!=='beauticode.runtime/v1'||p.host!==hostName||!/^[A-Za-z0-9._-]+$/.test(String(p.version)))process.exit(2);", + "const root=path.join(path.dirname(pointerPath),'versions',p.version);", + "if(!fs.existsSync(root))process.exit(3);", + "const entry=path.resolve(root,quick||String(p.entrypoint));", + "if(!entry.startsWith(root+path.sep)||!fs.existsSync(entry))process.exit(4);", + "process.stdout.write(entry);", + ].join(""); + return [ + "#!/bin/sh", + "# beautiCode runtime launcher (generated; do not edit).", + "set -u", + `host_name=${shQuote(safeHost)}`, + `startup_log=${shQuote(startupLogPath || "")}`, + `node_hint=${shQuote(nodePath || "")}`, + 'host_root=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || exit 1', + 'fail() {', + ' if [ -n "$startup_log" ]; then', + ' mkdir -p "$(dirname -- "$startup_log")" 2>/dev/null', + ' if [ -f "$startup_log" ] && [ "$(wc -c < "$startup_log")" -gt 1048576 ]; then mv -f "$startup_log" "$startup_log.previous"; fi', + String.raw` printf '%s [launcher-failure] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >> "$startup_log" 2>/dev/null`, + ' fi', + ' echo "beautiCode launcher: $1" >&2', + ' exit 1', + '}', + 'pointer="$host_root/current.json"', + '[ -f "$pointer" ] || fail pointer-missing', + String.raw`node_ok() { [ -n "$1" ] && [ -x "$1" ] && "$1" -e 'process.exit(Number(process.versions.node.split(".")[0])>=22?0:1)' 2>/dev/null; }`, + 'node_bin=""', + 'record_node=""', + String.raw`if [ -f "$host_root/node-source.json" ]; then record_node=$(sed -n 's/.*"path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$host_root/node-source.json" | head -n 1); fi`, + 'for candidate in "$node_hint" "${BEAUTICODE_NODE_PATH:-}" "$record_node" "$(command -v node 2>/dev/null)" /usr/local/bin/node /usr/bin/node; do', + ' if node_ok "$candidate"; then node_bin=$candidate; break; fi', + 'done', + '[ -n "$node_bin" ] || fail node-unavailable', + 'quick=""', + 'if [ "${1:-}" = "--quick-launch" ]; then', + ' shift', + ` quick=${shQuote(quickEntry)}`, + ' set -- "$host_name" "$@"', + 'fi', + `entry=$("$node_bin" -e ${shQuote(resolver)} "$pointer" "$host_name" "$quick")`, + 'case $? in', + ' 0) ;;', + ' 3) fail runtime-missing ;;', + ' 4) fail entry-missing ;;', + ' *) fail pointer-invalid ;;', + 'esac', + 'if [ -n "$startup_log" ]; then', + ' mkdir -p "$(dirname -- "$startup_log")" 2>/dev/null', + ' if [ -f "$startup_log" ] && [ "$(wc -c < "$startup_log")" -gt 1048576 ]; then mv -f "$startup_log" "$startup_log.previous"; fi', + ' exec "$node_bin" "$entry" "$@" >> "$startup_log" 2>&1', + 'fi', + 'exec "$node_bin" "$entry" "$@"', + "", + ].join("\n"); +} + +/** Quote one argument for a freedesktop Desktop Entry Exec key. */ +export function desktopExecQuote(value) { + const text = String(value); + if (/[\x00-\x1f]/.test(text)) throw new Error("desktop entry argument contains control characters"); + const escaped = text.replace(/[\\"`$]/g, (char) => `\\${char}`).replaceAll("%", "%%"); + // The Exec key itself is a string value: backslashes are escaped once more. + return `"${escaped.replaceAll("\\", "\\\\")}"`; +} + +/** Hidden XDG autostart / application entry that runs a POSIX launcher. */ +export function renderXdgDesktopEntry({ name, comment = "", launcher, args = [], autostart = true, icon = "" }) { + if (!path.posix.isAbsolute(String(launcher))) throw new Error("launcher path must be absolute"); + const line = (value) => String(value).replace(/[\r\n]+/g, " "); + return [ + "[Desktop Entry]", + "Type=Application", + `Name=${line(name)}`, + ...(comment ? [`Comment=${line(comment)}`] : []), + `Exec=${["/bin/sh", launcher, ...args].map(desktopExecQuote).join(" ")}`, + ...(icon ? [`Icon=${line(icon)}`] : []), + "Terminal=false", + ...(autostart + ? ["NoDisplay=true", "X-GNOME-Autostart-enabled=true", "X-KDE-autostart-after=panel"] + : ["Categories=Development;Utility;", "StartupNotify=false"]), + "X-BeautiCode-Managed=true", + "", + ].join("\n"); +} + +/** Start a launcher outside this process tree (the Linux "Run startup item now"). */ +export function startDetachedLauncher(launcher, args = [], { env = process.env } = {}) { + const child = spawn("/bin/sh", [launcher, ...args], { detached: true, stdio: "ignore", env }); + child.unref(); + return child.pid ?? null; +} + export function renderWindowsStartupVbs({ launcher, args = [] }) { const command = [ "powershell.exe", @@ -151,7 +292,13 @@ export function renderCodexStarter({ launcher }) { export async function activateStableRuntime(runtime, options = {}) { const { host, entrypoint, version, pointerPath, launcher } = runtime; - await atomicWrite(launcher, renderRuntimeLauncherPs1({ host, startupLogPath: options.startupLogPath })); + if (launcher.endsWith(".sh")) { + const node = options.nodePath ? { path: options.nodePath } : await resolveNodeExecutable({ stableRoot: runtime.hostRoot }); + await atomicWrite(launcher, renderRuntimeLauncherSh({ host, startupLogPath: options.startupLogPath, nodePath: node.path })); + await fsp.chmod(launcher, 0o755); + } else { + await atomicWrite(launcher, renderRuntimeLauncherPs1({ host, startupLogPath: options.startupLogPath })); + } await atomicWrite(pointerPath, `${JSON.stringify({ schema: RUNTIME_SCHEMA, host: String(host).toLowerCase(), version, entrypoint }, null, 2)}\n`); return runtime; } @@ -176,7 +323,8 @@ export async function syncStableRuntime({ sourceRoot, stableRoot, host, entrypoi } if (!fs.existsSync(path.join(temporary, entrypoint))) throw new Error(`runtime entrypoint missing: ${entrypoint}`); await fsApi.rename(temporary, activeRoot); - const runtime = { host, hostRoot, activeRoot, pointerPath: path.join(hostRoot, "current.json"), launcher: path.join(hostRoot, "launcher.ps1"), version, entrypoint }; + const launcherName = process.platform === "linux" ? "launcher.sh" : "launcher.ps1"; + const runtime = { host, hostRoot, activeRoot, pointerPath: path.join(hostRoot, "current.json"), launcher: path.join(hostRoot, launcherName), version, entrypoint }; if (activate) await activateStableRuntime(runtime); return runtime; } catch (error) { @@ -216,6 +364,14 @@ function defaultCandidates({ env = process.env, home = os.homedir(), stableRoot if (process.platform === "win32") { try { out.push(...String(spawnSync("where.exe", ["node.exe"], { encoding: "utf8", windowsHide: true }).stdout || "").split(/\r?\n/)); } catch {} } + if (process.platform === "linux") { + // The installer itself already satisfies engines>=22; prefer its absolute + // path because a login session's PATH often lacks nvm/fnm/volta shims. + out.push(process.execPath); + const pathValue = env.PATH || ""; + for (const directory of pathValue.split(path.delimiter).filter(Boolean)) out.push(path.join(directory, "node")); + out.push("/usr/local/bin/node", "/usr/bin/node"); + } return out.filter(Boolean); } diff --git a/scripts/quick-launch.mjs b/scripts/quick-launch.mjs index e18531b..102360c 100644 --- a/scripts/quick-launch.mjs +++ b/scripts/quick-launch.mjs @@ -18,6 +18,7 @@ const SAFE_FAILURE_CODES = new Set([ 'cdp-target-unavailable', 'executable-not-found', 'windows-only', + 'unsupported-platform', 'unsupported-host', ]); @@ -54,7 +55,8 @@ async function launchDesktop(host) { } export async function runQuickLaunch(host) { - if (process.platform !== 'win32') throw new Error('windows-only'); + if (process.platform !== 'win32' && process.platform !== 'linux') throw new Error('unsupported-platform'); + if (process.platform === 'linux' && host === 'doubao') throw new Error('unsupported-host'); if (host === 'codex-plugin') { const packed = path.join(runtimeRoot, 'vendor', 'adapter-codex', 'index.js'); const { ensureCodexCdp } = await load(fs.existsSync(packed) @@ -75,7 +77,10 @@ export async function runQuickLaunch(host) { async function main() { const host = String(process.argv[2] || '').toLowerCase(); const dataHost = host === 'codex-plugin' ? 'codex' : host; - const file = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'beautiCode', 'hosts', dataHost, 'quick-launch.json'); + const base = 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 file = path.join(base, 'beautiCode', 'hosts', dataHost, 'quick-launch.json'); let state; try { state = { at: new Date().toISOString(), result: await runQuickLaunch(host) }; diff --git a/scripts/wb-cdp-runner.mjs b/scripts/wb-cdp-runner.mjs index 2938c9b..67446e7 100644 --- a/scripts/wb-cdp-runner.mjs +++ b/scripts/wb-cdp-runner.mjs @@ -106,8 +106,13 @@ let PORT = args.port || parseInt(process.env.WORKBUDDY_REMOTE_DEBUGGING_PORT || const DEFAULT_WALLPAPER = args.wallpaper || path.join(REPO, 'assets', 'themes', 'internal-beyond', 'bg-canvas-4k.webp'); +const LINUX_DATA_HOME = process.env.XDG_DATA_HOME && path.isAbsolute(process.env.XDG_DATA_HOME) + ? process.env.XDG_DATA_HOME : path.join(os.homedir(), '.local', 'share'); const log = createWorkBuddyLogger({ - file: path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'beauticode', 'logs', 'wb-runner.log'), + // Linux keeps logs beside core's XDG data root (~/.local/share/beautiCode). + file: process.platform === 'linux' && !process.env.LOCALAPPDATA + ? path.join(LINUX_DATA_HOME, 'beautiCode', 'logs', 'wb-runner.log') + : path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'beauticode', 'logs', 'wb-runner.log'), debug: args.verbose, }); log.info('runner-started'); @@ -195,9 +200,13 @@ const GALLERY_PORT_BASE = 9337; const GALLERY_TOKEN = crypto.randomBytes(16).toString('hex'); const SKIN_ID = core.SKIN_ID_PATTERN; const CENTER_URL = core.SKIN_CENTER_ORIGIN; +// Must match wb-setup.mjs DATA_DIR (port file, owner record, panel state). const DATA_DIR = process.platform === 'win32' ? path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'beauticode') - : path.join(os.homedir(), 'Library', 'Application Support', 'beauticode'); + : process.platform === 'darwin' + ? path.join(os.homedir(), 'Library', 'Application Support', 'beauticode') + : path.join(process.env.XDG_CONFIG_HOME && path.isAbsolute(process.env.XDG_CONFIG_HOME) + ? process.env.XDG_CONFIG_HOME : path.join(os.homedir(), '.config'), 'beauticode'); const ENV_KEY = 'WORKBUDDY_REMOTE_DEBUGGING_PORT'; const PORT_FILE = path.join(DATA_DIR, 'workbuddy-port.json'); const RUNNER_PID_FILE = path.join(DATA_DIR, 'wb-runner.pid'); diff --git a/scripts/wb-setup.mjs b/scripts/wb-setup.mjs index 54b0591..86cadd6 100644 --- a/scripts/wb-setup.mjs +++ b/scripts/wb-setup.mjs @@ -19,12 +19,13 @@ * macOS :LaunchAgent(RunAtLoad+KeepAlive,登录时 launchctl setenv + 常驻 runner) * Windows:setx 持久化用户 env + 「启动」文件夹放隐藏运行的 VBS * Linux :environment.d(systemd 用户会话 env)+ ~/.config/autostart/*.desktop + * (autostart 执行稳定 runtime 的 launcher.sh;守护会在 10 秒窗口内带 env 修复性重启无 CDP 的新进程) * * 安全:不写 ~/.workbuddy/;卸载可完整还原。 */ import { spawn, spawnSync, execSync } from 'node:child_process'; -import { isManagedWorkBuddyRunner, readWindowsProcess } from './guardian-process.mjs'; +import { isManagedWorkBuddyRunner, readGuardianProcess } from './guardian-process.mjs'; import fs from 'node:fs'; import net from 'node:net'; import os from 'node:os'; @@ -32,11 +33,14 @@ import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { activateStableRuntime, + beautiCodeLocalRoot, discardStableRuntime, hostRuntimeRoot, renderWindowsStartupVbs, + renderXdgDesktopEntry, resolveNodeExecutable, stableRuntimeRoot, + startDetachedLauncher, startupDirectory, syncStableRuntime, writeTextAtomic, @@ -188,7 +192,7 @@ async function prepareStableRuntime() { }); try { await resolveNodeExecutable({ stableRoot: runtime.hostRoot }); - return await activateStableRuntime(runtime, { startupLogPath: WIN_LOG }); + return await activateStableRuntime(runtime, { startupLogPath: PLAT === 'linux' ? LINUX_LOG : WIN_LOG }); } catch (error) { await discardStableRuntime(runtime); throw error; @@ -208,11 +212,13 @@ const WIN_LOG = path.join( ); const LINUX_ENV = path.join(home, '.config/environment.d/beauticode-wb.conf'); const LINUX_DESKTOP = path.join(home, '.config/autostart/beauticode-beauticode-wb-runner.desktop'); +const LINUX_LOG = path.join(beautiCodeLocalRoot(process.env, home), 'logs', 'wb-launcher.log'); +// Linux uses the owner record that wb-cdp-runner --watchdog writes itself. const PID_FILE = PLAT === 'win32' ? path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'beauticode', 'wb-runner.pid') : PLAT === 'darwin' ? path.join(home, 'Library', 'Logs', 'beauticode-wb-runner.pid') - : '/tmp/beauticode-wb-runner.pid'; + : path.join(DATA_DIR, 'wb-runner.pid'); const macPlist = () => ` @@ -308,11 +314,18 @@ function statusWin() { log(` 守护日志:${state}`); } -function installLinux() { +function installLinux(runtime) { fs.mkdirSync(path.dirname(LINUX_ENV), { recursive: true }); fs.mkdirSync(path.dirname(LINUX_DESKTOP), { recursive: true }); fs.writeFileSync(LINUX_ENV, `${ENV_KEY}=${port}\n`); - fs.writeFileSync(LINUX_DESKTOP, linuxDesktop()); + fs.writeFileSync(LINUX_DESKTOP, runtime + ? renderXdgDesktopEntry({ + name: 'beautiCode WorkBuddy runner', + comment: '注入 WorkBuddy 自定义背景(CDP)', + launcher: runtime.launcher, + args: ['--watchdog'], + }) + : linuxDesktop()); log(' ✓ environment.d + autostart 已写入(下次登录生效;当前会话可手动 export)'); } function uninstallLinux() { @@ -348,12 +361,12 @@ function findWindowsRunnerPids(nodeOnly = false) { } function managedWindowsRunnerProcess() { - if (PLAT !== 'win32') return null; + if (PLAT !== 'win32' && PLAT !== 'linux') return null; let record; try { record = JSON.parse(fs.readFileSync(PID_FILE, 'utf8')); } catch { return null; } const pid = Number(record?.pid); - const current = readWindowsProcess(pid); + const current = readGuardianProcess(pid); return isManagedWorkBuddyRunner(record, current, { stableHostRoot: STABLE_HOST_ROOT, sourceRunner: RUNNER, pidFile: PID_FILE, }) ? current : null; @@ -366,6 +379,16 @@ function stopManagedDaemon() { for (const pid of managed) { spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }); } + } else if (PLAT === 'linux') { + // Only an owner record whose PID, image, runner path and start time + // still match is signalled; a recycled PID is left alone. + const managed = managedWindowsRunnerProcess(); + if (!managed) return; + try { process.kill(managed.pid, 'SIGTERM'); } catch { /* already gone */ } + const deadline = Date.now() + 5_000; + while (readGuardianProcess(managed.pid) && Date.now() < deadline) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); + } } else { if (!fs.existsSync(PID_FILE)) return; const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10); @@ -377,8 +400,13 @@ function stopManagedDaemon() { } } -function startDaemon() { +function startDaemon(runtime) { stopManagedDaemon(); + if (PLAT === 'linux' && runtime) { + const pid = startDetachedLauncher(runtime.launcher, ['--watchdog'], { env: { ...process.env, [ENV_KEY]: port } }); + log(` ✓ 守护启动器已调用(pid ${pid},日志 ${path.join(beautiCodeLocalRoot(process.env, home), 'logs', 'wb-runner.log')})`); + return; + } const logFile = PLAT === 'darwin' ? MAC_LOG : PLAT === 'win32' ? WIN_LOG : '/tmp/beauticode-wb-runner.log'; fs.mkdirSync(path.dirname(logFile), { recursive: true }); fs.mkdirSync(path.dirname(PID_FILE), { recursive: true }); @@ -404,7 +432,7 @@ if (!fs.existsSync(RUNNER)) { } if (cmd === 'status') { - if (PLAT === 'win32' && process.argv.includes('--machine-status')) { + if ((PLAT === 'win32' || PLAT === 'linux') && process.argv.includes('--machine-status')) { let health = { state: 'probe-failed' }; try { const adapterUrl = pathToFileURL(path.join(REPO, 'packages', 'adapter-workbuddy', 'dist', 'index.js')).href; @@ -412,7 +440,7 @@ if (cmd === 'status') { health = await adapter.inspectWorkBuddyUiHealth(); } catch { /* report a sanitized failure instead of spawning the long-lived runner */ } process.stdout.write(JSON.stringify({ - installed: fs.existsSync(STARTUP_VBS), + installed: fs.existsSync(PLAT === 'linux' ? LINUX_DESKTOP : STARTUP_VBS), running: Boolean(managedWindowsRunnerProcess()), health, }) + '\n'); @@ -447,11 +475,11 @@ if (PACKAGED_RUNTIME) { // install — resolve and stage first; startup wiring is only replaced after // the stable runtime and verified Node are ready. log(`安装 beautiCode × WorkBuddy(${PLAT},端口 ${port}):`); -const stableRuntime = PLAT === 'win32' ? await prepareStableRuntime() : null; +const stableRuntime = PLAT === 'win32' || PLAT === 'linux' ? await prepareStableRuntime() : null; writeConfiguredPort(port); -if (PLAT === 'darwin') installMac(); else if (PLAT === 'win32') await installWin(stableRuntime); else installLinux(); +if (PLAT === 'darwin') installMac(); else if (PLAT === 'win32') await installWin(stableRuntime); else installLinux(stableRuntime); -startDaemon(); +startDaemon(stableRuntime); const up = await cdpUp(); if (up) { From 98ae49af82516e51761bd8dc05247aeaadba16e6 Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Tue, 27 Oct 2026 21:58:55 -0400 Subject: [PATCH 04/13] feat(codex): Linux guardian wiring via XDG autostart Install writes an owned autostart entry and starts the guardian detached; --remove deletes only our entry, stops guardians and restores open Codex pages with the renderer's own dispose hooks (saved theme kept). Health, lease and lifecycle read /proc on Linux. --- integrations/codex-desktop/cli.js | 77 ++++++++++++++++++- integrations/codex-desktop/codex-watchdog.mjs | 4 +- integrations/codex-desktop/health.mjs | 19 ++++- integrations/codex-desktop/lifecycle.mjs | 31 +++++++- integrations/codex-desktop/task-wiring.mjs | 52 +++++++++++++ 5 files changed, 175 insertions(+), 8 deletions(-) diff --git a/integrations/codex-desktop/cli.js b/integrations/codex-desktop/cli.js index ef40d39..78168cc 100644 --- a/integrations/codex-desktop/cli.js +++ b/integrations/codex-desktop/cli.js @@ -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) { @@ -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"); } @@ -142,6 +149,54 @@ 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(); + 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"); @@ -169,6 +224,11 @@ 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; @@ -199,7 +259,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") { @@ -213,6 +275,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); diff --git a/integrations/codex-desktop/codex-watchdog.mjs b/integrations/codex-desktop/codex-watchdog.mjs index 28d3efb..28c58bd 100644 --- a/integrations/codex-desktop/codex-watchdog.mjs +++ b/integrations/codex-desktop/codex-watchdog.mjs @@ -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`), diff --git a/integrations/codex-desktop/health.mjs b/integrations/codex-desktop/health.mjs index 7d0c6f1..5d15ea8 100644 --- a/integrations/codex-desktop/health.mjs +++ b/integrations/codex-desktop/health.mjs @@ -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"; @@ -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" || @@ -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); @@ -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", }; diff --git a/integrations/codex-desktop/lifecycle.mjs b/integrations/codex-desktop/lifecycle.mjs index aadbda3..15ad006 100644 --- a/integrations/codex-desktop/lifecycle.mjs +++ b/integrations/codex-desktop/lifecycle.mjs @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; +import { readdirSync, readFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import path from "node:path"; @@ -56,7 +57,18 @@ function pidAlive(pid) { } } +function linuxCommandLine(pid) { + try { + return readFileSync(`/proc/${Math.trunc(pid)}/cmdline`, "utf8") + .split("\0").filter(Boolean) + .map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" "); + } catch { + return ""; + } +} + export function processCommandLine(pid) { + if (process.platform === "linux") return linuxCommandLine(pid); if (process.platform !== "win32") return ""; try { const script = @@ -145,15 +157,28 @@ export function shouldRestartCodexHelper({ stopping = false } = {}) { } /** Stop only verified installed beautiCode guardian processes, never Codex. */ +/** Same-user node processes from /proc, shaped like the Win32_Process rows. */ +function linuxNodeProcessRows() { + let entries = []; + try { entries = readdirSync("/proc"); } catch { return []; } + return entries.filter((entry) => /^\d+$/.test(entry)).flatMap((entry) => { + const command = linuxCommandLine(Number(entry)); + const first = command.split(" ", 1)[0] || ""; + return /(?:^|\/)node$|(?:^|\/)sh$/.test(first) ? [{ ProcessId: Number(entry), CommandLine: command }] : []; + }); +} + export function stopInstalledCodexGuardians(helperHome, stableHostRoot) { - if (process.platform !== "win32") return 0; + if (process.platform !== "win32" && process.platform !== "linux") return 0; const allowed = [ path.join(helperHome, "start-watch.ps1"), path.join(helperHome, "codex-watchdog.mjs"), path.join(helperHome, "watch-host.mjs"), path.join(stableHostRoot, "launcher.ps1"), + path.join(stableHostRoot, "launcher.sh"), ].map((value) => path.resolve(value).replaceAll("\\", "/").toLowerCase()); const versionPrefix = path.join(stableHostRoot, "versions").replaceAll("\\", "/").toLowerCase() + "/"; + if (process.platform === "linux") return stopMatchingRows(linuxNodeProcessRows(), allowed, versionPrefix); const script = "$p=Get-CimInstance Win32_Process | Where-Object { $_.Name -in @('node.exe','powershell.exe','pwsh.exe') }; $p | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"; let rows; try { @@ -162,6 +187,10 @@ export function stopInstalledCodexGuardians(helperHome, stableHostRoot) { }).trim(); rows = raw ? JSON.parse(raw) : []; } catch { return 0; } + return stopMatchingRows(rows, allowed, versionPrefix); +} + +function stopMatchingRows(rows, allowed, versionPrefix) { let stopped = 0; for (const row of Array.isArray(rows) ? rows : [rows]) { const pid = Number(row?.ProcessId); diff --git a/integrations/codex-desktop/task-wiring.mjs b/integrations/codex-desktop/task-wiring.mjs index 1e3bfd9..1fb4f99 100644 --- a/integrations/codex-desktop/task-wiring.mjs +++ b/integrations/codex-desktop/task-wiring.mjs @@ -1,4 +1,6 @@ import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; export const CODEX_TASK_NAME = "beautiCode Codex Guardian"; @@ -119,3 +121,53 @@ export async function uninstallCodexWiring({ task, runKey, stopOwned }) { } await runKey.remove(); } + +/** + * Linux startup wiring: one XDG autostart entry that runs the stable POSIX + * launcher. Ownership is proven by the managed marker plus the exact + * launcher path, so a same-named user file is never rewritten or deleted. + */ +export const CODEX_AUTOSTART_NAME = "beauticode-codex-guardian"; + +export function codexAutostartPath(env = process.env, home = os.homedir()) { + const config = env.XDG_CONFIG_HOME && path.isAbsolute(env.XDG_CONFIG_HOME) + ? env.XDG_CONFIG_HOME + : path.join(home, ".config"); + return path.join(config, "autostart", `${CODEX_AUTOSTART_NAME}.desktop`); +} + +export function isOwnedCodexAutostart(content, launcher) { + const text = String(content ?? ""); + return /^X-BeautiCode-Managed=true$/m.test(text) && + text.split(/\r?\n/).some((line) => line.startsWith("Exec=") && line.includes(`"${launcher}"`)); +} + +export function createCodexAutostartAdapter(launcher, { file = codexAutostartPath() } = {}) { + if (!path.posix.isAbsolute(launcher)) throw new Error("invalid Codex launcher path"); + const read = () => { + try { return fs.readFileSync(file, "utf8"); } + catch (error) { if (error?.code === "ENOENT") return null; throw error; } + }; + const assertAbsentOrOwned = () => { + const current = read(); + if (current !== null && !isOwnedCodexAutostart(current, launcher)) { + throw new Error("Codex autostart entry is not owned by beautiCode"); + } + return current; + }; + return { + file, + read, + isOwned: () => { const current = read(); return current !== null && isOwnedCodexAutostart(current, launcher); }, + write(content) { + assertAbsentOrOwned(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const temporary = `${file}.tmp-${process.pid}`; + fs.writeFileSync(temporary, content, "utf8"); + fs.renameSync(temporary, file); + }, + removeOwned() { + if (assertAbsentOrOwned() !== null) fs.rmSync(file, { force: true }); + }, + }; +} From e80d1f7ee8ff67fd6c0121c528fad0f0f36a0cc9 Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Tue, 27 Oct 2026 21:58:55 -0400 Subject: [PATCH 05/13] feat(desktop): Linux support for beauticode-desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux detection for codex/workbuddy/cursor (doubao skipped), all install/ status/health on Linux, owned ~/.local/share/applications '(背景)' entries removed on uninstall, README Linux section, tests for Linux paths. --- packages/beauticode-desktop/README.md | 43 ++++++++++-- .../beauticode-desktop/bin/postinstall.mjs | 4 +- packages/beauticode-desktop/src/health.mjs | 4 +- packages/beauticode-desktop/src/index.mjs | 70 ++++++++++++++++--- .../src/quick-shortcuts.mjs | 69 ++++++++++++++++++ packages/beauticode-desktop/test/all.test.mjs | 10 ++- .../beauticode-desktop/test/pack.test.mjs | 2 +- .../test/portable-runtime.test.mjs | 2 +- .../test/quick-shortcuts.test.mjs | 24 +++++++ 9 files changed, 210 insertions(+), 18 deletions(-) diff --git a/packages/beauticode-desktop/README.md b/packages/beauticode-desktop/README.md index c2a51e2..460b899 100644 --- a/packages/beauticode-desktop/README.md +++ b/packages/beauticode-desktop/README.md @@ -1,10 +1,11 @@ # beauticode-desktop -Windows-only aggregate package for the beautiCode desktop hosts. Requires Node.js 22 or newer. +Aggregate package for the beautiCode desktop hosts on Windows and Linux (x64). +Requires Node.js 22 or newer. Linux support is described in +[Linux](#linux); everything else in this file describes Windows. -The source workspace installs on Linux for CI validation. `npm run desktop:pack` -adds the Windows-only npm platform restriction to the distributable; running -Windows installation commands on another platform remains unsupported. +`npm run desktop:pack` sets the distributable's npm platform restriction to +`win32` and `linux`. Codex MSIX safety notice: direct launching an executable inside WindowsApps loses its package identity and can prevent Codex from starting. This package @@ -124,3 +125,37 @@ WorkBuddy-managed sandbox Node paths. A verified sandbox Node is copied into that host runtime only as a recorded fallback. Moving or removing the npm package after install therefore does not invalidate the runner. Uninstall removes only beautiCode's startup wiring and preserves host state and media. + +## Linux + +Supported clients: Codex (the ChatGPT desktop app, e.g. `/usr/lib/chatgpt`), +Cursor (`.deb`/`.rpm` in `/usr/share/cursor`, `/opt/Cursor`, or +`BEAUTICODE_CURSOR_EXECUTABLE`), WorkBuddy (`/opt/WorkBuddy`) and DSH when it +is already installed. Doubao has no Linux client and is skipped. + +```sh +npm install -g beauticode-desktop --foreground-scripts +beauticode-desktop all install # or: beauticode-desktop codex install +beauticode-desktop all status +beauticode-desktop codex uninstall # per host; removes only beautiCode-owned files +``` + +What `install` writes for the current user (no root, no system files): + +- a stable runtime under `~/.local/share/beautiCode` (`$XDG_DATA_HOME`) with a + POSIX `launcher.sh` per host (Codex: `codex-plugin/`, others: + `runtime//`), plus logs and per-host state; +- an XDG autostart entry in `~/.config/autostart` (`$XDG_CONFIG_HOME`) marked + `X-BeautiCode-Managed=true` that starts the guardian at login, and starts it + now; +- a `beautiCode (背景)` application entry in + `~/.local/share/applications` that launches the client with the CDP port; +- WorkBuddy also keeps its port in `~/.config/beauticode` and + `~/.config/environment.d/beauticode-wb.conf`. + +Opening a client from its own icon without CDP is restarted once by the +guardian if it is younger than 10 seconds, the same as on Windows. There is +no tray on Linux. `uninstall` stops the guardian, restores the client page, +and removes the autostart entry and the `(背景)` application entry; an entry +with the same name that beautiCode does not own is left untouched. + diff --git a/packages/beauticode-desktop/bin/postinstall.mjs b/packages/beauticode-desktop/bin/postinstall.mjs index efb9122..1eb4de7 100644 --- a/packages/beauticode-desktop/bin/postinstall.mjs +++ b/packages/beauticode-desktop/bin/postinstall.mjs @@ -3,6 +3,8 @@ console.log("\nbeautiCode 已安装。下一步请运行命令安装宿主守护和开始菜单快捷方式:"); console.log(" 全局安装:beauticode-desktop all install"); console.log(" 项目内安装:npm exec -- beauticode-desktop all install"); -console.log("这一步会修改当前 Windows 用户的启动项,不会启动桌面客户端。"); +console.log(process.platform === "linux" + ? "这一步会写入当前用户的 ~/.config/autostart 与 ~/.local/share/applications,不会启动桌面客户端。" + : "这一步会修改当前 Windows 用户的启动项,不会启动桌面客户端。"); console.log("安装后检查:beauticode-desktop all status"); diff --git a/packages/beauticode-desktop/src/health.mjs b/packages/beauticode-desktop/src/health.mjs index 392194f..355a82f 100644 --- a/packages/beauticode-desktop/src/health.mjs +++ b/packages/beauticode-desktop/src/health.mjs @@ -47,7 +47,9 @@ export function createHealthRunner(deps) { ...(conflict ? { reason: conflict } : {}), }; } - if (resolved.platform !== "win32") return { host, installation: "unsupported-platform", state: "not-checked" }; + if (resolved.platform !== "win32" && !(resolved.platform === "linux" && host !== "doubao")) { + return { host, installation: "unsupported-platform", state: "not-checked" }; + } const runtimeRoot = options.runtimeRoot || deps.defaultRuntimeRoot; const script = host === "workbuddy" ? path.join(runtimeRoot, "workbuddy", "scripts", "wb-setup.mjs") diff --git a/packages/beauticode-desktop/src/index.mjs b/packages/beauticode-desktop/src/index.mjs index b49f768..4258707 100644 --- a/packages/beauticode-desktop/src/index.mjs +++ b/packages/beauticode-desktop/src/index.mjs @@ -5,12 +5,21 @@ import fs from "node:fs"; import { spawnSync } from "node:child_process"; import { queryWindowsInstallRecords, resolveRegisteredExecutable } from "./windows-host-install.mjs"; import { createHealthRunner } from "./health.mjs"; -import { installHostQuickShortcut } from "./quick-shortcuts.mjs"; +import { installHostQuickShortcut, removeLinuxQuickShortcut } from "./quick-shortcuts.mjs"; export { resolveRegisteredExecutable, writeVerifiedExecutableRecord } from "./windows-host-install.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); +/** Platforms with a supported install path. Linux skips Doubao (no Linux client). */ +const SUPPORTED_PLATFORMS = Object.freeze(["win32", "linux"]); +const LINUX_UNSUPPORTED_HOSTS = Object.freeze(["doubao"]); + +function xdgDir(env, key, home, fallback) { + const value = env[key]; + return value && path.isAbsolute(value) ? value : path.join(home, ...fallback); +} + function canonicalStartupMarker(host, env, home) { const appData = env.APPDATA || path.join(home, "AppData", "Roaming"); return path.join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `beauticode-${host}-runner.vbs`); @@ -130,6 +139,7 @@ export function helpText() { "命令:install 安装后台接线;status 检查守护;health 检查宿主、CDP 与背景入口;uninstall 卸载后台接线", "提示:各宿主的 install/uninstall 只调用包内运行时;不会依赖源码 workspace。", "all install 安装守护并创建四个背景版开始菜单快捷方式;安装时不启动客户端。原图标仍由守护兜底。", + "Linux:支持 codex、workbuddy、cursor(以及已安装的 dsh);守护写入 ~/.config/autostart,背景版启动项写入 ~/.local/share/applications;doubao 无 Linux 客户端,自动跳过。", ].join("\n"); } @@ -163,7 +173,9 @@ function installationMarkers(host, options) { ]; } if (host === "codex") { - const root = env.BEAUTICODE_DATA_ROOT || localAppData; + const root = env.BEAUTICODE_DATA_ROOT || (platform === "linux" + ? env.LOCALAPPDATA || xdgDir(env, "XDG_DATA_HOME", home, [".local", "share"]) + : localAppData); return [path.join(root, "beautiCode", "codex-plugin", "codex-watchdog.mjs")]; } if (host === "workbuddy") { @@ -172,6 +184,9 @@ function installationMarkers(host, options) { return [path.join(home, ".config", "autostart", "beauticode-beauticode-wb-runner.desktop")]; } if (platform === "win32") return [canonicalStartupMarker(host, env, home)]; + if (platform === "linux") { + return [path.join(xdgDir(env, "XDG_CONFIG_HOME", home, [".config"]), "autostart", `beauticode-${host}-runner.desktop`)]; + } return [path.join(appData, `beauticode-${host}-runner.vbs`)]; } @@ -229,8 +244,31 @@ function windowsExecutableCandidates(host, env, home) { return []; } +/** Mirrors the adapters' Linux executable candidates (deb/rpm/opt and per-user installs). */ +function linuxExecutableCandidates(host, env, home) { + const data = xdgDir(env, "XDG_DATA_HOME", home, [".local", "share"]); + if (host === "codex") return [ + "/usr/lib/chatgpt/ChatGPT", "/opt/ChatGPT/ChatGPT", "/opt/chatgpt/ChatGPT", + "/usr/lib/codex/Codex", "/opt/Codex/Codex", + path.join(data, "chatgpt", "ChatGPT"), path.join(home, ".local", "opt", "ChatGPT", "ChatGPT"), + ]; + if (host === "workbuddy") return [ + "/opt/WorkBuddy/workbuddy", "/opt/workbuddy/workbuddy", "/usr/lib/workbuddy/workbuddy", + path.join(data, "WorkBuddy", "workbuddy"), + ]; + if (host === "cursor") return [ + ...(env.BEAUTICODE_CURSOR_EXECUTABLE ? [env.BEAUTICODE_CURSOR_EXECUTABLE] : []), + "/usr/share/cursor/cursor", "/opt/Cursor/cursor", "/opt/cursor/cursor", + path.join(data, "cursor", "cursor"), path.join(home, "Applications", "cursor", "cursor"), + ]; + return []; +} + function commandOnPath(command, options) { const pathValue = options.env.PATH || options.env.Path || ""; + if (options.platform === "linux") { + return pathValue.split(":").filter(Boolean).some((directory) => options.exists(path.join(directory, command))); + } return pathValue.split(";").filter(Boolean).some((directory) => [".cmd", ".exe", ".bat"].some((extension) => options.exists(path.join(directory, `${command}${extension}`)), @@ -258,13 +296,21 @@ function packagedCodexPresent(options) { export function detectHostAvailability(host, options = {}) { if (!HOSTS.includes(host)) throw new Error(`未知宿主:${host}`); const resolved = defaultOptions(options); - if (resolved.platform !== "win32") return { host, available: false, reason: "unsupported-platform" }; + if (!SUPPORTED_PLATFORMS.includes(resolved.platform) || + (resolved.platform === "linux" && LINUX_UNSUPPORTED_HOSTS.includes(host))) { + return { host, available: false, reason: "unsupported-platform" }; + } if (host === "dsh") { const dshHome = resolved.env.DSH_HOME || path.join(resolved.home, ".dsh"); const plugin = path.join(dshHome, "plugins", "beauticode-dsh", "index.mjs"); if (resolved.exists(plugin) || resolved.exists(dshHome) || commandOnPath("dsh", resolved)) { return { host, available: true, reason: "detected" }; } + } else if (resolved.platform === "linux") { + if (getHostStatus(host, resolved).installed || + linuxExecutableCandidates(host, resolved.env, resolved.home).some(resolved.exists)) { + return { host, available: true, reason: "detected" }; + } } else { const installed = getHostStatus(host, resolved).installed; if (installed || windowsExecutableCandidates(host, resolved.env, resolved.home).some(resolved.exists) || @@ -280,7 +326,7 @@ export function detectHostAvailability(host, options = {}) { export function runAllInstall(options = {}) { const resolved = defaultOptions(options); - if (resolved.platform !== "win32") throw new Error("all install 仅支持 Windows。"); + if (!SUPPORTED_PLATFORMS.includes(resolved.platform)) throw new Error("all install 仅支持 Windows 和 Linux。"); const results = []; for (const host of HOSTS) { try { @@ -305,10 +351,12 @@ export function runAllInstall(options = {}) { results.push({ host, outcome: "failed", reason: verification.reason || "guardian-not-ready" }); continue; } - const shortcut = host === "dsh" ? null : resolved.installShortcut(host, { - localAppData: resolved.env.LOCALAPPDATA, - appData: resolved.env.APPDATA, - }); + const shortcut = host === "dsh" ? null : resolved.installShortcut(host, resolved.platform === "linux" + ? { platform: "linux", home: resolved.home, localAppData: resolved.env.LOCALAPPDATA, xdgDataHome: resolved.env.XDG_DATA_HOME } + : { + localAppData: resolved.env.LOCALAPPDATA, + appData: resolved.env.APPDATA, + }); results.push({ host, outcome: "installed", readiness: verification.state || "guardian-ready", shortcut: shortcut?.path ?? null, action: shortcut ? "open-background-shortcut" : "wait-for-launch" }); } catch (error) { results.push({ host, outcome: "failed", reason: error instanceof Error ? error.message : String(error) }); @@ -394,7 +442,7 @@ export function getAllStatus(options = {}) { ...(conflict ? { reason: conflict } : {}), }; } - if (!status.installed || !status.runtimeReady || resolved.platform !== "win32") { + if (!status.installed || !status.runtimeReady || !SUPPORTED_PLATFORMS.includes(resolved.platform)) { return { ...status, guardian: "not-checked" }; } const verification = resolved.verifyHostInstall(host, resolved); @@ -454,6 +502,10 @@ export function runHostCommand(host, command, options = {}) { if (result.status !== 0) { throw new Error(`${host} ${command} failed with exit code ${result.status ?? "unknown"}`); } + if (command === "uninstall" && host !== "dsh" && (options.platform || process.platform) === "linux") { + // Linux clean uninstall also drops our own "(背景)" application entry. + (options.removeShortcut || removeLinuxQuickShortcut)(host, { home: options.home, xdgDataHome: options.env?.XDG_DATA_HOME }); + } return { host, command, ok: true }; } diff --git a/packages/beauticode-desktop/src/quick-shortcuts.mjs b/packages/beauticode-desktop/src/quick-shortcuts.mjs index 36518ef..2821031 100644 --- a/packages/beauticode-desktop/src/quick-shortcuts.mjs +++ b/packages/beauticode-desktop/src/quick-shortcuts.mjs @@ -10,8 +10,19 @@ function psLiteral(value) { return `'${String(value).replaceAll("'", "''")}'`; } +function linuxDataHome(options = {}) { + const xdg = options.xdgDataHome || process.env.XDG_DATA_HOME; + return xdg && path.isAbsolute(xdg) ? xdg : path.join(options.home || os.homedir(), '.local', 'share'); +} + export function stableQuickLauncher(host, options = {}) { if (!NAMES[host]) throw new Error('unsupported-quick-launch-host'); + if ((options.platform || process.platform) === 'linux') { + const local = options.localAppData || linuxDataHome(options); + return host === 'codex' + ? path.join(local, 'beautiCode', 'codex-plugin', 'launcher.sh') + : path.join(local, 'beautiCode', 'runtime', host, 'launcher.sh'); + } const local = options.localAppData || process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); return host === 'codex' ? path.join(local, 'beautiCode', 'codex-plugin', 'launcher.ps1') @@ -20,6 +31,7 @@ export function stableQuickLauncher(host, options = {}) { /** Create only an owned Start Menu link; a same-named foreign link is a conflict. */ export function installHostQuickShortcut(host, options = {}) { + if ((options.platform || process.platform) === 'linux') return installLinuxQuickShortcut(host, options); if (process.platform !== 'win32') throw new Error('windows-only'); if (!NAMES[host]) throw new Error('unsupported-quick-launch-host'); const launcher = options.launcher || stableQuickLauncher(host, options); @@ -96,3 +108,60 @@ export function installHostQuickShortcut(host, options = {}) { } } } + +const LINUX_ICONS = Object.freeze({ codex: 'chatgpt', workbuddy: 'workbuddy', cursor: 'co.anysphere.cursor', doubao: 'doubao' }); + +function desktopExecQuote(value) { + // Same rules as portable-runtime desktopExecQuote (Desktop Entry spec). + return `"${String(value).replace(/["`$\\]/g, (char) => `\\${char}`).replaceAll('%', '%%')}"`; +} + +/** XDG application entry; an existing same-named entry we do not own is a conflict. */ +function installLinuxQuickShortcut(host, options) { + if (!NAMES[host] || host === 'doubao') throw new Error('unsupported-quick-launch-host'); + const launcher = options.launcher || stableQuickLauncher(host, { ...options, platform: 'linux' }); + if (!path.isAbsolute(launcher) || !fs.existsSync(launcher)) throw new Error('stable-launcher-missing'); + const applicationsDir = options.programsDir || path.join(linuxDataHome(options), 'applications'); + const shortcutPath = path.join(applicationsDir, `beautiCode-${host}-background.desktop`); + const exec = `${desktopExecQuote('/bin/sh')} ${desktopExecQuote(launcher)} --quick-launch`; + const content = [ + '[Desktop Entry]', + 'Type=Application', + `Name=beautiCode ${NAMES[host]} (背景)`, + `Comment=beautiCode quick launch for ${host}`, + `Exec=${exec}`, + `Icon=${LINUX_ICONS[host]}`, + 'Terminal=false', + 'Categories=Utility;', + 'X-BeautiCode-Managed=true', + '', + ].join('\n'); + let existing = null; + try { existing = fs.readFileSync(shortcutPath, 'utf8'); } catch (error) { if (error?.code !== 'ENOENT') throw error; } + if (existing != null) { + if (!/^X-BeautiCode-Managed=true$/m.test(existing) || !existing.includes(`Exec=${exec}`)) throw new Error('shortcut-conflict'); + return { outcome: 'present', path: shortcutPath }; + } + fs.mkdirSync(applicationsDir, { recursive: true }); + const temporaryPath = path.join(applicationsDir, `.beauticode-${randomUUID()}.tmp`); + fs.writeFileSync(temporaryPath, content, { mode: 0o644 }); + try { + fs.linkSync(temporaryPath, shortcutPath); // fails instead of overwriting a racing foreign file + } catch (error) { + if (error?.code === 'EEXIST') throw new Error('shortcut-conflict'); + throw error; + } finally { + fs.rmSync(temporaryPath, { force: true }); + } + return { outcome: 'created', path: shortcutPath }; +} + +/** Remove only our own XDG application entry (uninstall). */ +export function removeLinuxQuickShortcut(host, options = {}) { + const shortcutPath = path.join(options.programsDir || path.join(linuxDataHome(options), 'applications'), `beautiCode-${host}-background.desktop`); + try { + if (!/^X-BeautiCode-Managed=true$/m.test(fs.readFileSync(shortcutPath, 'utf8'))) return false; + } catch { return false; } + fs.rmSync(shortcutPath, { force: true }); + return true; +} diff --git a/packages/beauticode-desktop/test/all.test.mjs b/packages/beauticode-desktop/test/all.test.mjs index ccc5d2c..7038f4b 100644 --- a/packages/beauticode-desktop/test/all.test.mjs +++ b/packages/beauticode-desktop/test/all.test.mjs @@ -100,7 +100,15 @@ test("all install detects an npx-managed DSH home without a global command", () test("all install refuses unsupported platforms before invoking any setup", () => { const options = fakeOptions([], () => { throw new Error("must not spawn"); }); - assert.throws(() => runAllInstall({ ...options, platform: "linux" }), /仅支持 Windows/); + assert.throws(() => runAllInstall({ ...options, platform: "darwin" }), /仅支持 Windows 和 Linux/); +}); + +test("Linux detection uses Linux install paths and skips Doubao", () => { + const options = { ...fakeOptions(["/usr/share/cursor/cursor", "/usr/lib/chatgpt/ChatGPT"], () => { throw new Error("must not spawn"); }), platform: "linux", home: "/home/test", env: { PATH: "/usr/bin" } }; + assert.equal(detectHostAvailability("cursor", options).available, true); + assert.equal(detectHostAvailability("codex", options).available, true); + assert.equal(detectHostAvailability("workbuddy", options).reason, "host-not-found"); + assert.equal(detectHostAvailability("doubao", { ...options, exists: () => true }).reason, "unsupported-platform"); }); test("registered Cursor in a non-default directory is detected only after executable verification", () => { diff --git a/packages/beauticode-desktop/test/pack.test.mjs b/packages/beauticode-desktop/test/pack.test.mjs index 4ac648b..d5c13df 100644 --- a/packages/beauticode-desktop/test/pack.test.mjs +++ b/packages/beauticode-desktop/test/pack.test.mjs @@ -40,7 +40,7 @@ test("staging produces one self-contained runtime for all five hosts", async () assert.equal(pkg.name, "beauticode-desktop"); assert.equal(pkg.version, "0.1.0-test.12"); assert.equal(pkg.private, false); - assert.deepEqual(pkg.os, ["win32"]); + assert.deepEqual(pkg.os, ["win32", "linux"]); assert.equal(pkg.bin["beauticode-desktop"], "./bin/beauticode-desktop.mjs"); for (const [host, config] of Object.entries(HOST_RUNTIME)) { for (const relative of config.required) { diff --git a/packages/beauticode-desktop/test/portable-runtime.test.mjs b/packages/beauticode-desktop/test/portable-runtime.test.mjs index 206f538..03e1651 100644 --- a/packages/beauticode-desktop/test/portable-runtime.test.mjs +++ b/packages/beauticode-desktop/test/portable-runtime.test.mjs @@ -56,7 +56,7 @@ test("runtime sync survives package directory removal and leaves a pointer", asy assert.equal(await fs.readFile(path.join(result.activeRoot, "runner.mjs"), "utf8"), "export const source = true;\n"); assert.equal((await fs.readFile(path.join(result.activeRoot, "vendor", "adapter.js"), "utf8")).includes("adapter"), true); assert.equal((await fs.stat(result.pointerPath)).isFile(), true); - assert.equal((await fs.stat(path.join(hostRuntimeRoot(stable, "cursor"), "launcher.ps1"))).isFile(), true); + assert.equal((await fs.stat(path.join(hostRuntimeRoot(stable, "cursor"), process.platform === "linux" ? "launcher.sh" : "launcher.ps1"))).isFile(), true); } finally { await fs.rm(profile, { recursive: true, force: true }); } diff --git a/packages/beauticode-desktop/test/quick-shortcuts.test.mjs b/packages/beauticode-desktop/test/quick-shortcuts.test.mjs index 49ae1c0..a47f685 100644 --- a/packages/beauticode-desktop/test/quick-shortcuts.test.mjs +++ b/packages/beauticode-desktop/test/quick-shortcuts.test.mjs @@ -120,3 +120,27 @@ test('parent cleans its private shortcut alias when the native process is termin }), (error) => error.code === 'ETIMEDOUT'); assert.deepEqual(fs.readdirSync(programsDir), []); }); + +test('Linux quick launch entry is owned, idempotent, conflict-safe and removable', { skip: process.platform !== 'linux' }, async (t) => { + const { removeLinuxQuickShortcut } = await import('../src/quick-shortcuts.mjs'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'beauticode-quick-desktop-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const launcher = path.join(root, 'data', 'beautiCode', 'runtime', 'cursor', 'launcher.sh'); + fs.mkdirSync(path.dirname(launcher), { recursive: true }); + fs.writeFileSync(launcher, '#!/bin/sh\n'); + const programsDir = path.join(root, 'applications'); + const first = installHostQuickShortcut('cursor', { platform: 'linux', xdgDataHome: path.join(root, 'data'), programsDir }); + assert.equal(first.outcome, 'created'); + const content = fs.readFileSync(first.path, 'utf8'); + assert.match(content, /^Exec="\/bin\/sh" ".*launcher\.sh" --quick-launch$/m); + assert.match(content, /^X-BeautiCode-Managed=true$/m); + assert.equal(installHostQuickShortcut('cursor', { platform: 'linux', xdgDataHome: path.join(root, 'data'), programsDir }).outcome, 'present'); + fs.writeFileSync(first.path, '[Desktop Entry]\nExec=foreign\n'); + assert.throws(() => installHostQuickShortcut('cursor', { platform: 'linux', xdgDataHome: path.join(root, 'data'), programsDir }), /shortcut-conflict/); + assert.equal(removeLinuxQuickShortcut('cursor', { programsDir }), false); + assert.equal(fs.existsSync(first.path), true); + fs.rmSync(first.path); + installHostQuickShortcut('cursor', { platform: 'linux', xdgDataHome: path.join(root, 'data'), programsDir }); + assert.equal(removeLinuxQuickShortcut('cursor', { programsDir }), true); + assert.equal(fs.existsSync(first.path), false); +}); From b47789a9957d75cec0bc8a70d3b226e6bfa7273d Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Tue, 27 Oct 2026 21:58:55 -0400 Subject: [PATCH 06/13] fix(cursor): support Cursor 3.2x sidebar and workbench surfaces Cursor 3.27 header actions are [New Agent, Search, Automations, Customize], so nth-child(2) hit Search (anchor-text-mismatch). Accept Customize as 2nd or 4th cell when it is one of the last two (our entry is inserted after it), and make the 3.2x unified sidebar/auxiliary bar/editor content transparent. Affects all platforms; needs a Windows check. --- packages/adapter-cursor/src/spec.ts | 11 ++++++++++- packages/adapter-cursor/test/contract.test.js | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/adapter-cursor/src/spec.ts b/packages/adapter-cursor/src/spec.ts index ac103a1..acd5fff 100644 --- a/packages/adapter-cursor/src/spec.ts +++ b/packages/adapter-cursor/src/spec.ts @@ -81,7 +81,9 @@ export const CURSOR_CDP_SPEC: DesktopCdpHostSpec = Object.freeze({ mount: "cursor", // Cursor's measured agent sidebar places Customize second in header actions. // The exact position and label are both required; an upstream reorder fails closed. - anchorSelector: '[data-action-id="marketplace"][data-sidebar-primary-action], .agent-sidebar-header-actions > .agent-sidebar-cell:nth-child(2)', + // Cursor 3.2x: header cells are [New Agent, Search, Automations, Customize]; + // Earlier builds put Customize in the second cell. Each match also allows the injected entry right after it (nth-last-child(-n+2)). + anchorSelector: '[data-action-id="marketplace"][data-sidebar-primary-action], .agent-sidebar-header-actions > .agent-sidebar-cell:nth-child(2):nth-last-child(-n+2), .agent-sidebar-header-actions > .agent-sidebar-cell:nth-child(4):nth-last-child(-n+2)', anchorText: "Customize", strings: Object.freeze({ entry: "background", @@ -128,6 +130,13 @@ export const CURSOR_CDP_SPEC: DesktopCdpHostSpec = Object.freeze({ ".monaco-editor .margin", ".agent-panel", ".terminal-outer-container", + // Cursor 3.2x folder workbench (unified sidebar + agent auxiliary bar). + ".part.unifiedsidebar", + ".agent-sidebar", + ".part.auxiliarybar", + ".part.editor > .content", + ".editor-container", + ".composer-bar", ]), surfaceSelectors: Object.freeze([ ".ui-sidebar", diff --git a/packages/adapter-cursor/test/contract.test.js b/packages/adapter-cursor/test/contract.test.js index 0807567..8f2c828 100644 --- a/packages/adapter-cursor/test/contract.test.js +++ b/packages/adapter-cursor/test/contract.test.js @@ -19,7 +19,7 @@ test('Cursor uses its measured workbench target and direct Customize successor', assert.equal(CURSOR_CDP_SPEC.defaultPort, 9341); assert.equal(CURSOR_CDP_SPEC.popupTopInset, 44); assert.equal(CURSOR_CDP_SPEC.anchorText, 'Customize'); - assert.equal(CURSOR_CDP_SPEC.anchorSelector, '[data-action-id="marketplace"][data-sidebar-primary-action], .agent-sidebar-header-actions > .agent-sidebar-cell:nth-child(2)'); + assert.equal(CURSOR_CDP_SPEC.anchorSelector, '[data-action-id="marketplace"][data-sidebar-primary-action], .agent-sidebar-header-actions > .agent-sidebar-cell:nth-child(2):nth-last-child(-n+2), .agent-sidebar-header-actions > .agent-sidebar-cell:nth-child(4):nth-last-child(-n+2)'); assert.equal(CURSOR_CDP_SPEC.strings.entry, 'background'); assert.deepEqual(CURSOR_CDP_SPEC.theme.highContrastClassTokens, ['cursor-high-contrast', 'hc-black']); assert.deepEqual(CURSOR_CDP_SPEC.theme.darkClassTokens, ['cursor-dark', 'vs-dark']); From edad8304666d791d429a5be0300b29b182a30a22 Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Tue, 27 Oct 2026 21:59:47 -0400 Subject: [PATCH 07/13] fix(workbuddy): remove restore-panel and panel-settle styles on cleanup Uninstall/--clean left these two runner-injected styles in the page. Affects all platforms. --- scripts/wb-cdp-runner.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/wb-cdp-runner.mjs b/scripts/wb-cdp-runner.mjs index 67446e7..058e0a9 100644 --- a/scripts/wb-cdp-runner.mjs +++ b/scripts/wb-cdp-runner.mjs @@ -654,7 +654,8 @@ return 'default-applied'; async function cleanupAll(c) { try { await evaluate(c, BACKGROUND_BAR_CLEANUP); - for (const id of APPLY_IDS) { + // The restore layer and panel-settle style are injected outside APPLY_IDS. + for (const id of [...APPLY_IDS, 'beauticode-restore-panel', 'beauticode-panel-settle']) { await evaluate(c, `(function(){var el=document.getElementById(${JSON.stringify(id)});if(el)el.remove();return 'removed:'+${JSON.stringify(id)};})()`); } log.info('清理完成'); From cb422543c005ec42553a0cf91cc6e64225475b43 Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Tue, 27 Oct 2026 22:22:54 -0400 Subject: [PATCH 08/13] fix(desktop-cdp): Linux picker filter lists only formats core accepts; surface rejected picks in the panel instead of failing silently --- scripts/desktop-cdp-runner.mjs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/desktop-cdp-runner.mjs b/scripts/desktop-cdp-runner.mjs index fbc0e4c..445ef8a 100644 --- a/scripts/desktop-cdp-runner.mjs +++ b/scripts/desktop-cdp-runner.mjs @@ -224,7 +224,8 @@ async function evaluate(connection, expression) { /** Linux: zenity first, kdialog as the KDE fallback (same order as the WorkBuddy runner). */ function pickFileLinux() { const title = '选择背景图片或视频'; - const filter = '*.png *.jpg *.jpeg *.webp *.gif *.bmp *.avif *.mp4 *.mov *.webm *.m4v'; + // Only what core media validation accepts (IMAGE_EXTENSIONS / VIDEO_EXTENSIONS). + const filter = '*.png *.jpg *.jpeg *.webp *.avif *.mp4 *.mov *.PNG *.JPG *.JPEG *.WEBP *.AVIF *.MP4 *.MOV'; return new Promise((resolve, reject) => execFile( 'zenity', ['--file-selection', '--title', title, '--file-filter', `媒体 | ${filter}`], { maxBuffer: 1024 * 1024 }, @@ -519,7 +520,14 @@ async function runSession(endpoint) { await evaluate(connection, 'window.__bcDesktopPickRequest=0'); let picked = ''; try { picked = await pickFileNative(); } catch {} - if (picked) await setFileInput(connection, picked, { mode: 'import', save: false }); + if (picked) { + try { await setFileInput(connection, picked, { mode: 'import', save: false }); } + catch (error) { + // Surface rejected picks (e.g. unsupported format) instead of failing silently. + log.warn(`导入背景失败:${error.message}`); + await evaluate(connection, `window.__bcDesktopMessage&&window.__bcDesktopMessage(${JSON.stringify(String(error.message || spec.strings.missingFile))})`); + } + } } if (snapshot.apply?.requestId && snapshot.apply.requestId !== lastApply) { lastApply = snapshot.apply.requestId; From a1b1104be9fddc87534b538c4e33a269652fede3 Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Tue, 27 Oct 2026 22:33:35 -0400 Subject: [PATCH 09/13] fix(workbuddy): install prefers a port where WorkBuddy already serves CDP over a stale-but-free configured port --- scripts/wb-setup.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/wb-setup.mjs b/scripts/wb-setup.mjs index 86cadd6..d38bf46 100644 --- a/scripts/wb-setup.mjs +++ b/scripts/wb-setup.mjs @@ -140,8 +140,12 @@ async function chooseInstallPort() { ...(Number.isInteger(requested) ? [requested] : []), ...PORT_CANDIDATES, ].filter((value, index, all) => Number.isInteger(value) && value > 0 && value <= 65535 && all.indexOf(value) === index); + // A WorkBuddy that is already serving CDP wins over a merely free port, so a + // stale configured port never shadows the live instance (false "no CDP" warning). for (const candidate of ordered) { if (await hasWorkBuddyCdp(candidate)) return String(candidate); + } + for (const candidate of ordered) { if (await isLoopbackPortFree(candidate)) return String(candidate); } throw new Error('没有可用的 WorkBuddy loopback CDP 端口。'); From 13e6c86140aa1da2739389e6bcd59d1c7eebaaf8 Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Tue, 27 Oct 2026 22:36:51 -0400 Subject: [PATCH 10/13] =?UTF-8?q?fix(codex):=20Linux=20--remove=20page=20r?= =?UTF-8?q?estore=20also=20clears=20renderer=20load=20guards=20so=20a=20re?= =?UTF-8?q?install=20re-mounts=20=E8=83=8C=E6=99=AF=20without=20restarting?= =?UTF-8?q?=20Codex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- integrations/codex-desktop/cli.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/integrations/codex-desktop/cli.js b/integrations/codex-desktop/cli.js index 78168cc..33ef671 100644 --- a/integrations/codex-desktop/cli.js +++ b/integrations/codex-desktop/cli.js @@ -163,6 +163,12 @@ 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; })()`; From f10769d1fde619b40ead4060d6438f7cdedd0028 Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Tue, 27 Oct 2026 22:38:49 -0400 Subject: [PATCH 11/13] =?UTF-8?q?fix(codex):=20cap=20=E8=83=8C=E6=99=AF=20?= =?UTF-8?q?popover=20height=20to=20the=20space=20above=20the=20trigger=20s?= =?UTF-8?q?o=20short=20windows=20don't=20clip=20its=20header=20(console=20?= =?UTF-8?q?rev=2014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/adapter-codex/src/renderer/console.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/adapter-codex/src/renderer/console.js b/packages/adapter-codex/src/renderer/console.js index 9543421..6cd2d1a 100644 --- a/packages/adapter-codex/src/renderer/console.js +++ b/packages/adapter-codex/src/renderer/console.js @@ -1,6 +1,6 @@ (() => { "use strict"; - const CONSOLE_REV = 13; + const CONSOLE_REV = 14; if (window.__beauticodeConsoleRev === CONSOLE_REV && window.__beauticodeConsoleLoaded) { try { window.__beauticodeConsolePlace?.(); @@ -333,6 +333,10 @@ body:not([data-ds-dark-theme]) #beauticode-console-pop{background:#f3f0e9;color: if (!rect.width) return; pop.style.left = `${Math.round(rect.left)}px`; pop.style.bottom = `${Math.round(window.innerHeight - rect.top + 8)}px`; + // Short windows (e.g. 1280x820 with the rail mid-height) would push the + // header off-screen; cap to the space above the trigger and scroll instead. + pop.style.maxHeight = `${Math.max(120, Math.round(rect.top - 16))}px`; + pop.style.overflowY = "auto"; } function isContents(node) { From abe1e33808313f446ce33c9c36f7488af2db5879 Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Tue, 27 Oct 2026 22:44:09 -0400 Subject: [PATCH 12/13] feat(linux): ship Linux support as separate beauticode-linux package; keep beauticode-desktop Windows-only --- README.en.md | 11 ++++ README.md | 11 ++++ package-lock.json | 15 +++++ package.json | 5 +- packages/beauticode-desktop/README.md | 45 ++----------- .../beauticode-desktop/bin/postinstall.mjs | 4 +- packages/beauticode-desktop/src/index.mjs | 15 +++-- .../beauticode-desktop/test/pack.test.mjs | 2 +- packages/beauticode-linux/README.md | 65 +++++++++++++++++++ .../beauticode-linux/bin/beauticode-linux.mjs | 38 +++++++++++ packages/beauticode-linux/bin/postinstall.mjs | 8 +++ packages/beauticode-linux/package.json | 31 +++++++++ packages/beauticode-linux/test/pack.test.mjs | 63 ++++++++++++++++++ scripts/pack-desktop-aggregate.mjs | 36 ++++++++-- 14 files changed, 293 insertions(+), 56 deletions(-) create mode 100644 packages/beauticode-linux/README.md create mode 100755 packages/beauticode-linux/bin/beauticode-linux.mjs create mode 100755 packages/beauticode-linux/bin/postinstall.mjs create mode 100644 packages/beauticode-linux/package.json create mode 100644 packages/beauticode-linux/test/pack.test.mjs diff --git a/README.en.md b/README.en.md index 1ccc643..5c3a2a2 100644 --- a/README.en.md +++ b/README.en.md @@ -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 diff --git a/README.md b/README.md index 40d7eba..16308b4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 185a031..1c73e9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,10 @@ "resolved": "packages/beauticode-desktop", "link": true }, + "node_modules/beauticode-linux": { + "resolved": "packages/beauticode-linux", + "link": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -216,6 +220,17 @@ "node": ">=22" } }, + "packages/beauticode-linux": { + "version": "0.1.0", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "beauticode-linux": "bin/beauticode-linux.mjs" + }, + "engines": { + "node": ">=22" + } + }, "packages/core": { "name": "@beauticode/core", "version": "0.1.0", diff --git a/package.json b/package.json index f5863ba..1d9baf6 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ ], "scripts": { "build": "npm run build -w @beauticode/core && npm run build -w @beauticode/adapter-codex && npm run build -w @beauticode/adapter-dsh && npm run build -w @beauticode/adapter-workbuddy && npm run build -w @beauticode/adapter-desktop-cdp && npm run build -w @beauticode/adapter-cursor && npm run build -w @beauticode/adapter-doubao", - "test": "npm run test -w @beauticode/core && npm run test -w @beauticode/adapter-codex && npm run test -w @beauticode/adapter-dsh && npm run test -w @beauticode/adapter-workbuddy && npm run test -w @beauticode/adapter-desktop-cdp && npm run test -w @beauticode/adapter-cursor && npm run test -w @beauticode/adapter-doubao && npm run test -w beauticode-desktop", + "test": "npm run test -w @beauticode/core && npm run test -w @beauticode/adapter-codex && npm run test -w @beauticode/adapter-dsh && npm run test -w @beauticode/adapter-workbuddy && npm run test -w @beauticode/adapter-desktop-cdp && npm run test -w @beauticode/adapter-cursor && npm run test -w @beauticode/adapter-doubao && npm run test -w beauticode-desktop && npm run test -w beauticode-linux", "typecheck": "npm run build -w @beauticode/core && npm run typecheck -w @beauticode/adapter-codex && npm run typecheck -w @beauticode/adapter-dsh && npm run typecheck -w @beauticode/adapter-workbuddy && npm run build -w @beauticode/adapter-desktop-cdp && npm run typecheck -w @beauticode/adapter-cursor && npm run typecheck -w @beauticode/adapter-doubao", "discover": "node scripts/beauticode.mjs discover", "bc": "node scripts/beauticode.mjs", @@ -26,7 +26,8 @@ "cursor:runner": "node scripts/desktop-cdp-runner.mjs --host cursor", "cursor:setup": "node scripts/desktop-cdp-setup.mjs --host cursor", "doubao:runner": "node scripts/desktop-cdp-runner.mjs --host doubao", - "doubao:setup": "node scripts/desktop-cdp-setup.mjs --host doubao" + "doubao:setup": "node scripts/desktop-cdp-setup.mjs --host doubao", + "linux:pack": "node scripts/pack-desktop-aggregate.mjs --linux" }, "engines": { "node": ">=22" diff --git a/packages/beauticode-desktop/README.md b/packages/beauticode-desktop/README.md index 460b899..916cc9f 100644 --- a/packages/beauticode-desktop/README.md +++ b/packages/beauticode-desktop/README.md @@ -1,11 +1,12 @@ # beauticode-desktop -Aggregate package for the beautiCode desktop hosts on Windows and Linux (x64). -Requires Node.js 22 or newer. Linux support is described in -[Linux](#linux); everything else in this file describes Windows. +Windows-only aggregate package for the beautiCode desktop hosts. Requires Node.js 22 or newer. -`npm run desktop:pack` sets the distributable's npm platform restriction to -`win32` and `linux`. +The source workspace installs on Linux for CI validation. `npm run desktop:pack` +adds the Windows-only npm platform restriction to the distributable; running +Windows installation commands on another platform remains unsupported. +Linux users install the separate [`beauticode-linux`](../beauticode-linux/README.md) +package instead. Codex MSIX safety notice: direct launching an executable inside WindowsApps loses its package identity and can prevent Codex from starting. This package @@ -125,37 +126,3 @@ WorkBuddy-managed sandbox Node paths. A verified sandbox Node is copied into that host runtime only as a recorded fallback. Moving or removing the npm package after install therefore does not invalidate the runner. Uninstall removes only beautiCode's startup wiring and preserves host state and media. - -## Linux - -Supported clients: Codex (the ChatGPT desktop app, e.g. `/usr/lib/chatgpt`), -Cursor (`.deb`/`.rpm` in `/usr/share/cursor`, `/opt/Cursor`, or -`BEAUTICODE_CURSOR_EXECUTABLE`), WorkBuddy (`/opt/WorkBuddy`) and DSH when it -is already installed. Doubao has no Linux client and is skipped. - -```sh -npm install -g beauticode-desktop --foreground-scripts -beauticode-desktop all install # or: beauticode-desktop codex install -beauticode-desktop all status -beauticode-desktop codex uninstall # per host; removes only beautiCode-owned files -``` - -What `install` writes for the current user (no root, no system files): - -- a stable runtime under `~/.local/share/beautiCode` (`$XDG_DATA_HOME`) with a - POSIX `launcher.sh` per host (Codex: `codex-plugin/`, others: - `runtime//`), plus logs and per-host state; -- an XDG autostart entry in `~/.config/autostart` (`$XDG_CONFIG_HOME`) marked - `X-BeautiCode-Managed=true` that starts the guardian at login, and starts it - now; -- a `beautiCode (背景)` application entry in - `~/.local/share/applications` that launches the client with the CDP port; -- WorkBuddy also keeps its port in `~/.config/beauticode` and - `~/.config/environment.d/beauticode-wb.conf`. - -Opening a client from its own icon without CDP is restarted once by the -guardian if it is younger than 10 seconds, the same as on Windows. There is -no tray on Linux. `uninstall` stops the guardian, restores the client page, -and removes the autostart entry and the `(背景)` application entry; an entry -with the same name that beautiCode does not own is left untouched. - diff --git a/packages/beauticode-desktop/bin/postinstall.mjs b/packages/beauticode-desktop/bin/postinstall.mjs index 1eb4de7..efb9122 100644 --- a/packages/beauticode-desktop/bin/postinstall.mjs +++ b/packages/beauticode-desktop/bin/postinstall.mjs @@ -3,8 +3,6 @@ console.log("\nbeautiCode 已安装。下一步请运行命令安装宿主守护和开始菜单快捷方式:"); console.log(" 全局安装:beauticode-desktop all install"); console.log(" 项目内安装:npm exec -- beauticode-desktop all install"); -console.log(process.platform === "linux" - ? "这一步会写入当前用户的 ~/.config/autostart 与 ~/.local/share/applications,不会启动桌面客户端。" - : "这一步会修改当前 Windows 用户的启动项,不会启动桌面客户端。"); +console.log("这一步会修改当前 Windows 用户的启动项,不会启动桌面客户端。"); console.log("安装后检查:beauticode-desktop all status"); diff --git a/packages/beauticode-desktop/src/index.mjs b/packages/beauticode-desktop/src/index.mjs index 4258707..6b8c8d9 100644 --- a/packages/beauticode-desktop/src/index.mjs +++ b/packages/beauticode-desktop/src/index.mjs @@ -130,17 +130,20 @@ export function parseCommand(argv) { return { kind: "host", host, command }; } -export function helpText() { - return [ - "用法:beauticode-desktop ", - " beauticode-desktop all ", +export function helpText(cliName = "beauticode-desktop", platform = process.platform) { + const lines = [ + `用法:${cliName} `, + ` ${cliName} all `, "", "宿主:dsh、codex、workbuddy、cursor、doubao", "命令:install 安装后台接线;status 检查守护;health 检查宿主、CDP 与背景入口;uninstall 卸载后台接线", "提示:各宿主的 install/uninstall 只调用包内运行时;不会依赖源码 workspace。", "all install 安装守护并创建四个背景版开始菜单快捷方式;安装时不启动客户端。原图标仍由守护兜底。", - "Linux:支持 codex、workbuddy、cursor(以及已安装的 dsh);守护写入 ~/.config/autostart,背景版启动项写入 ~/.local/share/applications;doubao 无 Linux 客户端,自动跳过。", - ].join("\n"); + ]; + if (platform === "linux") { + lines.push("Linux:支持 codex、workbuddy、cursor(以及已安装的 dsh);守护写入 ~/.config/autostart,背景版启动项写入 ~/.local/share/applications;doubao 无 Linux 客户端,自动跳过。"); + } + return lines.join("\n"); } function defaultOptions(options = {}) { diff --git a/packages/beauticode-desktop/test/pack.test.mjs b/packages/beauticode-desktop/test/pack.test.mjs index d5c13df..4ac648b 100644 --- a/packages/beauticode-desktop/test/pack.test.mjs +++ b/packages/beauticode-desktop/test/pack.test.mjs @@ -40,7 +40,7 @@ test("staging produces one self-contained runtime for all five hosts", async () assert.equal(pkg.name, "beauticode-desktop"); assert.equal(pkg.version, "0.1.0-test.12"); assert.equal(pkg.private, false); - assert.deepEqual(pkg.os, ["win32", "linux"]); + assert.deepEqual(pkg.os, ["win32"]); assert.equal(pkg.bin["beauticode-desktop"], "./bin/beauticode-desktop.mjs"); for (const [host, config] of Object.entries(HOST_RUNTIME)) { for (const relative of config.required) { diff --git a/packages/beauticode-linux/README.md b/packages/beauticode-linux/README.md new file mode 100644 index 0000000..d031661 --- /dev/null +++ b/packages/beauticode-linux/README.md @@ -0,0 +1,65 @@ +# beauticode-linux + +Local image and video backgrounds for desktop AI clients on **Linux (x64)**: +Codex (the ChatGPT desktop app), Cursor and WorkBuddy. Unofficial; MIT. + +This is the Linux build of beautiCode. Windows users install +[`beauticode-desktop`](https://www.npmjs.com/package/beauticode-desktop) instead; +both packages share the same CLI and host runtimes from this repository. + +## Requirements + +- Linux x64 with a graphical session (X11 or XWayland), Node.js 22 or newer. +- `zenity` (GNOME/most desktops) or `kdialog` (KDE) for the in-app file picker, + e.g. `sudo apt install zenity`. +- The client installed from its official Linux package: + - Codex: `/usr/lib/chatgpt` (ChatGPT desktop app); + - Cursor: `.deb`/`.rpm` in `/usr/share/cursor` or `/opt/Cursor`, or set + `BEAUTICODE_CURSOR_EXECUTABLE`; + - WorkBuddy: `/opt/WorkBuddy`. + +## Install and use + +```sh +npm install -g beauticode-linux --foreground-scripts +beauticode-linux all install # guardians + "(背景)" launcher entries for all clients +beauticode-linux codex install # or one client: codex | cursor | workbuddy +beauticode-linux all status +beauticode-linux cursor health # CDP, host page and 背景 entry check +beauticode-linux codex uninstall # per client; removes only beautiCode-owned files +``` + +Then open the client (from its normal icon or the `beautiCode (背景)` +entry) and use the injected **背景** entry: import an image (JPG/PNG/WebP/AVIF) +or a video (MP4/MOV), switch saved themes, adjust shadow/blur, open the skin +center (皮肤中心) to install themes, or clear the background. Themes persist +across client restarts. + +What `install` writes for the current user (no root, no system files): + +- a stable runtime under `~/.local/share/beautiCode` (`$XDG_DATA_HOME`) with a + POSIX `launcher.sh` per client, plus logs and per-client state; +- an XDG autostart entry in `~/.config/autostart` marked + `X-BeautiCode-Managed=true` that starts the guardian at login (and now); +- a `beautiCode (背景)` entry in `~/.local/share/applications` that + starts the client with a loopback-only (127.0.0.1) debugging port; +- WorkBuddy also keeps its port in `~/.config/beauticode` and + `~/.config/environment.d/beauticode-wb.conf`. + +If a client is opened from its own icon without the debugging port, the +guardian restarts it once with the port (only within ~10 s of launch). +`uninstall` stops the guardian, restores open client windows and removes the +autostart and `(背景)` entries; entries beautiCode does not own are left alone. + +## Known limitations + +- No tray app on Linux; the built-in animated ambience themes (e.g. the rainy + window) are tray/DSH-only and not available here. +- x64 only. +- Cursor: after the guardian's repair restart, only Cursor's default window + reopens; a folder window you had opened by path is not restored (reopen it + with `cursor `). The 背景 entry needs a workbench window. +- Cursor/WorkBuddy skin center opens in your default web browser. +- WorkBuddy: the 背景 entry lives in the signed-in sidebar; features were not + verified while signed out. +- Doubao has no Linux client and is skipped. diff --git a/packages/beauticode-linux/bin/beauticode-linux.mjs b/packages/beauticode-linux/bin/beauticode-linux.mjs new file mode 100755 index 0000000..64fb9b2 --- /dev/null +++ b/packages/beauticode-linux/bin/beauticode-linux.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// beauticode-linux shares beauticode-desktop's CLI source. The packed tarball +// carries it at ../src; in the source workspace it lives in the sibling package. +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const packed = path.join(here, "..", "src", "index.mjs"); +const workspace = path.join(here, "..", "..", "beauticode-desktop", "src", "index.mjs"); +const cli = await import(pathToFileURL(fs.existsSync(packed) ? packed : workspace).href); +const { getAllStatus, helpText, parseCommand, runAllHealth, runAllInstall, runHostCommand, runHostHealth, statusText } = cli; + +try { + if (process.platform !== "linux") { + throw new Error("beauticode-linux 只支持 Linux;Windows 请安装 beauticode-desktop。"); + } + const command = parseCommand(process.argv.slice(2)); + if (command.kind === "help") { + process.stdout.write(`${helpText("beauticode-linux", "linux")}\n`); + process.exit(0); + } + if (command.kind === "all") { + const result = command.command === "status" ? getAllStatus() + : command.command === "health" ? await runAllHealth() + : runAllInstall(); + process.stdout.write(`${statusText(result)}\n`); + if (command.command === "install" && !result.ok) process.exitCode = 2; + } else { + const result = command.command === "health" + ? await runHostHealth(command.host) + : runHostCommand(command.host, command.command); + if (command.command === "status" || command.command === "health") process.stdout.write(`${statusText(result)}\n`); + } +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(2); +} diff --git a/packages/beauticode-linux/bin/postinstall.mjs b/packages/beauticode-linux/bin/postinstall.mjs new file mode 100755 index 0000000..48dabdf --- /dev/null +++ b/packages/beauticode-linux/bin/postinstall.mjs @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +console.log("\nbeautiCode (Linux) 已安装。下一步请运行命令安装宿主守护和背景版启动项:"); +console.log(" 全局安装:beauticode-linux all install"); +console.log(" 项目内安装:npm exec -- beauticode-linux all install"); +console.log("这一步会写入当前用户的 ~/.config/autostart 与 ~/.local/share/applications,不会启动桌面客户端。"); +console.log("文件选择器需要 zenity 或 kdialog(例如 sudo apt install zenity)。"); +console.log("安装后检查:beauticode-linux all status"); diff --git a/packages/beauticode-linux/package.json b/packages/beauticode-linux/package.json new file mode 100644 index 0000000..3f0d733 --- /dev/null +++ b/packages/beauticode-linux/package.json @@ -0,0 +1,31 @@ +{ + "name": "beauticode-linux", + "version": "0.1.0", + "private": false, + "description": "beautiCode local image/video backgrounds for Codex, Cursor and WorkBuddy on Linux (x64). Unofficial.", + "type": "module", + "bin": { + "beauticode-linux": "./bin/beauticode-linux.mjs" + }, + "files": [ + "bin", + "src", + "runtime", + "README.md", + "LICENSE" + ], + "scripts": { + "postinstall": "node ./bin/postinstall.mjs", + "test": "node ../../scripts/test-runner.mjs --test --test-concurrency=1 test/*.test.mjs" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/starsstreaming/beautiCode.git", + "directory": "packages/beauticode-linux" + }, + "keywords": ["beautiCode", "codex", "cursor", "workbuddy", "background", "linux"], + "engines": { + "node": ">=22" + }, + "license": "MIT" +} diff --git a/packages/beauticode-linux/test/pack.test.mjs b/packages/beauticode-linux/test/pack.test.mjs new file mode 100644 index 0000000..8a2413f --- /dev/null +++ b/packages/beauticode-linux/test/pack.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { stageLinuxPackage, stageDesktopAggregate } from "../../../scripts/pack-desktop-aggregate.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const REQUIRED = [ + "bin/beauticode-linux.mjs", + "bin/postinstall.mjs", + "src/index.mjs", + "README.md", + "runtime/codex/cli.js", + "runtime/codex/vendor/adapter-codex/native-picker.js", + "runtime/codex/vendor/core/linux-process.js", + "runtime/desktop/scripts/desktop-cdp-runner.mjs", + "runtime/desktop/packages/adapter-cursor/dist/spec.js", + "runtime/workbuddy/scripts/wb-cdp-runner.mjs", + "runtime/workbuddy/packages/adapter-workbuddy/dist/launch.js", +]; + +test("beauticode-linux stages as its own Linux-only x64 package", async () => { + const dest = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-linux-pack-")); + try { + await stageLinuxPackage(dest, { build: false }); + const pkg = JSON.parse(await fs.readFile(path.join(dest, "package.json"), "utf8")); + const source = JSON.parse(await fs.readFile(path.join(here, "..", "package.json"), "utf8")); + assert.equal(pkg.name, "beauticode-linux"); + assert.equal(pkg.version, source.version); + assert.equal(pkg.private, false); + assert.deepEqual(pkg.os, ["linux"]); + assert.deepEqual(pkg.cpu, ["x64"]); + assert.equal(pkg.engines.node, ">=22"); + assert.deepEqual(pkg.bin, { "beauticode-linux": "./bin/beauticode-linux.mjs" }); + assert.equal(pkg.scripts.test, undefined); + for (const relative of REQUIRED) await fs.access(path.join(dest, relative)); + await assert.rejects(fs.access(path.join(dest, "bin", "beauticode-desktop.mjs")), { code: "ENOENT" }); + assert.match(await fs.readFile(path.join(dest, "README.md"), "utf8"), /^# beauticode-linux/); + } finally { await fs.rm(dest, { recursive: true, force: true }); } +}); + +test("beauticode-desktop staging stays Windows-only", async () => { + const dest = await fs.mkdtemp(path.join(os.tmpdir(), "beauticode-desktop-os-")); + try { + await stageDesktopAggregate(dest, { build: false }); + const pkg = JSON.parse(await fs.readFile(path.join(dest, "package.json"), "utf8")); + assert.equal(pkg.name, "beauticode-desktop"); + assert.deepEqual(pkg.os, ["win32"]); + assert.equal(pkg.cpu, undefined); + } finally { await fs.rm(dest, { recursive: true, force: true }); } +}); + +test("beauticode-linux --help names its own command", { skip: process.platform !== "linux" }, () => { + const bin = path.join(here, "..", "bin", "beauticode-linux.mjs"); + const result = spawnSync(process.execPath, [bin, "--help"], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /用法:beauticode-linux /); + assert.doesNotMatch(result.stdout, /beauticode-desktop/); + assert.match(result.stdout, /Linux:/); +}); diff --git a/scripts/pack-desktop-aggregate.mjs b/scripts/pack-desktop-aggregate.mjs index 08ef7dd..25d5142 100644 --- a/scripts/pack-desktop-aggregate.mjs +++ b/scripts/pack-desktop-aggregate.mjs @@ -10,11 +10,16 @@ import { stageDshPlugin } from "./pack-dsh-plugin.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(here, ".."); const sourceRoot = path.join(repoRoot, "packages", "beauticode-desktop"); +const linuxRoot = path.join(repoRoot, "packages", "beauticode-linux"); export function defaultStageDir() { return path.join(repoRoot, "artifacts", "desktop-aggregate"); } +export function defaultLinuxStageDir() { + return path.join(repoRoot, "artifacts", "linux"); +} + function runBuild() { const result = spawnSync( process.platform === "win32" ? "npm.cmd" : "npm", @@ -173,15 +178,26 @@ export async function stageDesktopAggregate(destRoot = defaultStageDir(), opts = if (opts.build !== false) runBuild(); await fsp.rm(destRoot, { recursive: true, force: true }); await fsp.mkdir(destRoot, { recursive: true }); - const manifest = JSON.parse(await fsp.readFile(path.join(sourceRoot, "package.json"), "utf8")); - // Source workspaces must install anywhere; the distributable supports Windows and Linux (x64 desktop clients). - manifest.os = ["win32", "linux"]; + // `beauticode-linux` is a separate distributable: its own manifest, README and + // bin, sharing beauticode-desktop's CLI source (src/) and the same runtimes. + const linux = opts.variant === "linux"; + const packageRoot = linux ? linuxRoot : sourceRoot; + const manifest = JSON.parse(await fsp.readFile(path.join(packageRoot, "package.json"), "utf8")); + // Source workspaces must install anywhere; only the distributable carries the + // platform restriction (beauticode-desktop stays Windows-only). + if (linux) { + manifest.os = ["linux"]; + manifest.cpu = ["x64"]; + delete manifest.scripts?.test; + } else { + manifest.os = ["win32"]; + } await fsp.writeFile(path.join(destRoot, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); for (const name of ["README.md", "LICENSE"]) { - const source = name === "LICENSE" ? path.join(repoRoot, name) : path.join(sourceRoot, name); + const source = name === "LICENSE" ? path.join(repoRoot, name) : path.join(packageRoot, name); if (fs.existsSync(source)) await fsp.copyFile(source, path.join(destRoot, name)); } - await copyTree(path.join(sourceRoot, "bin"), path.join(destRoot, "bin")); + await copyTree(path.join(packageRoot, "bin"), path.join(destRoot, "bin")); await copyTree(path.join(sourceRoot, "src"), path.join(destRoot, "src")); await stageDshPlugin(path.join(destRoot, "runtime", "dsh"), { build: false }); await stageCodexPlugin(path.join(destRoot, "runtime", "codex"), { build: false }); @@ -200,7 +216,17 @@ export async function stageDesktopAggregate(destRoot = defaultStageDir(), opts = return destRoot; } +export async function stageLinuxPackage(destRoot = defaultLinuxStageDir(), opts = {}) { + return stageDesktopAggregate(destRoot, { ...opts, variant: "linux" }); +} + async function main() { + if (process.argv.includes("--linux")) { + const dest = await stageLinuxPackage(); + const { version } = JSON.parse(await fsp.readFile(path.join(dest, "package.json"), "utf8")); + process.stdout.write(`Staged beauticode-linux@${version} at ${dest}\n`); + return; + } const dest = await stageDesktopAggregate(); process.stdout.write(`Staged beauticode-desktop@0.1.0-test.12 at ${dest}\n`); } From b241e9bf856a15fa0e8f91f7bb22b2f152e54348 Mon Sep 17 00:00:00 2001 From: starsstreaming <2946843254lw@gmail.com> Date: Fri, 30 Oct 2026 10:13:17 +0800 Subject: [PATCH 13/13] fix(linux): protect desktop processes and owned autostart entries --- ...esktop-identity-and-autostart-ownership.md | 34 + .../2026-10-29-pr98-linux-safety-fixes.md | 34 + integrations/codex-desktop/cli.js | 8 +- integrations/codex-desktop/task-wiring.mjs | 38 +- packages/adapter-codex/src/index.ts | 24 +- packages/adapter-codex/src/launch.ts | 640 +----------------- packages/adapter-codex/src/processes.ts | 261 +++++++ packages/adapter-codex/src/startup-policy.ts | 80 +++ packages/adapter-codex/src/startup-repair.ts | 283 ++++++++ .../test/adapter-session.test.js | 8 +- .../test/adapter-startup.test.js | 8 +- packages/adapter-codex/test/adapter.test.js | 8 +- .../test/codex-autostart.test.js | 32 + .../test/codex-lifecycle.test.js | 4 +- .../test/linux-processes.test.js | 60 ++ .../test/xdg-autostart.test.mjs | 85 +++ scripts/desktop-cdp-setup.mjs | 22 +- scripts/desktop-runner-identity.mjs | 5 +- scripts/pack-codex-plugin.mjs | 7 +- scripts/pack-desktop-aggregate.mjs | 1 + scripts/xdg-autostart.mjs | 69 ++ 21 files changed, 1036 insertions(+), 675 deletions(-) create mode 100644 docs/adr/0010-linux-desktop-identity-and-autostart-ownership.md create mode 100644 docs/superpowers/plans/2026-10-29-pr98-linux-safety-fixes.md create mode 100644 packages/adapter-codex/src/processes.ts create mode 100644 packages/adapter-codex/src/startup-policy.ts create mode 100644 packages/adapter-codex/src/startup-repair.ts create mode 100644 packages/adapter-codex/test/codex-autostart.test.js create mode 100644 packages/adapter-codex/test/linux-processes.test.js create mode 100644 packages/beauticode-desktop/test/xdg-autostart.test.mjs create mode 100644 scripts/xdg-autostart.mjs diff --git a/docs/adr/0010-linux-desktop-identity-and-autostart-ownership.md b/docs/adr/0010-linux-desktop-identity-and-autostart-ownership.md new file mode 100644 index 0000000..278801c --- /dev/null +++ b/docs/adr/0010-linux-desktop-identity-and-autostart-ownership.md @@ -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 启动协议;回滚本修复不需要数据迁移。 diff --git a/docs/superpowers/plans/2026-10-29-pr98-linux-safety-fixes.md b/docs/superpowers/plans/2026-10-29-pr98-linux-safety-fixes.md new file mode 100644 index 0000000..b4b2110 --- /dev/null +++ b/docs/superpowers/plans/2026-10-29-pr98-linux-safety-fixes.md @@ -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 身份不能进入自动终止流程;合法桌面继续被发现;转义后的完整命令可以重复识别; +外来文件在失败与回滚时仍然保留;安装后的代码不依赖源码目录。 diff --git a/integrations/codex-desktop/cli.js b/integrations/codex-desktop/cli.js index 33ef671..a73cabe 100644 --- a/integrations/codex-desktop/cli.js +++ b/integrations/codex-desktop/cli.js @@ -70,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); @@ -240,9 +240,9 @@ export async function runCli(argv = process.argv.slice(2)) { 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)); } diff --git a/integrations/codex-desktop/task-wiring.mjs b/integrations/codex-desktop/task-wiring.mjs index 1fb4f99..1319553 100644 --- a/integrations/codex-desktop/task-wiring.mjs +++ b/integrations/codex-desktop/task-wiring.mjs @@ -3,6 +3,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +// The source checkout and the installed self-contained runtime have different roots. +const autostartUrl = new URL("./xdg-autostart.mjs", import.meta.url); +const { createXdgAutostartAdapter, isOwnedXdgAutostart } = await import( + fs.existsSync(autostartUrl) ? autostartUrl.href : new URL("../../scripts/xdg-autostart.mjs", import.meta.url).href +); + export const CODEX_TASK_NAME = "beautiCode Codex Guardian"; const RUN_PATH = "HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"; const RUN_VALUE = "BeautiCodeCodex"; @@ -137,37 +143,9 @@ export function codexAutostartPath(env = process.env, home = os.homedir()) { } export function isOwnedCodexAutostart(content, launcher) { - const text = String(content ?? ""); - return /^X-BeautiCode-Managed=true$/m.test(text) && - text.split(/\r?\n/).some((line) => line.startsWith("Exec=") && line.includes(`"${launcher}"`)); + return isOwnedXdgAutostart(content, launcher); } export function createCodexAutostartAdapter(launcher, { file = codexAutostartPath() } = {}) { - if (!path.posix.isAbsolute(launcher)) throw new Error("invalid Codex launcher path"); - const read = () => { - try { return fs.readFileSync(file, "utf8"); } - catch (error) { if (error?.code === "ENOENT") return null; throw error; } - }; - const assertAbsentOrOwned = () => { - const current = read(); - if (current !== null && !isOwnedCodexAutostart(current, launcher)) { - throw new Error("Codex autostart entry is not owned by beautiCode"); - } - return current; - }; - return { - file, - read, - isOwned: () => { const current = read(); return current !== null && isOwnedCodexAutostart(current, launcher); }, - write(content) { - assertAbsentOrOwned(); - fs.mkdirSync(path.dirname(file), { recursive: true }); - const temporary = `${file}.tmp-${process.pid}`; - fs.writeFileSync(temporary, content, "utf8"); - fs.renameSync(temporary, file); - }, - removeOwned() { - if (assertAbsentOrOwned() !== null) fs.rmSync(file, { force: true }); - }, - }; + return createXdgAutostartAdapter(launcher, { file }); } diff --git a/packages/adapter-codex/src/index.ts b/packages/adapter-codex/src/index.ts index 640445e..3ed9088 100644 --- a/packages/adapter-codex/src/index.ts +++ b/packages/adapter-codex/src/index.ts @@ -80,23 +80,31 @@ export { export { DEFAULT_CODEX_CDP_PORT, - DEFAULT_CODEX_REPAIR_WINDOW_MS, - codexInstallCandidates, - findCodexExecutable, - listCodexProcesses, isLoopbackPortFree, pickAvailableCodexPort, - ensureCodexCdp, +} from "./launch.js"; +export { + DEFAULT_CODEX_REPAIR_WINDOW_MS, classifyCodexStartupProcess, CodexStartupRepairController, - buildWindowsCodexProcessStartScript, - startCodexStartupRepairMonitor, - type CodexProcess, type CodexStartupProcessDecision, type CodexStartupRepairControllerOptions, type CodexStartupRepairResult, +} from "./startup-policy.js"; +export { + buildWindowsCodexProcessStartScript, + startCodexStartupRepairMonitor, type CodexStartupRepairMonitorOptions, type CodexStartupRepairMonitor, +} from "./startup-repair.js"; +export { + codexInstallCandidates, + findCodexExecutable, + listCodexProcesses, + type CodexProcess, +} from "./processes.js"; +export { + ensureCodexCdp, type EnsureCodexCdpOptions, type EnsuredCodexCdp, } from "./launch.js"; diff --git a/packages/adapter-codex/src/launch.ts b/packages/adapter-codex/src/launch.ts index 5458e3e..31a0f9a 100644 --- a/packages/adapter-codex/src/launch.ts +++ b/packages/adapter-codex/src/launch.ts @@ -1,370 +1,26 @@ -import { execFile, spawn, type ChildProcess } from "node:child_process"; -import fs from "node:fs"; +import { execFile, spawn } from "node:child_process"; import net from "node:net"; -import os from "node:os"; -import path from "node:path"; -import readline from "node:readline"; +import { setTimeout as delay } from "node:timers/promises"; import { promisify } from "node:util"; +import { DEFAULT_CDP_CANDIDATE_PORTS, discoverCdpEndpoints, type DiscoveredCdpEndpoint } from "./host-discover.js"; import { - listLinuxProcesses, - readLinuxProcess, - selectLinuxMainProcesses, - signalLinuxProcess, -} from "@beauticode/core"; -import { - DEFAULT_CDP_CANDIDATE_PORTS, - discoverCdpEndpoints, - parseRemoteDebuggingFlags, - type DiscoveredCdpEndpoint, -} from "./host-discover.js"; + findCodexExecutable, + findPackagedCodexExecutable, + listCodexProcesses, + parseExecutableFromProcess, + stopLinuxCodexProcess, + type CodexProcess, +} from "./processes.js"; +import { classifyCodexStartupProcess } from "./startup-policy.js"; const execFileAsync = promisify(execFile); - export const DEFAULT_CODEX_CDP_PORT = 9335; -export const DEFAULT_CODEX_REPAIR_WINDOW_MS = 10_000; - -export interface CodexProcess { - pid: number; - name: string; - executablePath: string; - commandLine: string; - port: number | null; - /** Main-process creation time. Null means it is unsafe to auto-restart. */ - createdAtMs: number | null; -} - -export type CodexStartupProcessDecision = - | "repair-now" - | "wait-for-cdp" - | "ignore-stale"; - -/** - * Decide from immutable process evidence only. A missing CDP flag can never - * become available later in the same Chromium main process, so a fresh blind - * process is repaired immediately instead of waiting for an endpoint timeout. - */ -export function classifyCodexStartupProcess( - proc: CodexProcess, - nowMs = Date.now(), - repairWindowMs = DEFAULT_CODEX_REPAIR_WINDOW_MS, -): CodexStartupProcessDecision { - if (proc.port != null) return "wait-for-cdp"; - if (proc.createdAtMs == null || !Number.isFinite(proc.createdAtMs)) { - return "ignore-stale"; - } - const ageMs = nowMs - proc.createdAtMs; - return ageMs >= 0 && ageMs < repairWindowMs - ? "repair-now" - : "ignore-stale"; -} - -export interface CodexStartupRepairControllerOptions { - now?: () => number; - repairWindowMs?: number; - repair: (proc: CodexProcess) => Promise; -} - -export type CodexStartupRepairResult = - | "repaired" - | "already-handled" - | "wait-for-cdp" - | "ignore-stale"; - -/** Serialize and deduplicate process-start events by PID + creation time. */ -export class CodexStartupRepairController { - private readonly seen = new Set(); - private chain: Promise = Promise.resolve(); - private readonly now: () => number; - private readonly repairWindowMs: number; - private readonly repair: (proc: CodexProcess) => Promise; - - constructor(opts: CodexStartupRepairControllerOptions) { - this.now = opts.now ?? Date.now; - this.repairWindowMs = - opts.repairWindowMs ?? DEFAULT_CODEX_REPAIR_WINDOW_MS; - this.repair = opts.repair; - } - - observe(proc: CodexProcess): Promise { - const decision = classifyCodexStartupProcess( - proc, - this.now(), - this.repairWindowMs, - ); - if (decision !== "repair-now") return Promise.resolve(decision); - - const generation = `${proc.pid}:${proc.createdAtMs}`; - if (this.seen.has(generation)) return Promise.resolve("already-handled"); - this.seen.add(generation); - - const run = async (): Promise => { - await this.repair(proc); - return "repaired"; - }; - const next = this.chain.then(run, run); - this.chain = next.then( - () => undefined, - () => undefined, - ); - return next; - } -} export interface EnsureCodexLog { info: (...m: string[]) => void; warn: (...m: string[]) => void; } -export interface EnsureCodexCdpOptions { - preferredPort?: number; - /** Bounded fallback ports; override in deterministic tests. */ - candidatePorts?: readonly number[]; - /** Include same-user process flags in discovery. Defaults to true on Windows. */ - scanProcesses?: boolean; - launch?: boolean; - restartIfBlind?: boolean; - timeoutMs?: number; - log?: EnsureCodexLog; -} - -export interface EnsuredCodexCdp extends DiscoveredCdpEndpoint { - launched: boolean; - restarted: boolean; -} - -export function looksLikeCodexMain( - commandLine: string, - name: string, - exePath = "", -): boolean { - const hay = `${name} ${exePath} ${commandLine}`; - if (!/\b(ChatGPT|Codex)\.exe\b/i.test(hay) && !/\b(ChatGPT|Codex)\b/i.test(hay)) { - return false; - } - if (/\s--type=/.test(commandLine)) return false; - if (/\bapp-server\b/i.test(commandLine)) return false; - if (/\\resources\\/i.test(exePath) || /\\resources\\/i.test(commandLine)) { - return false; - } - // Linux/macOS bundle the Codex CLI app-server under resources/ as well. - if (/\/resources\//i.test(exePath)) return false; - return true; -} - -/** Official Linux packages ship the Electron/Owl shell as ChatGPT (or Codex). */ -export const LINUX_CODEX_EXECUTABLE_NAMES = Object.freeze(["ChatGPT", "Codex", "chatgpt", "codex"]); - -export function codexInstallCandidates( - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform, -): string[] { - const home = env.HOME || env.USERPROFILE || os.homedir(); - if (platform === "linux") { - return [ - "/usr/lib/chatgpt/ChatGPT", - "/opt/ChatGPT/ChatGPT", - "/opt/chatgpt/ChatGPT", - "/usr/lib/codex/Codex", - "/opt/Codex/Codex", - path.posix.join(home, ".local", "share", "chatgpt", "ChatGPT"), - path.posix.join(home, ".local", "opt", "ChatGPT", "ChatGPT"), - ]; - } - if (platform !== "win32") return []; - const local = env.LOCALAPPDATA || path.join(home, "AppData", "Local"); - const pf = env.ProgramFiles || "C:\\Program Files"; - const pf86 = env["ProgramFiles(x86)"] || "C:\\Program Files (x86)"; - const relatives = [ - ["Programs", "ChatGPT", "ChatGPT.exe"], - ["Programs", "chatgpt", "ChatGPT.exe"], - ["Programs", "Codex", "Codex.exe"], - ["Programs", "codex", "Codex.exe"], - ["OpenAI", "ChatGPT", "ChatGPT.exe"], - ["OpenAI", "Codex", "Codex.exe"], - ]; - const out: string[] = []; - for (const root of [local, pf, pf86]) { - for (const rel of relatives) { - out.push(path.join(root, ...rel)); - } - } - return out; -} - -export function findCodexExecutable( - env: NodeJS.ProcessEnv = process.env, - platform: NodeJS.Platform = process.platform, -): string | null { - for (const candidate of codexInstallCandidates(env, platform)) { - try { - if (fs.existsSync(candidate)) return candidate; - } catch { - /* ignore */ - } - } - return null; -} - -export function parseAppxCodexInstallLocations(stdout: string): string[] { - return [...new Set( - String(stdout ?? "") - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => path.win32.isAbsolute(line)), - )]; -} - -export async function findPackagedCodexExecutable( - platform: NodeJS.Platform = process.platform, -): Promise { - if (platform !== "win32") return null; - try { - const { stdout } = await execFileAsync( - "powershell.exe", - [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - "Get-AppxPackage -Name OpenAI.Codex | Sort-Object Version -Descending | ForEach-Object { $_.InstallLocation }", - ], - { windowsHide: true, timeout: 12_000, maxBuffer: 1024 * 1024 }, - ); - for (const installLocation of parseAppxCodexInstallLocations(stdout)) { - const executable = path.win32.join(installLocation, "app", "ChatGPT.exe"); - if (fs.existsSync(executable)) return executable; - } - } catch { - /* package absent or AppX query unavailable */ - } - return null; -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function scanWindowsCodexProcesses(): Promise { - if (process.platform !== "win32") return []; - const script = [ - "$ErrorActionPreference='SilentlyContinue';", - "Get-CimInstance Win32_Process | ForEach-Object {", - "$cmd=[string]$_.CommandLine;", - "$name=[string]$_.Name;", - "$path=[string]$_.ExecutablePath;", - "$created=0;", - "try{$created=([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds()}catch{};", - "if($name -notmatch '^(ChatGPT|Codex)\\.exe$'){return};", - "if($cmd -match '\\s--type='){return};", - "if($path -match '\\\\resources\\\\'){return};", - "$o=@{pid=$_.ProcessId;name=$name;exe=$path;cmd=$cmd;created=$created};", - "($o | ConvertTo-Json -Compress -Depth 3)", - "}", - ].join(" "); - try { - const { stdout } = await execFileAsync( - "powershell.exe", - [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - script, - ], - { - windowsHide: true, - timeout: 12_000, - maxBuffer: 4 * 1024 * 1024, - }, - ); - const found: CodexProcess[] = []; - for (const line of String(stdout ?? "").split(/\r?\n/)) { - const trimmed = line.trim(); - if (!trimmed.startsWith("{")) continue; - let row: { - pid?: unknown; - name?: unknown; - exe?: unknown; - cmd?: unknown; - created?: unknown; - }; - try { - row = JSON.parse(trimmed) as typeof row; - } catch { - continue; - } - const commandLine = typeof row.cmd === "string" ? row.cmd : ""; - const name = typeof row.name === "string" ? row.name : "ChatGPT.exe"; - const executablePath = typeof row.exe === "string" ? row.exe : ""; - if (!looksLikeCodexMain(commandLine, name, executablePath)) continue; - const flags = parseRemoteDebuggingFlags(commandLine); - found.push({ - pid: Number(row.pid) || 0, - name, - executablePath, - commandLine, - port: flags.port, - createdAtMs: - Number.isFinite(Number(row.created)) && Number(row.created) > 0 - ? Number(row.created) - : null, - }); - } - return found; - } catch { - return []; - } -} - -/** /proc counterpart of the Win32_Process scan: main ChatGPT/Codex binaries only. */ -export function scanLinuxCodexProcesses(): CodexProcess[] { - const isCodexImage = (exe: string) => - LINUX_CODEX_EXECUTABLE_NAMES.includes(path.posix.basename(exe)) && - !/\/resources\//i.test(exe); - const rows = listLinuxProcesses((row) => isCodexImage(row.executablePath)); - return selectLinuxMainProcesses(rows, (row) => - looksLikeCodexMain(row.commandLine, path.posix.basename(row.executablePath), row.executablePath), - ).map((row) => ({ - pid: row.pid, - name: path.posix.basename(row.executablePath), - executablePath: row.executablePath, - commandLine: row.commandLine, - port: parseRemoteDebuggingFlags(row.commandLine).port, - createdAtMs: row.createdAtMs, - })); -} - -export async function listCodexProcesses(): Promise { - if (process.platform === "linux") return scanLinuxCodexProcesses(); - return scanWindowsCodexProcesses(); -} - -/** - * Stop one exact Linux main process (PID + creation time) and wait for it to - * exit: a relaunch while the old instance is alive would just hand its - * arguments to the singleton and drop the CDP flags. - */ -async function stopLinuxCodexProcess( - proc: CodexProcess, - graceful: boolean, - timeoutMs = 8_000, -): Promise { - if (!proc.pid || proc.pid <= 0) return false; - if (!signalLinuxProcess(proc.pid, graceful ? "SIGTERM" : "SIGKILL", proc.createdAtMs)) { - return false; - } - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!readLinuxProcess(proc.pid)) return true; - await delay(100); - } - signalLinuxProcess(proc.pid, "SIGKILL", proc.createdAtMs); - await delay(250); - return !readLinuxProcess(proc.pid); -} - export async function isLoopbackPortFree(port: number): Promise { if (!Number.isInteger(port) || port < 1 || port > 65535) return false; return await new Promise((resolve) => { @@ -458,7 +114,7 @@ export function buildCodexLaunchCommand( }; } -async function launchCodexWithCdp( +export async function launchCodexWithCdp( port: number, executable: string, ): Promise { @@ -475,263 +131,21 @@ async function launchCodexWithCdp( child.unref(); } -export interface CodexStartupRepairMonitorOptions { +export interface EnsureCodexCdpOptions { preferredPort?: number; + /** Bounded fallback ports; override in deterministic tests. */ candidatePorts?: readonly number[]; - repairWindowMs?: number; + /** Include same-user process flags in discovery. Defaults to true on Windows. */ + scanProcesses?: boolean; + launch?: boolean; + restartIfBlind?: boolean; + timeoutMs?: number; log?: EnsureCodexLog; } -export interface CodexStartupRepairMonitor { - close: () => void; -} - -/** - * PowerShell emits one compact JSON record for each new Codex main process. - * Win32_ProcessStartTrace requires privileges that are unavailable on some - * desktop installs, so PID discovery uses cheap Get-Process snapshots and only - * performs a targeted CIM command-line read once per new PID. - */ -export function buildWindowsCodexProcessStartScript(parentPid: number): string { - const safeParentPid = Math.max(0, Math.trunc(parentPid)); - return [ - "$ErrorActionPreference='SilentlyContinue';", - "$seen=@{};", - "while($true){", - `if(-not (Get-Process -Id ${safeParentPid} -ErrorAction SilentlyContinue)){break};`, - "$current=@{};", - "$matches=Get-Process -Name ChatGPT,Codex -ErrorAction SilentlyContinue;", - "foreach($match in @($matches)){", - "$pidValue=[int]$match.Id;", - "$current[$pidValue]=$true;", - "if($seen.ContainsKey($pidValue)){continue};", - "$p=Get-CimInstance Win32_Process -Filter ('ProcessId = ' + $pidValue);", - "if($null -eq $p){continue};", - "$name=[string]$p.Name;", - "$cmd=[string]$p.CommandLine;", - "$path=[string]$p.ExecutablePath;", - "if(-not $cmd){continue};", - "if($cmd -match '\\s--type='){continue};", - "if($cmd -match '\\bapp-server\\b'){continue};", - "$created=0;", - "try{$created=([DateTimeOffset]$p.CreationDate).ToUnixTimeMilliseconds()}catch{};", - "if($created -le 0){$created=[DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()};", - "$seen[$pidValue]=$true;", - "$o=@{pid=$pidValue;name=$name;exe=$path;cmd=$cmd;created=$created};", - "($o | ConvertTo-Json -Compress -Depth 3);", - "}", - "foreach($known in @($seen.Keys)){if(-not $current.ContainsKey($known)){$seen.Remove($known)}};", - "Start-Sleep -Milliseconds 200;", - "}", - ].join(" "); -} - -async function repairFreshBlindCodexProcess( - observed: CodexProcess, - opts: CodexStartupRepairMonitorOptions, -): Promise { - const repairWindowMs = - opts.repairWindowMs ?? DEFAULT_CODEX_REPAIR_WINDOW_MS; - const live = await listCodexProcesses(); - const exact = live.find((proc) => proc.pid === observed.pid); - if (!exact) return; - if ( - classifyCodexStartupProcess(exact, Date.now(), repairWindowMs) !== - "repair-now" - ) { - return; - } - - // Never recycle a newly spawned secondary instance while an older primary - // is already serving the user. That path needs an explicit/manual repair. - if (live.some((proc) => proc.pid !== exact.pid)) { - opts.log?.warn( - `检测到 Codex 新进程 ${exact.pid},但已有主进程;跳过自动重启以保护正在运行的任务。`, - ); - return; - } - - const executable = - parseExecutableFromProcess(exact) || - (await findPackagedCodexExecutable()) || - findCodexExecutable(); - if (!executable) { - opts.log?.warn("检测到新的 Codex 进程,但无法确认可执行文件;跳过自动重启。"); - return; - } - - const preferred = opts.preferredPort ?? DEFAULT_CODEX_CDP_PORT; - const candidates = opts.candidatePorts ?? DEFAULT_CDP_CANDIDATE_PORTS; - const port = await pickAvailableCodexPort(preferred, candidates); - opts.log?.warn( - `Codex 新主进程 ${exact.pid} 未带 CDP 参数,立即使用 :${port} 修复性重启。`, - ); - - if (process.platform === "linux") { - // SIGTERM is Chromium's fast clean exit; wait so the relaunch is not - // swallowed by the still-running singleton. - if (!(await stopLinuxCodexProcess(exact, true, 5_000))) return; - } else try { - // Exact fresh PID only: no /T and no name-wide kill. At <10s there is no - // established user task to preserve, and /F avoids the old 8s stop delay. - await execFileAsync("taskkill.exe", ["/PID", String(exact.pid), "/F"], { - windowsHide: true, - timeout: 2_000, - }); - } catch { - return; - } - await delay(120); - await launchCodexWithCdp(port, executable); - opts.log?.info(`已立即带 --remote-debugging-port=${port} 重启 Codex`); -} - -/** - * Event-driven fast lane for the original Windows icon. It does not wait for - * CDP when the newly created main process has no debugging flag at all. - */ -export function startCodexStartupRepairMonitor( - opts: CodexStartupRepairMonitorOptions = {}, -): CodexStartupRepairMonitor { - if (process.platform === "linux") return startLinuxCodexStartupRepairMonitor(opts); - if (process.platform !== "win32") return { close: () => {} }; - - let closed = false; - let child: ChildProcess | null = null; - let restartTimer: ReturnType | null = null; - const controllerOptions: CodexStartupRepairControllerOptions = { - repair: (proc) => repairFreshBlindCodexProcess(proc, opts), - }; - if (opts.repairWindowMs !== undefined) { - controllerOptions.repairWindowMs = opts.repairWindowMs; - } - const controller = new CodexStartupRepairController(controllerOptions); - - const observe = (proc: CodexProcess) => { - void controller.observe(proc).catch((error) => { - opts.log?.warn( - `Codex 启动快速修复失败:${error instanceof Error ? error.message : String(error)}`, - ); - }); - }; - - const reconcile = () => { - void listCodexProcesses().then( - (processes) => processes.forEach(observe), - () => undefined, - ); - }; - - const startChild = () => { - if (closed) return; - const script = buildWindowsCodexProcessStartScript(process.pid); - const monitor = spawn( - "powershell.exe", - [ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - script, - ], - { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }, - ); - child = monitor; - let stderr = ""; - monitor.stderr?.on("data", (chunk) => { - if (stderr.length < 2_000) stderr += String(chunk); - }); - const lines = readline.createInterface({ input: monitor.stdout! }); - lines.on("line", (line) => { - let row: { - pid?: unknown; - name?: unknown; - exe?: unknown; - cmd?: unknown; - created?: unknown; - }; - try { - row = JSON.parse(line) as typeof row; - } catch { - return; - } - const commandLine = typeof row.cmd === "string" ? row.cmd : ""; - const name = typeof row.name === "string" ? row.name : "ChatGPT.exe"; - const executablePath = typeof row.exe === "string" ? row.exe : ""; - if (!looksLikeCodexMain(commandLine, name, executablePath)) return; - const flags = parseRemoteDebuggingFlags(commandLine); - observe({ - pid: Number(row.pid) || 0, - name, - executablePath, - commandLine, - port: flags.port, - createdAtMs: - Number.isFinite(Number(row.created)) && Number(row.created) > 0 - ? Number(row.created) - : null, - }); - }); - let finished = false; - const finish = (code: number | null, spawnError?: Error) => { - if (finished) return; - finished = true; - lines.close(); - if (child === monitor) child = null; - if (!closed) { - const detail = (spawnError?.message || stderr) - .trim() - .replace(/\s+/g, " ") - .slice(0, 500); - opts.log?.warn( - `Codex 进程启动监听器退出(${code ?? "unknown"})${detail ? `:${detail}` : ""},1 秒后恢复。`, - ); - restartTimer = setTimeout(startChild, 1_000); - } - }; - monitor.once("error", (error) => finish(null, error)); - monitor.once("exit", (code) => finish(code)); - reconcile(); - }; - - startChild(); - - return { - close: () => { - closed = true; - if (restartTimer) clearTimeout(restartTimer); - restartTimer = null; - child?.kill(); - child = null; - }, - }; -} - -/** - * Linux fast lane: sample /proc every 300 ms (no unprivileged process-start - * event exists) and feed the same controller as the Windows monitor. - */ -function startLinuxCodexStartupRepairMonitor( - opts: CodexStartupRepairMonitorOptions, -): CodexStartupRepairMonitor { - const controllerOptions: CodexStartupRepairControllerOptions = { - repair: (proc) => repairFreshBlindCodexProcess(proc, opts), - }; - if (opts.repairWindowMs !== undefined) { - controllerOptions.repairWindowMs = opts.repairWindowMs; - } - const controller = new CodexStartupRepairController(controllerOptions); - const timer = setInterval(() => { - for (const proc of scanLinuxCodexProcesses()) { - void controller.observe(proc).catch((error) => { - opts.log?.warn( - `Codex 启动快速修复失败:${error instanceof Error ? error.message : String(error)}`, - ); - }); - } - }, 300); - return { close: () => clearInterval(timer) }; +export interface EnsuredCodexCdp extends DiscoveredCdpEndpoint { + launched: boolean; + restarted: boolean; } async function waitForCodexCdp( @@ -760,16 +174,6 @@ async function waitForAnyCodexCdp( return null; } -function parseExecutableFromProcess(proc: CodexProcess | undefined): string | null { - if (!proc) return null; - if (proc.executablePath && fs.existsSync(proc.executablePath)) { - return proc.executablePath; - } - const quoted = proc.commandLine.match(/^"([^"]+\.exe)"/i); - if (quoted?.[1] && fs.existsSync(quoted[1])) return quoted[1]; - return null; -} - let ensureChain: Promise = Promise.resolve(); async function ensureCodexCdpUnqueued( diff --git a/packages/adapter-codex/src/processes.ts b/packages/adapter-codex/src/processes.ts new file mode 100644 index 0000000..f54627f --- /dev/null +++ b/packages/adapter-codex/src/processes.ts @@ -0,0 +1,261 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { promisify } from "node:util"; +import { listLinuxProcesses, readLinuxProcess, selectLinuxMainProcesses, signalLinuxProcess } from "@beauticode/core"; +import { parseRemoteDebuggingFlags } from "./host-discover.js"; + +const execFileAsync = promisify(execFile); + +export interface CodexProcess { + pid: number; + name: string; + executablePath: string; + commandLine: string; + port: number | null; + /** Main-process creation time. Null means it is unsafe to auto-restart. */ + createdAtMs: number | null; +} + +export function looksLikeCodexMain( + commandLine: string, + name: string, + exePath = "", +): boolean { + const hay = `${name} ${exePath} ${commandLine}`; + if (!/\b(ChatGPT|Codex)\.exe\b/i.test(hay) && !/\b(ChatGPT|Codex)\b/i.test(hay)) { + return false; + } + if (/\s--type=/.test(commandLine)) return false; + if (/\bapp-server\b/i.test(commandLine)) return false; + if (/\\resources\\/i.test(exePath) || /\\resources\\/i.test(commandLine)) { + return false; + } + // Linux/macOS bundle the Codex CLI app-server under resources/ as well. + if (/\/resources\//i.test(exePath)) return false; + return true; +} + +export function codexInstallCandidates( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string[] { + const home = env.HOME || env.USERPROFILE || os.homedir(); + if (platform === "linux") { + return [ + "/usr/lib/chatgpt/ChatGPT", + "/opt/ChatGPT/ChatGPT", + "/opt/chatgpt/ChatGPT", + "/usr/lib/codex/Codex", + "/opt/Codex/Codex", + path.posix.join(home, ".local", "share", "chatgpt", "ChatGPT"), + path.posix.join(home, ".local", "opt", "ChatGPT", "ChatGPT"), + ]; + } + if (platform !== "win32") return []; + const local = env.LOCALAPPDATA || path.join(home, "AppData", "Local"); + const pf = env.ProgramFiles || "C:\\Program Files"; + const pf86 = env["ProgramFiles(x86)"] || "C:\\Program Files (x86)"; + const relatives = [ + ["Programs", "ChatGPT", "ChatGPT.exe"], + ["Programs", "chatgpt", "ChatGPT.exe"], + ["Programs", "Codex", "Codex.exe"], + ["Programs", "codex", "Codex.exe"], + ["OpenAI", "ChatGPT", "ChatGPT.exe"], + ["OpenAI", "Codex", "Codex.exe"], + ]; + const out: string[] = []; + for (const root of [local, pf, pf86]) { + for (const rel of relatives) { + out.push(path.join(root, ...rel)); + } + } + return out; +} + +export function findCodexExecutable( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): string | null { + for (const candidate of codexInstallCandidates(env, platform)) { + try { + if (fs.existsSync(candidate)) return candidate; + } catch { + /* ignore */ + } + } + return null; +} + +export function parseAppxCodexInstallLocations(stdout: string): string[] { + return [...new Set( + String(stdout ?? "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => path.win32.isAbsolute(line)), + )]; +} + +export async function findPackagedCodexExecutable( + platform: NodeJS.Platform = process.platform, +): Promise { + if (platform !== "win32") return null; + try { + const { stdout } = await execFileAsync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + "Get-AppxPackage -Name OpenAI.Codex | Sort-Object Version -Descending | ForEach-Object { $_.InstallLocation }", + ], + { windowsHide: true, timeout: 12_000, maxBuffer: 1024 * 1024 }, + ); + for (const installLocation of parseAppxCodexInstallLocations(stdout)) { + const executable = path.win32.join(installLocation, "app", "ChatGPT.exe"); + if (fs.existsSync(executable)) return executable; + } + } catch { + /* package absent or AppX query unavailable */ + } + return null; +} + +async function scanWindowsCodexProcesses(): Promise { + if (process.platform !== "win32") return []; + const script = [ + "$ErrorActionPreference='SilentlyContinue';", + "Get-CimInstance Win32_Process | ForEach-Object {", + "$cmd=[string]$_.CommandLine;", + "$name=[string]$_.Name;", + "$path=[string]$_.ExecutablePath;", + "$created=0;", + "try{$created=([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds()}catch{};", + "if($name -notmatch '^(ChatGPT|Codex)\\.exe$'){return};", + "if($cmd -match '\\s--type='){return};", + "if($path -match '\\\\resources\\\\'){return};", + "$o=@{pid=$_.ProcessId;name=$name;exe=$path;cmd=$cmd;created=$created};", + "($o | ConvertTo-Json -Compress -Depth 3)", + "}", + ].join(" "); + try { + const { stdout } = await execFileAsync( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ], + { + windowsHide: true, + timeout: 12_000, + maxBuffer: 4 * 1024 * 1024, + }, + ); + const found: CodexProcess[] = []; + for (const line of String(stdout ?? "").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("{")) continue; + let row: { + pid?: unknown; + name?: unknown; + exe?: unknown; + cmd?: unknown; + created?: unknown; + }; + try { + row = JSON.parse(trimmed) as typeof row; + } catch { + continue; + } + const commandLine = typeof row.cmd === "string" ? row.cmd : ""; + const name = typeof row.name === "string" ? row.name : "ChatGPT.exe"; + const executablePath = typeof row.exe === "string" ? row.exe : ""; + if (!looksLikeCodexMain(commandLine, name, executablePath)) continue; + const flags = parseRemoteDebuggingFlags(commandLine); + found.push({ + pid: Number(row.pid) || 0, + name, + executablePath, + commandLine, + port: flags.port, + createdAtMs: + Number.isFinite(Number(row.created)) && Number(row.created) > 0 + ? Number(row.created) + : null, + }); + } + return found; + } catch { + return []; + } +} + +/** Resolve known desktop installations; the standalone CLI is also named codex. */ +function canonicalLinuxExecutable(executable: string): string { + try { return fs.realpathSync(executable); } + catch { return path.posix.normalize(executable); } +} + +/** /proc counterpart of the Win32_Process scan: trusted desktop binaries only. */ +export function scanLinuxCodexProcesses(): CodexProcess[] { + const desktopImages = new Set(codexInstallCandidates(process.env, "linux").map(canonicalLinuxExecutable)); + const rows = listLinuxProcesses((row) => row.executablePath !== "" && + desktopImages.has(canonicalLinuxExecutable(row.executablePath))); + return selectLinuxMainProcesses(rows, (row) => + looksLikeCodexMain(row.commandLine, path.posix.basename(row.executablePath), row.executablePath), + ).map((row) => ({ + pid: row.pid, + name: path.posix.basename(row.executablePath), + executablePath: row.executablePath, + commandLine: row.commandLine, + port: parseRemoteDebuggingFlags(row.commandLine).port, + createdAtMs: row.createdAtMs, + })); +} + +export async function listCodexProcesses(): Promise { + if (process.platform === "linux") return scanLinuxCodexProcesses(); + return scanWindowsCodexProcesses(); +} + +/** + * Stop one exact Linux main process (PID + creation time) and wait for it to + * exit: a relaunch while the old instance is alive would just hand its + * arguments to the singleton and drop the CDP flags. + */ +export async function stopLinuxCodexProcess( + proc: CodexProcess, + graceful: boolean, + timeoutMs = 8_000, +): Promise { + if (!proc.pid || proc.pid <= 0) return false; + if (!signalLinuxProcess(proc.pid, graceful ? "SIGTERM" : "SIGKILL", proc.createdAtMs)) { + return false; + } + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!readLinuxProcess(proc.pid)) return true; + await delay(100); + } + signalLinuxProcess(proc.pid, "SIGKILL", proc.createdAtMs); + await delay(250); + return !readLinuxProcess(proc.pid); +} + +export function parseExecutableFromProcess(proc: CodexProcess | undefined): string | null { + if (!proc) return null; + if (proc.executablePath && fs.existsSync(proc.executablePath)) { + return proc.executablePath; + } + const quoted = proc.commandLine.match(/^"([^"]+\.exe)"/i); + if (quoted?.[1] && fs.existsSync(quoted[1])) return quoted[1]; + return null; +} diff --git a/packages/adapter-codex/src/startup-policy.ts b/packages/adapter-codex/src/startup-policy.ts new file mode 100644 index 0000000..a8a77b4 --- /dev/null +++ b/packages/adapter-codex/src/startup-policy.ts @@ -0,0 +1,80 @@ +import type { CodexProcess } from "./processes.js"; + +export const DEFAULT_CODEX_REPAIR_WINDOW_MS = 10_000; + +export type CodexStartupProcessDecision = + | "repair-now" + | "wait-for-cdp" + | "ignore-stale"; + +/** + * Decide from immutable process evidence only. A missing CDP flag can never + * become available later in the same Chromium main process, so a fresh blind + * process is repaired immediately instead of waiting for an endpoint timeout. + */ +export function classifyCodexStartupProcess( + proc: CodexProcess, + nowMs = Date.now(), + repairWindowMs = DEFAULT_CODEX_REPAIR_WINDOW_MS, +): CodexStartupProcessDecision { + if (proc.port != null) return "wait-for-cdp"; + if (proc.createdAtMs == null || !Number.isFinite(proc.createdAtMs)) { + return "ignore-stale"; + } + const ageMs = nowMs - proc.createdAtMs; + return ageMs >= 0 && ageMs < repairWindowMs + ? "repair-now" + : "ignore-stale"; +} + +export interface CodexStartupRepairControllerOptions { + now?: () => number; + repairWindowMs?: number; + repair: (proc: CodexProcess) => Promise; +} + +export type CodexStartupRepairResult = + | "repaired" + | "already-handled" + | "wait-for-cdp" + | "ignore-stale"; + +/** Serialize and deduplicate process-start events by PID + creation time. */ +export class CodexStartupRepairController { + private readonly seen = new Set(); + private chain: Promise = Promise.resolve(); + private readonly now: () => number; + private readonly repairWindowMs: number; + private readonly repair: (proc: CodexProcess) => Promise; + + constructor(opts: CodexStartupRepairControllerOptions) { + this.now = opts.now ?? Date.now; + this.repairWindowMs = + opts.repairWindowMs ?? DEFAULT_CODEX_REPAIR_WINDOW_MS; + this.repair = opts.repair; + } + + observe(proc: CodexProcess): Promise { + const decision = classifyCodexStartupProcess( + proc, + this.now(), + this.repairWindowMs, + ); + if (decision !== "repair-now") return Promise.resolve(decision); + + const generation = `${proc.pid}:${proc.createdAtMs}`; + if (this.seen.has(generation)) return Promise.resolve("already-handled"); + this.seen.add(generation); + + const run = async (): Promise => { + await this.repair(proc); + return "repaired"; + }; + const next = this.chain.then(run, run); + this.chain = next.then( + () => undefined, + () => undefined, + ); + return next; + } +} diff --git a/packages/adapter-codex/src/startup-repair.ts b/packages/adapter-codex/src/startup-repair.ts new file mode 100644 index 0000000..68b1476 --- /dev/null +++ b/packages/adapter-codex/src/startup-repair.ts @@ -0,0 +1,283 @@ +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import readline from "node:readline"; +import { setTimeout as delay } from "node:timers/promises"; +import { promisify } from "node:util"; +import { DEFAULT_CDP_CANDIDATE_PORTS, parseRemoteDebuggingFlags } from "./host-discover.js"; +import { DEFAULT_CODEX_CDP_PORT, launchCodexWithCdp, pickAvailableCodexPort, type EnsureCodexLog } from "./launch.js"; +import { + findCodexExecutable, + findPackagedCodexExecutable, + listCodexProcesses, + looksLikeCodexMain, + parseExecutableFromProcess, + scanLinuxCodexProcesses, + stopLinuxCodexProcess, + type CodexProcess, +} from "./processes.js"; +import { + DEFAULT_CODEX_REPAIR_WINDOW_MS, + classifyCodexStartupProcess, + CodexStartupRepairController, + type CodexStartupRepairControllerOptions, +} from "./startup-policy.js"; + +const execFileAsync = promisify(execFile); + +export interface CodexStartupRepairMonitorOptions { + preferredPort?: number; + candidatePorts?: readonly number[]; + repairWindowMs?: number; + log?: EnsureCodexLog; +} + +export interface CodexStartupRepairMonitor { + close: () => void; +} + +/** + * PowerShell emits one compact JSON record for each new Codex main process. + * Win32_ProcessStartTrace requires privileges that are unavailable on some + * desktop installs, so PID discovery uses cheap Get-Process snapshots and only + * performs a targeted CIM command-line read once per new PID. + */ +export function buildWindowsCodexProcessStartScript(parentPid: number): string { + const safeParentPid = Math.max(0, Math.trunc(parentPid)); + return [ + "$ErrorActionPreference='SilentlyContinue';", + "$seen=@{};", + "while($true){", + `if(-not (Get-Process -Id ${safeParentPid} -ErrorAction SilentlyContinue)){break};`, + "$current=@{};", + "$matches=Get-Process -Name ChatGPT,Codex -ErrorAction SilentlyContinue;", + "foreach($match in @($matches)){", + "$pidValue=[int]$match.Id;", + "$current[$pidValue]=$true;", + "if($seen.ContainsKey($pidValue)){continue};", + "$p=Get-CimInstance Win32_Process -Filter ('ProcessId = ' + $pidValue);", + "if($null -eq $p){continue};", + "$name=[string]$p.Name;", + "$cmd=[string]$p.CommandLine;", + "$path=[string]$p.ExecutablePath;", + "if(-not $cmd){continue};", + "if($cmd -match '\\s--type='){continue};", + "if($cmd -match '\\bapp-server\\b'){continue};", + "$created=0;", + "try{$created=([DateTimeOffset]$p.CreationDate).ToUnixTimeMilliseconds()}catch{};", + "if($created -le 0){$created=[DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()};", + "$seen[$pidValue]=$true;", + "$o=@{pid=$pidValue;name=$name;exe=$path;cmd=$cmd;created=$created};", + "($o | ConvertTo-Json -Compress -Depth 3);", + "}", + "foreach($known in @($seen.Keys)){if(-not $current.ContainsKey($known)){$seen.Remove($known)}};", + "Start-Sleep -Milliseconds 200;", + "}", + ].join(" "); +} + +async function repairFreshBlindCodexProcess( + observed: CodexProcess, + opts: CodexStartupRepairMonitorOptions, +): Promise { + const repairWindowMs = + opts.repairWindowMs ?? DEFAULT_CODEX_REPAIR_WINDOW_MS; + const live = await listCodexProcesses(); + const exact = live.find((proc) => proc.pid === observed.pid); + if (!exact) return; + if ( + classifyCodexStartupProcess(exact, Date.now(), repairWindowMs) !== + "repair-now" + ) { + return; + } + + // Never recycle a newly spawned secondary instance while an older primary + // is already serving the user. That path needs an explicit/manual repair. + if (live.some((proc) => proc.pid !== exact.pid)) { + opts.log?.warn( + `检测到 Codex 新进程 ${exact.pid},但已有主进程;跳过自动重启以保护正在运行的任务。`, + ); + return; + } + + const executable = + parseExecutableFromProcess(exact) || + (await findPackagedCodexExecutable()) || + findCodexExecutable(); + if (!executable) { + opts.log?.warn("检测到新的 Codex 进程,但无法确认可执行文件;跳过自动重启。"); + return; + } + + const preferred = opts.preferredPort ?? DEFAULT_CODEX_CDP_PORT; + const candidates = opts.candidatePorts ?? DEFAULT_CDP_CANDIDATE_PORTS; + const port = await pickAvailableCodexPort(preferred, candidates); + opts.log?.warn( + `Codex 新主进程 ${exact.pid} 未带 CDP 参数,立即使用 :${port} 修复性重启。`, + ); + + if (process.platform === "linux") { + // SIGTERM is Chromium's fast clean exit; wait so the relaunch is not + // swallowed by the still-running singleton. + if (!(await stopLinuxCodexProcess(exact, true, 5_000))) return; + } else try { + // Exact fresh PID only: no /T and no name-wide kill. At <10s there is no + // established user task to preserve, and /F avoids the old 8s stop delay. + await execFileAsync("taskkill.exe", ["/PID", String(exact.pid), "/F"], { + windowsHide: true, + timeout: 2_000, + }); + } catch { + return; + } + await delay(120); + await launchCodexWithCdp(port, executable); + opts.log?.info(`已立即带 --remote-debugging-port=${port} 重启 Codex`); +} + +/** + * Event-driven fast lane for the original Windows icon. It does not wait for + * CDP when the newly created main process has no debugging flag at all. + */ +export function startCodexStartupRepairMonitor( + opts: CodexStartupRepairMonitorOptions = {}, +): CodexStartupRepairMonitor { + if (process.platform === "linux") return startLinuxCodexStartupRepairMonitor(opts); + if (process.platform !== "win32") return { close: () => {} }; + + let closed = false; + let child: ChildProcess | null = null; + let restartTimer: ReturnType | null = null; + const controllerOptions: CodexStartupRepairControllerOptions = { + repair: (proc) => repairFreshBlindCodexProcess(proc, opts), + }; + if (opts.repairWindowMs !== undefined) { + controllerOptions.repairWindowMs = opts.repairWindowMs; + } + const controller = new CodexStartupRepairController(controllerOptions); + + const observe = (proc: CodexProcess) => { + void controller.observe(proc).catch((error) => { + opts.log?.warn( + `Codex 启动快速修复失败:${error instanceof Error ? error.message : String(error)}`, + ); + }); + }; + + const reconcile = () => { + void listCodexProcesses().then( + (processes) => processes.forEach(observe), + () => undefined, + ); + }; + + const startChild = () => { + if (closed) return; + const script = buildWindowsCodexProcessStartScript(process.pid); + const monitor = spawn( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ], + { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }, + ); + child = monitor; + let stderr = ""; + monitor.stderr?.on("data", (chunk) => { + if (stderr.length < 2_000) stderr += String(chunk); + }); + const lines = readline.createInterface({ input: monitor.stdout! }); + lines.on("line", (line) => { + let row: { + pid?: unknown; + name?: unknown; + exe?: unknown; + cmd?: unknown; + created?: unknown; + }; + try { + row = JSON.parse(line) as typeof row; + } catch { + return; + } + const commandLine = typeof row.cmd === "string" ? row.cmd : ""; + const name = typeof row.name === "string" ? row.name : "ChatGPT.exe"; + const executablePath = typeof row.exe === "string" ? row.exe : ""; + if (!looksLikeCodexMain(commandLine, name, executablePath)) return; + const flags = parseRemoteDebuggingFlags(commandLine); + observe({ + pid: Number(row.pid) || 0, + name, + executablePath, + commandLine, + port: flags.port, + createdAtMs: + Number.isFinite(Number(row.created)) && Number(row.created) > 0 + ? Number(row.created) + : null, + }); + }); + let finished = false; + const finish = (code: number | null, spawnError?: Error) => { + if (finished) return; + finished = true; + lines.close(); + if (child === monitor) child = null; + if (!closed) { + const detail = (spawnError?.message || stderr) + .trim() + .replace(/\s+/g, " ") + .slice(0, 500); + opts.log?.warn( + `Codex 进程启动监听器退出(${code ?? "unknown"})${detail ? `:${detail}` : ""},1 秒后恢复。`, + ); + restartTimer = setTimeout(startChild, 1_000); + } + }; + monitor.once("error", (error) => finish(null, error)); + monitor.once("exit", (code) => finish(code)); + reconcile(); + }; + + startChild(); + + return { + close: () => { + closed = true; + if (restartTimer) clearTimeout(restartTimer); + restartTimer = null; + child?.kill(); + child = null; + }, + }; +} + +/** + * Linux fast lane: sample /proc every 300 ms (no unprivileged process-start + * event exists) and feed the same controller as the Windows monitor. + */ +function startLinuxCodexStartupRepairMonitor( + opts: CodexStartupRepairMonitorOptions, +): CodexStartupRepairMonitor { + const controllerOptions: CodexStartupRepairControllerOptions = { + repair: (proc) => repairFreshBlindCodexProcess(proc, opts), + }; + if (opts.repairWindowMs !== undefined) { + controllerOptions.repairWindowMs = opts.repairWindowMs; + } + const controller = new CodexStartupRepairController(controllerOptions); + const timer = setInterval(() => { + for (const proc of scanLinuxCodexProcesses()) { + void controller.observe(proc).catch((error) => { + opts.log?.warn( + `Codex 启动快速修复失败:${error instanceof Error ? error.message : String(error)}`, + ); + }); + } + }, 300); + return { close: () => clearInterval(timer) }; +} diff --git a/packages/adapter-codex/test/adapter-session.test.js b/packages/adapter-codex/test/adapter-session.test.js index 5666887..be1d39e 100644 --- a/packages/adapter-codex/test/adapter-session.test.js +++ b/packages/adapter-codex/test/adapter-session.test.js @@ -33,12 +33,18 @@ import http from "node:http"; import net from "node:net"; import { buildCodexLaunchCommand, +} from "../dist/launch.js"; +import { buildWindowsCodexProcessStartScript, +} from "../dist/startup-repair.js"; +import { CodexStartupRepairController, classifyCodexStartupProcess, +} from "../dist/startup-policy.js"; +import { looksLikeCodexMain, parseAppxCodexInstallLocations, -} from "../dist/launch.js"; +} from "../dist/processes.js"; test("BeautiSession reapply reconnects after CDP browser identity changes", async () => { const mock = await startMockCdp(); diff --git a/packages/adapter-codex/test/adapter-startup.test.js b/packages/adapter-codex/test/adapter-startup.test.js index 3fd57f4..3a4c4f8 100644 --- a/packages/adapter-codex/test/adapter-startup.test.js +++ b/packages/adapter-codex/test/adapter-startup.test.js @@ -33,12 +33,18 @@ import http from "node:http"; import net from "node:net"; import { buildCodexLaunchCommand, +} from "../dist/launch.js"; +import { buildWindowsCodexProcessStartScript, +} from "../dist/startup-repair.js"; +import { CodexStartupRepairController, classifyCodexStartupProcess, +} from "../dist/startup-policy.js"; +import { looksLikeCodexMain, parseAppxCodexInstallLocations, -} from "../dist/launch.js"; +} from "../dist/processes.js"; test("CodexHostApplier verify fails on generation mismatch", async () => { const mock = await startMockCdp(); diff --git a/packages/adapter-codex/test/adapter.test.js b/packages/adapter-codex/test/adapter.test.js index a56d371..6978d5b 100644 --- a/packages/adapter-codex/test/adapter.test.js +++ b/packages/adapter-codex/test/adapter.test.js @@ -33,12 +33,18 @@ import http from "node:http"; import net from "node:net"; import { buildCodexLaunchCommand, +} from "../dist/launch.js"; +import { buildWindowsCodexProcessStartScript, +} from "../dist/startup-repair.js"; +import { CodexStartupRepairController, classifyCodexStartupProcess, +} from "../dist/startup-policy.js"; +import { looksLikeCodexMain, parseAppxCodexInstallLocations, -} from "../dist/launch.js"; +} from "../dist/processes.js"; test("slimCodexCdpPayload drops huge video posters and blob dataUrls", () => { const huge = `data:image/png;base64,${"A".repeat(MAX_CDP_DATA_URL_CHARS + 10)}`; diff --git a/packages/adapter-codex/test/codex-autostart.test.js b/packages/adapter-codex/test/codex-autostart.test.js new file mode 100644 index 0000000..df6ef7d --- /dev/null +++ b/packages/adapter-codex/test/codex-autostart.test.js @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createCodexAutostartAdapter, isOwnedCodexAutostart } from "../../../integrations/codex-desktop/task-wiring.mjs"; +import { renderXdgDesktopEntry } from "../../../scripts/portable-runtime.mjs"; + +for (const user of ["alice", "100%user", "$user", 'a"b', "a\\b", "中文 user"]) { + test(`Codex startup can be reinstalled and removed under ${JSON.stringify(user)}`, async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "bc-autostart-")); + const file = path.join(dir, "codex.desktop"); + const launcher = `/home/${user}/.local/share/beautiCode/codex-plugin/launcher.sh`; + const adapter = createCodexAutostartAdapter(launcher, { file }); + const content = renderXdgDesktopEntry({ name: "Codex", launcher }); + try { + adapter.write(content); + assert.equal(adapter.isOwned(), true); + adapter.write(content); + assert.equal(await fs.readFile(file, "utf8"), content); + adapter.removeOwned(); + await assert.rejects(fs.access(file), { code: "ENOENT" }); + adapter.removeOwned(); + } finally { await fs.rm(dir, { recursive: true, force: true }); } + }); +} + +test("a launcher path mentioned as an argument does not prove startup ownership", () => { + const launcher = "/home/alice/beautiCode/launcher.sh"; + const content = `[Desktop Entry]\nExec=/bin/other-program "${launcher}"\nX-BeautiCode-Managed=true\n`; + assert.equal(isOwnedCodexAutostart(content, launcher), false); +}); diff --git a/packages/adapter-codex/test/codex-lifecycle.test.js b/packages/adapter-codex/test/codex-lifecycle.test.js index 403ee00..96c4bc3 100644 --- a/packages/adapter-codex/test/codex-lifecycle.test.js +++ b/packages/adapter-codex/test/codex-lifecycle.test.js @@ -11,7 +11,9 @@ import { recoverStaleCodexGuardianLock, } from "../../../integrations/codex-desktop/lifecycle.mjs"; import { acquireCodexGuardianLease, startCodexHelperWatchdog } from "../../../integrations/codex-desktop/codex-watchdog.mjs"; -import { classifyCodexStartupProcess } from "../dist/launch.js"; +import { + classifyCodexStartupProcess, +} from "../dist/startup-policy.js"; const HELPER_HOME = path.resolve(os.tmpdir(), "beautiCode", "codex-plugin"); diff --git a/packages/adapter-codex/test/linux-processes.test.js b/packages/adapter-codex/test/linux-processes.test.js new file mode 100644 index 0000000..1e9263c --- /dev/null +++ b/packages/adapter-codex/test/linux-processes.test.js @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { test } from "node:test"; +import { + scanLinuxCodexProcesses, +} from "../dist/processes.js"; + +// Exercise the real /proc reader and host selection without touching live PIDs. +function procSnapshot(t, rows) { + const byPid = new Map(rows.map((row) => [String(row.pid), row])); + t.mock.method(fs, "readdirSync", (root) => { + assert.equal(root, "/proc"); + return [...byPid.keys()]; + }); + t.mock.method(fs, "readFileSync", (file) => { + const normalized = String(file).replaceAll("\\", "/"); + if (normalized === "/proc/stat") return "btime 100000\n"; + const [, pid, kind] = /^\/proc\/(\d+)\/(stat|cmdline)$/.exec(normalized) || []; + const row = byPid.get(pid); + assert.ok(row, `unexpected /proc read: ${file}`); + if (kind === "cmdline") return `${row.argv.join("\0")}\0`; + const fields = Array(20).fill("0"); + fields[0] = "S"; + fields[1] = String(row.parentPid || 1); + fields[19] = "100"; + return `${pid} (${path.posix.basename(row.exe)}) ${fields.join(" ")}`; + }); + t.mock.method(fs, "readlinkSync", (file) => { + const pid = /\/proc\/(\d+)\/exe$/.exec(String(file).replaceAll("\\", "/"))?.[1]; + assert.ok(byPid.has(pid)); + return byPid.get(pid).exe; + }); +} + +const cli = { + pid: 101, + exe: "/home/alice/.npm/lib/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex", + argv: ["codex", "resume"], +}; +const desktop = { pid: 102, exe: "/usr/lib/chatgpt/ChatGPT", argv: ["/usr/lib/chatgpt/ChatGPT"] }; + +test("Linux discovery leaves a standalone Codex CLI outside desktop repair", (t) => { + procSnapshot(t, [cli]); + assert.deepEqual(scanLinuxCodexProcesses(), []); +}); + +test("Linux discovery keeps the desktop when a standalone CLI is also running", (t) => { + procSnapshot(t, [cli, desktop]); + assert.deepEqual(scanLinuxCodexProcesses().map((row) => row.pid), [102]); +}); + +test("Linux discovery rejects unknown same-name binaries and desktop helpers", (t) => { + procSnapshot(t, [desktop, + { pid: 103, exe: "/tmp/Codex", argv: ["/tmp/Codex"] }, + { pid: 104, parentPid: 102, exe: desktop.exe, argv: [desktop.exe, "--type=renderer"] }, + { pid: 105, parentPid: 102, exe: desktop.exe, argv: [desktop.exe, "worker.js"] }, + ]); + assert.deepEqual(scanLinuxCodexProcesses().map((row) => row.pid), [102]); +}); diff --git a/packages/beauticode-desktop/test/xdg-autostart.test.mjs b/packages/beauticode-desktop/test/xdg-autostart.test.mjs new file mode 100644 index 0000000..d09566f --- /dev/null +++ b/packages/beauticode-desktop/test/xdg-autostart.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { renderXdgDesktopEntry } from "../../../scripts/portable-runtime.mjs"; +import { withDesktopWiringRollback } from "../../../scripts/desktop-runner-identity.mjs"; + +const wiring = await import("../../../scripts/xdg-autostart.mjs").catch(() => ({})); + +test("Cursor startup preserves foreign entries on both install and uninstall", async () => { + assert.equal(typeof wiring.createXdgAutostartAdapter, "function"); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "bc-cursor-autostart-")); + const file = path.join(dir, "cursor.desktop"); + const launcher = "/home/alice/beautiCode/runtime/cursor/launcher.sh"; + const args = ["--host", "cursor", "--watchdog", "--pid-file", "/home/alice/runner.pid"]; + const adapter = wiring.createXdgAutostartAdapter(launcher, { file, args }); + const owned = renderXdgDesktopEntry({ name: "Cursor", launcher, args }); + const foreignEntries = [ + "[Desktop Entry]\nExec=/bin/other-program\n", + owned.replace(launcher, "/home/other/beautiCode/runtime/cursor/launcher.sh"), + owned.replace('"--host" "cursor"', '"--host" "other"'), + ]; + try { + for (const foreign of foreignEntries) { + await fs.writeFile(file, foreign); + assert.throws(() => adapter.write(owned), /not owned/); + assert.throws(() => adapter.removeOwned(), /not owned/); + assert.equal(await fs.readFile(file, "utf8"), foreign); + } + await fs.unlink(file); + adapter.write(owned); + adapter.write(owned); + assert.equal(adapter.isOwned(), true); + adapter.removeOwned(); + await assert.rejects(fs.access(file), { code: "ENOENT" }); + } finally { await fs.rm(dir, { recursive: true, force: true }); } +}); + +for (const command of ["install", "uninstall"]) { +test(`Cursor ${command} refuses foreign autostart before touching the guardian`, { skip: process.platform !== "linux" }, async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "bc-cursor-uninstall-")); + const config = path.join(dir, "config"); + const file = path.join(config, "autostart", "beauticode-cursor-runner.desktop"); + const foreign = "[Desktop Entry]\nExec=/bin/other-program\n"; + try { + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, foreign); + const result = spawnSync(process.execPath, ["scripts/desktop-cdp-setup.mjs", "--host", "cursor", command], { + cwd: new URL("../../../", import.meta.url), encoding: "utf8", timeout: 10_000, + env: { ...process.env, HOME: dir, XDG_CONFIG_HOME: config, XDG_DATA_HOME: path.join(dir, "data"), LOCALAPPDATA: "" }, + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /not owned/); + assert.equal(await fs.readFile(file, "utf8"), foreign); + await assert.rejects(fs.access(path.join(dir, "data")), { code: "ENOENT" }); + } finally { await fs.rm(dir, { recursive: true, force: true }); } +}); +} + +test("failed activation rollback keeps a foreign replacement of its startup file", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "bc-autostart-rollback-")); + const startupVbs = path.join(dir, "cursor.desktop"); + const pointerPath = path.join(dir, "current.json"); + const launcher = "/home/alice/beautiCode/cursor/launcher.sh"; + const owned = renderXdgDesktopEntry({ name: "Cursor", launcher }); + const adapter = wiring.createXdgAutostartAdapter(launcher, { file: startupVbs }); + const foreign = "[Desktop Entry]\nExec=/bin/other-program\n"; + try { + await fs.writeFile(pointerPath, "old pointer"); + await assert.rejects(withDesktopWiringRollback({ + pointerPath, startupVbs, + restoreStartup: (previous) => adapter.restore(previous), + run: async () => { + adapter.write(owned); + await fs.writeFile(pointerPath, "new pointer"); + await fs.writeFile(startupVbs, foreign); + throw new Error("guardian-not-ready"); + }, + }), /not owned/); + assert.equal(await fs.readFile(startupVbs, "utf8"), foreign); + assert.equal(await fs.readFile(pointerPath, "utf8"), "old pointer"); + } finally { await fs.rm(dir, { recursive: true, force: true }); } +}); diff --git a/scripts/desktop-cdp-setup.mjs b/scripts/desktop-cdp-setup.mjs index e12a51e..496ad7e 100644 --- a/scripts/desktop-cdp-setup.mjs +++ b/scripts/desktop-cdp-setup.mjs @@ -5,6 +5,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; +import { createXdgAutostartAdapter } from './xdg-autostart.mjs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { writeVerifiedExecutableRecord } from '../packages/beauticode-desktop/src/windows-host-install.mjs'; import { @@ -61,10 +62,15 @@ const startupVbs = IS_LINUX const legacyStartupVbs = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), `beauticode-${args.host}-runner.vbs`); const runnerArgs = ['--host', args.host, '--watchdog', '--pid-file', pidFile]; const runnerContext = { host: args.host, stableHostRoot, sourceRunner: RUNNER, pidFile }; +const linuxAutostart = IS_LINUX + ? createXdgAutostartAdapter(path.join(stableHostRoot, 'launcher.sh'), { file: startupVbs, args: runnerArgs }) + : null; const DESKTOP_RUNTIME_PREFIXES = [ 'scripts/desktop-cdp-runner.mjs', 'scripts/quick-launch.mjs', + 'scripts/xdg-autostart.mjs', + 'scripts/portable-runtime.mjs', 'packages/core/dist/', 'packages/adapter-desktop-cdp/dist/', `packages/adapter-${args.host}/dist/`, @@ -248,15 +254,18 @@ if (args.command === 'status') { } if (args.command === 'uninstall') { + linuxAutostart?.assertAbsentOrOwned(); stopRunner(); cleanPage(); - try { fs.unlinkSync(startupVbs); } catch {} + if (linuxAutostart) linuxAutostart.removeOwned(); + else { try { fs.unlinkSync(startupVbs); } catch {} } if (!IS_LINUX) { try { fs.unlinkSync(legacyStartupVbs); } catch {} } process.stdout.write(`${args.host} 背景守护已卸载;独立主题状态保留在 ${dataDir}\n`); process.exit(0); } if (args.command === 'install') { +linuxAutostart?.assertAbsentOrOwned(); fs.mkdirSync(dataDir, { recursive: true }); if (fs.existsSync(path.join(REPO, 'package-lock.json'))) { const compiler = path.join(REPO, 'node_modules', 'typescript', 'bin', 'tsc'); @@ -282,19 +291,22 @@ try { pointerPath: stableRuntime.pointerPath, startupVbs, executableRecord: path.join(dataDir, 'executable.json'), + restoreStartup: linuxAutostart ? (previous) => linuxAutostart.restore(previous) : undefined, run: async () => { stopRunner(); await activateStableRuntime(stableRuntime, { startupLogPath: logFile }); if (!IS_LINUX) await writeVerifiedExecutableRecord(args.host, dataDir); fs.mkdirSync(path.dirname(startupVbs), { recursive: true }); - await writeTextAtomic(startupVbs, IS_LINUX - ? renderXdgDesktopEntry({ + if (linuxAutostart) { + linuxAutostart.write(renderXdgDesktopEntry({ name: `beautiCode ${args.host} background guardian`, comment: `Re-applies beautiCode backgrounds to ${args.host} through loopback CDP`, launcher: stableRuntime.launcher, args: runnerArgs, - }) - : renderWindowsStartupVbs({ launcher: stableRuntime.launcher, args: runnerArgs })); + })); + } else { + await writeTextAtomic(startupVbs, renderWindowsStartupVbs({ launcher: stableRuntime.launcher, args: runnerArgs })); + } try { return await startDesktopGuardian({ context: runnerContext, diff --git a/scripts/desktop-runner-identity.mjs b/scripts/desktop-runner-identity.mjs index 2ba3076..2f5bc54 100644 --- a/scripts/desktop-runner-identity.mjs +++ b/scripts/desktop-runner-identity.mjs @@ -53,7 +53,7 @@ async function readOptional(file) { catch (error) { if (error?.code === "ENOENT") return null; throw error; } } -export async function withDesktopWiringRollback({ pointerPath, startupVbs, executableRecord, run }) { +export async function withDesktopWiringRollback({ pointerPath, startupVbs, executableRecord, restoreStartup, run }) { const beforePointer = await readOptional(pointerPath); const beforeStartup = await readOptional(startupVbs); const beforeExecutable = executableRecord ? await readOptional(executableRecord) : null; @@ -61,7 +61,8 @@ export async function withDesktopWiringRollback({ pointerPath, startupVbs, execu catch (error) { if (beforePointer === null) await fsp.rm(pointerPath, { force: true }); else await writeTextAtomic(pointerPath, beforePointer); - if (beforeStartup === null) await fsp.rm(startupVbs, { force: true }); + if (restoreStartup) await restoreStartup(beforeStartup); + else if (beforeStartup === null) await fsp.rm(startupVbs, { force: true }); else await writeTextAtomic(startupVbs, beforeStartup); if (executableRecord) { if (beforeExecutable === null) await fsp.rm(executableRecord, { force: true }); diff --git a/scripts/pack-codex-plugin.mjs b/scripts/pack-codex-plugin.mjs index 2c2db2b..67a7def 100644 --- a/scripts/pack-codex-plugin.mjs +++ b/scripts/pack-codex-plugin.mjs @@ -72,10 +72,9 @@ export async function stageCodexPlugin(destRoot = defaultStageDir(), opts = {}) await fsp.mkdir(path.dirname(dest), { recursive: true }); await fsp.copyFile(source, dest); } - await fsp.copyFile( - path.join(repoRoot, "scripts", "portable-runtime.mjs"), - path.join(destRoot, "portable-runtime.mjs"), - ); + for (const name of ["portable-runtime.mjs", "xdg-autostart.mjs"]) { + await fsp.copyFile(path.join(repoRoot, "scripts", name), path.join(destRoot, name)); + } const vendorAdapter = path.join(destRoot, "vendor", "adapter-codex"); await copyTree(path.join(repoRoot, "packages", "core", "dist"), path.join(destRoot, "vendor", "core")); await copyTree(path.join(repoRoot, "packages", "adapter-codex", "dist"), vendorAdapter); diff --git a/scripts/pack-desktop-aggregate.mjs b/scripts/pack-desktop-aggregate.mjs index 25d5142..b5a679d 100644 --- a/scripts/pack-desktop-aggregate.mjs +++ b/scripts/pack-desktop-aggregate.mjs @@ -127,6 +127,7 @@ async function copyDesktopRuntime(destRoot) { path.join(desktop, "scripts", "desktop-runner-identity.mjs"), ); await copyText(path.join(repoRoot, "scripts", "guardian-process.mjs"), path.join(desktop, "scripts", "guardian-process.mjs")); + await copyText(path.join(repoRoot, "scripts", "xdg-autostart.mjs"), path.join(desktop, "scripts", "xdg-autostart.mjs")); await copyText( path.join(repoRoot, "scripts", "portable-runtime.mjs"), path.join(desktop, "scripts", "portable-runtime.mjs"), diff --git a/scripts/xdg-autostart.mjs b/scripts/xdg-autostart.mjs new file mode 100644 index 0000000..189ace9 --- /dev/null +++ b/scripts/xdg-autostart.mjs @@ -0,0 +1,69 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { desktopExecQuote } from "./portable-runtime.mjs"; + +/** Only our exact command in the Desktop Entry group establishes ownership. */ +export function isOwnedXdgAutostart(content, launcher, args = []) { + const lines = String(content ?? "").split(/\r?\n/); + const start = lines.indexOf("[Desktop Entry]"); + if (start < 0) return false; + const group = []; + for (const line of lines.slice(start + 1)) { + if (line.startsWith("[")) break; + group.push(line); + } + const exec = `Exec=${["/bin/sh", launcher, ...args].map(desktopExecQuote).join(" ")}`; + const commands = group.filter((line) => line.startsWith("Exec=")); + const markers = group.filter((line) => line.startsWith("X-BeautiCode-Managed=")); + return commands.length === 1 && commands[0] === exec && + markers.length === 1 && markers[0] === "X-BeautiCode-Managed=true"; +} + +/** User-owned autostart file; foreign files are conflicts, never replacement targets. */ +export function createXdgAutostartAdapter(launcher, { file, args = [] } = {}) { + if (!path.posix.isAbsolute(launcher)) throw new Error("invalid autostart launcher path"); + if (!file) throw new Error("autostart file is required"); + const read = () => { + try { return fs.readFileSync(file, "utf8"); } + catch (error) { if (error?.code === "ENOENT") return null; throw error; } + }; + const assertAbsentOrOwned = () => { + const current = read(); + if (current !== null && !isOwnedXdgAutostart(current, launcher, args)) { + throw new Error("autostart entry is not owned by beautiCode"); + } + return current; + }; + const adapter = { + file, + read, + assertAbsentOrOwned, + isOwned: () => { const current = read(); return current !== null && isOwnedXdgAutostart(current, launcher, args); }, + write(content) { + if (!isOwnedXdgAutostart(content, launcher, args)) throw new Error("invalid owned autostart content"); + const before = assertAbsentOrOwned(); + if (before === content) return; + fs.mkdirSync(path.dirname(file), { recursive: true }); + const temporary = `${file}.tmp-${randomUUID()}`; + fs.writeFileSync(temporary, content, { encoding: "utf8", flag: "wx", mode: 0o644 }); + try { + if (before === null) { + // Publish without replacing a foreign file created after preflight. + fs.linkSync(temporary, file); + } else { + if (assertAbsentOrOwned() !== before) throw new Error("autostart entry changed while writing"); + fs.renameSync(temporary, file); + } + } finally { fs.rmSync(temporary, { force: true }); } + }, + removeOwned() { + if (assertAbsentOrOwned() !== null) fs.unlinkSync(file); + }, + restore(previous) { + if (previous === null) adapter.removeOwned(); + else adapter.write(previous); + }, + }; + return adapter; +}