diff --git a/CHANGELOG.md b/CHANGELOG.md index 49661bbd2a6..b6401f69a7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,7 @@ Docs: https://docs.openclaw.ai - Configure/Ollama: show the configured Ollama model allowlist after Cloud only or Cloud + Local setup and skip slow per-model cloud metadata fetches. (#73995) Thanks @obviyus. - Channels/WhatsApp: detect explicit group `@mentions` again when the bot's own E.164 is in `allowFrom`, so shared-number setups no longer skip group pings that directly mention the bot. Fixes #49317. (#73453) Thanks @juan-flores077. - WhatsApp/reliability: publish real transport-liveness into WhatsApp channel status and force earlier reconnects on silent transport stalls, so quiet healthy sessions stay connected while wedged sockets recover before the later remote 408 path. (#72656) Thanks @Sathvik-1007. +- Core/channels: tighten selected runtime, media, and plugin edge-case handling while preserving existing behavior. Thanks @jesse-merhi. ## 2026.4.27 diff --git a/src/daemon/launchd-restart-handoff.test.ts b/src/daemon/launchd-restart-handoff.test.ts index 6919b6ee7bf..b1356c72af8 100644 --- a/src/daemon/launchd-restart-handoff.test.ts +++ b/src/daemon/launchd-restart-handoff.test.ts @@ -72,6 +72,35 @@ describe("scheduleDetachedLaunchdRestartHandoff", () => { expect(args[1]).not.toContain('basename "$service_target"'); }); + it("sanitizes restart helper environment overrides before spawning", () => { + spawnMock.mockReturnValue({ pid: 4242, unref: unrefMock }); + + scheduleDetachedLaunchdRestartHandoff({ + env: { + HOME: "/Users/test", + OPENCLAW_PROFILE: "default", + PATH: "/tmp/evil-bin", + DYLD_INSERT_LIBRARIES: "/tmp/evil.dylib", + NPM_CONFIG_GLOBALCONFIG: "/tmp/evil-npmrc", + }, + mode: "kickstart", + }); + + const [, args, options] = spawnMock.mock.calls[0] as [ + string, + string[], + { env: Record }, + ]; + expect(args[1]).toContain("exec >>'/Users/test/.openclaw/logs/gateway-restart.log' 2>&1"); + expect(args[1]).not.toContain("/tmp/evil-bin"); + expect(args[1]).not.toContain("/tmp/evil.dylib"); + expect(args[1]).not.toContain("/tmp/evil-npmrc"); + expect(options.env.OPENCLAW_PROFILE).toBe("default"); + expect(options.env.PATH).not.toBe("/tmp/evil-bin"); + expect(options.env.DYLD_INSERT_LIBRARIES).toBeUndefined(); + expect(options.env.NPM_CONFIG_GLOBALCONFIG).toBeUndefined(); + }); + it("rejects invalid launchd labels before spawning the helper", () => { expect(() => { scheduleDetachedLaunchdRestartHandoff({ diff --git a/src/daemon/launchd-restart-handoff.ts b/src/daemon/launchd-restart-handoff.ts index 942120e6413..6ce4e45dd59 100644 --- a/src/daemon/launchd-restart-handoff.ts +++ b/src/daemon/launchd-restart-handoff.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import os from "node:os"; import path from "node:path"; import { formatErrorMessage } from "../infra/errors.js"; +import { sanitizeHostExecEnv } from "../infra/host-env-security.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { sanitizeForLog } from "../terminal/ansi.js"; import { resolveGatewayLaunchAgentLabel } from "./constants.js"; @@ -25,6 +26,13 @@ export type LaunchdRestartTarget = { const START_AFTER_EXIT_PRINT_RETRY_COUNT = 15; const START_AFTER_EXIT_PRINT_RETRY_DELAY_SECONDS = 0.2; +type LaunchdRestartLogEnv = { + HOME?: string; + USERPROFILE?: string; + OPENCLAW_STATE_DIR?: string; + OPENCLAW_PROFILE?: string; +}; + function assertValidLaunchAgentLabel(label: string): string { const trimmed = label.trim(); if (!/^[A-Za-z0-9._-]+$/.test(trimmed)) { @@ -40,6 +48,27 @@ function resolveGuiDomain(): string { return `gui/${process.getuid()}`; } +function collectStringEnvOverrides( + env?: Record, +): Record | undefined { + const overrides = Object.fromEntries( + Object.entries(env ?? {}).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + return Object.keys(overrides).length > 0 ? overrides : undefined; +} + +function collectRestartLogEnv(env?: Record): LaunchdRestartLogEnv { + const source = { ...process.env, ...env }; + return { + HOME: source.HOME, + USERPROFILE: source.USERPROFILE, + OPENCLAW_STATE_DIR: source.OPENCLAW_STATE_DIR, + OPENCLAW_PROFILE: source.OPENCLAW_PROFILE, + }; +} + function resolveLaunchAgentLabel(env?: Record): string { const envLabel = normalizeOptionalString(env?.OPENCLAW_LAUNCHD_LABEL); if (envLabel) { @@ -80,11 +109,11 @@ export function isCurrentProcessLaunchdServiceLabel( function buildLaunchdRestartScript( mode: LaunchdRestartHandoffMode, - env: Record, + restartLogEnv: LaunchdRestartLogEnv, ): string { const waitForCallerPid = `wait_pid="$4" label="$5" -${renderPosixRestartLogSetup(env)} +${renderPosixRestartLogSetup(restartLogEnv)} printf '[%s] openclaw restart attempt source=launchd-handoff mode=${mode} target=%s waitPid=%s\\n' "$(date -u +%FT%TZ)" "$service_target" "$wait_pid" >&2 if [ -n "$wait_pid" ] && [ "$wait_pid" -gt 1 ] 2>/dev/null; then while kill -0 "$wait_pid" >/dev/null 2>&1; do @@ -169,13 +198,17 @@ export function scheduleDetachedLaunchdRestartHandoff(params: { typeof params.waitForPid === "number" && Number.isFinite(params.waitForPid) ? Math.floor(params.waitForPid) : 0; - const restartEnv = { ...process.env, ...params.env }; + const restartLogEnv = collectRestartLogEnv(params.env); + const restartEnv = sanitizeHostExecEnv({ + baseEnv: process.env, + overrides: collectStringEnvOverrides(params.env), + }); try { const child = spawn( "/bin/sh", [ "-c", - buildLaunchdRestartScript(params.mode, restartEnv), + buildLaunchdRestartScript(params.mode, restartLogEnv), "openclaw-launchd-restart-handoff", target.serviceTarget, target.domain,