diff --git a/src/openclaw/reply-listener-log.ts b/src/openclaw/reply-listener-log.ts new file mode 100644 index 000000000..58a536d6c --- /dev/null +++ b/src/openclaw/reply-listener-log.ts @@ -0,0 +1,55 @@ +import { + appendFileSync, + chmodSync, + existsSync, + renameSync, + statSync, + unlinkSync, + writeFileSync, +} from "fs" +import { + ensureReplyListenerStateDir, + REPLY_LISTENER_SECURE_FILE_MODE, + getReplyListenerLogFilePath, +} from "./reply-listener-paths" + +const MAX_REPLY_LISTENER_LOG_SIZE_BYTES = 1024 * 1024 + +export function writeSecureReplyListenerFile(filePath: string, content: string): void { + ensureReplyListenerStateDir() + writeFileSync(filePath, content, { mode: REPLY_LISTENER_SECURE_FILE_MODE }) + + try { + chmodSync(filePath, REPLY_LISTENER_SECURE_FILE_MODE) + } catch { + } +} + +function rotateReplyListenerLogIfNeeded(logPath: string): void { + try { + if (!existsSync(logPath)) return + + const stats = statSync(logPath) + if (stats.size <= MAX_REPLY_LISTENER_LOG_SIZE_BYTES) return + + const backupPath = `${logPath}.old` + if (existsSync(backupPath)) { + unlinkSync(backupPath) + } + renameSync(logPath, backupPath) + } catch { + } +} + +export function logReplyListenerMessage(message: string): void { + try { + ensureReplyListenerStateDir() + const logFilePath = getReplyListenerLogFilePath() + rotateReplyListenerLogIfNeeded(logFilePath) + const timestamp = new Date().toISOString() + appendFileSync(logFilePath, `[${timestamp}] ${message}\n`, { + mode: REPLY_LISTENER_SECURE_FILE_MODE, + }) + } catch { + } +} diff --git a/src/openclaw/reply-listener-process.ts b/src/openclaw/reply-listener-process.ts new file mode 100644 index 000000000..f6309f168 --- /dev/null +++ b/src/openclaw/reply-listener-process.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "fs" +import { spawn } from "bun" + +export const REPLY_LISTENER_DAEMON_IDENTITY_MARKER = "--openclaw-reply-listener-daemon" + +const REPLY_LISTENER_DAEMON_ENV_ALLOWLIST = [ + "PATH", + "HOME", + "USERPROFILE", + "USER", + "USERNAME", + "LOGNAME", + "LANG", + "LC_ALL", + "LC_CTYPE", + "TERM", + "TMUX", + "TMUX_PANE", + "TMPDIR", + "TMP", + "TEMP", + "XDG_RUNTIME_DIR", + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "SHELL", + "NODE_ENV", + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", + "NO_PROXY", + "no_proxy", + "SystemRoot", + "SYSTEMROOT", + "windir", + "COMSPEC", +] as const + +export function createReplyListenerDaemonEnv(extraEnv: Record): Record { + const env: Record = {} + + for (const key of REPLY_LISTENER_DAEMON_ENV_ALLOWLIST) { + const value = process.env[key] + if (value !== undefined) { + env[key] = value + } + } + + return { ...env, ...extraEnv } +} + +export function isReplyListenerProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +export async function isReplyListenerDaemonProcess(pid: number): Promise { + try { + if (process.platform === "linux") { + const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf-8") + return cmdline.includes(REPLY_LISTENER_DAEMON_IDENTITY_MARKER) + } + + const processInfo = spawn(["ps", "-p", String(pid), "-o", "args="], { + stdout: "pipe", + stderr: "ignore", + }) + const stdout = await new Response(processInfo.stdout).text() + if (processInfo.exitCode !== 0) return false + return stdout.includes(REPLY_LISTENER_DAEMON_IDENTITY_MARKER) + } catch { + return false + } +} diff --git a/src/openclaw/reply-listener-spawn.ts b/src/openclaw/reply-listener-spawn.ts new file mode 100644 index 000000000..1cd0a1818 --- /dev/null +++ b/src/openclaw/reply-listener-spawn.ts @@ -0,0 +1,25 @@ +import { spawn } from "bun" +import { + createReplyListenerDaemonEnv, + REPLY_LISTENER_DAEMON_IDENTITY_MARKER, +} from "./reply-listener-process" +import { REPLY_LISTENER_STARTUP_TOKEN_ENV } from "./reply-listener-state" + +export interface ReplyListenerSpawnProcess { + pid: number | undefined + unref(): void +} + +export function spawnReplyListenerDaemon( + daemonScript: string, + startupToken: string, +): ReplyListenerSpawnProcess { + return spawn(["bun", "run", daemonScript, REPLY_LISTENER_DAEMON_IDENTITY_MARKER], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + cwd: process.cwd(), + env: createReplyListenerDaemonEnv({ + [REPLY_LISTENER_STARTUP_TOKEN_ENV]: startupToken, + }), + }) +}