diff --git a/CHANGELOG.md b/CHANGELOG.md index d0412abba26..03d7e298131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - fix(infra): block ambient Homebrew env vars from brew resolution. (#74463) Thanks @pgondhi987. - Thinking/providers: resolve bundled provider thinking profiles through lightweight provider policy artifacts when startup-lazy providers are not active, so OpenAI Codex GPT-5.x keeps xhigh available in Gateway session validation. Fixes #74796. Thanks @maxschachere. +- Security/Windows: ignore workspace `.env` system-path variables and resolve stale-process `taskkill.exe` from the validated Windows install root, preventing repository-local env files from redirecting cleanup helpers. Thanks @pgondhi987. - Plugins/TTS: keep bundled speech-provider discovery available on cold package Gateway paths and add bundled plugin matrix runtime probes for health, readiness, RPC, TTS discovery, and post-ready runtime-deps watchdog coverage. Refs #75283. Thanks @vincentkoc. - Google Meet/Twilio: show delegated voice call ID, DTMF, and intro-greeting state in `googlemeet doctor`, and avoid claiming DTMF was sent when no Meet PIN sequence was configured. Refs #72478. Thanks @DougButdorf. - Voice Call/Twilio: send notify-mode initial TwiML directly in the outbound create-call request while keeping conversation and pre-connect DTMF calls webhook-driven, so one-shot notify calls do not depend on a first-answer webhook fetch. Supersedes #72758. Thanks @tyshepps. diff --git a/src/agents/pi-project-settings.test.ts b/src/agents/pi-project-settings.test.ts index e79779ef97d..f69ecba1cb4 100644 --- a/src/agents/pi-project-settings.test.ts +++ b/src/agents/pi-project-settings.test.ts @@ -1,9 +1,13 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; import { buildEmbeddedPiSettingsSnapshot, DEFAULT_EMBEDDED_PI_PROJECT_SETTINGS_POLICY, resolveEmbeddedPiProjectSettingsPolicy, } from "./pi-project-settings-snapshot.js"; +import { createPreparedEmbeddedPiSettingsManager } from "./pi-project-settings.js"; type EmbeddedPiSettingsArgs = Parameters[0]; @@ -126,3 +130,48 @@ describe("buildEmbeddedPiSettingsSnapshot", () => { }); }); }); + +describe("createPreparedEmbeddedPiSettingsManager", () => { + it("keeps trusted file-backed settings runtime-scoped after preparation", async () => { + const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-settings-")); + try { + const cwd = path.join(baseDir, "workspace"); + const agentDir = path.join(baseDir, "agent"); + const projectSettingsDir = path.join(cwd, ".pi"); + const agentSettingsPath = path.join(agentDir, "settings.json"); + await fs.mkdir(projectSettingsDir, { recursive: true }); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + agentSettingsPath, + JSON.stringify({ retry: { enabled: true } }, null, 2), + "utf8", + ); + await fs.writeFile( + path.join(projectSettingsDir, "settings.json"), + JSON.stringify({ shellCommandPrefix: "echo trusted &&" }, null, 2), + "utf8", + ); + + const settingsManager = createPreparedEmbeddedPiSettingsManager({ + cwd, + agentDir, + cfg: { + agents: { defaults: { embeddedPi: { projectSettingsPolicy: "trusted" } } }, + }, + }); + + expect(settingsManager.getShellCommandPrefix()).toBe("echo trusted &&"); + expect(settingsManager.getRetryEnabled()).toBe(true); + + settingsManager.setRetryEnabled(false); + await settingsManager.flush(); + + const diskSettings = JSON.parse(await fs.readFile(agentSettingsPath, "utf8")) as { + retry?: { enabled?: boolean }; + }; + expect(diskSettings.retry?.enabled).toBe(true); + } finally { + await fs.rm(baseDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/agents/pi-project-settings.ts b/src/agents/pi-project-settings.ts index bb7bd1e7676..3ec7faa1dee 100644 --- a/src/agents/pi-project-settings.ts +++ b/src/agents/pi-project-settings.ts @@ -37,6 +37,17 @@ export function createEmbeddedPiSettingsManager(params: { return SettingsManager.inMemory(settings); } +function createRuntimeEmbeddedPiSettingsManager(settingsManager: SettingsManager): SettingsManager { + return SettingsManager.inMemory( + buildEmbeddedPiSettingsSnapshot({ + globalSettings: settingsManager.getGlobalSettings(), + pluginSettings: {}, + projectSettings: settingsManager.getProjectSettings(), + policy: "trusted", + }), + ); +} + export function createPreparedEmbeddedPiSettingsManager(params: { cwd: string; agentDir: string; @@ -44,7 +55,9 @@ export function createPreparedEmbeddedPiSettingsManager(params: { /** Resolved context window budget so reserve-token floor can be capped for small models. */ contextTokenBudget?: number; }): SettingsManager { - const settingsManager = createEmbeddedPiSettingsManager(params); + const settingsManager = createRuntimeEmbeddedPiSettingsManager( + createEmbeddedPiSettingsManager(params), + ); applyPiCompactionSettingsFromConfig({ settingsManager, cfg: params.cfg, diff --git a/src/infra/brew.test.ts b/src/infra/brew.test.ts index fa7a629bb4a..d0af983d9a5 100644 --- a/src/infra/brew.test.ts +++ b/src/infra/brew.test.ts @@ -139,6 +139,18 @@ describe("brew helpers", () => { }); }); + it("does not resolve brew from PATH entries", async () => { + await withTempDir({ prefix: "openclaw-brew-" }, async (tmp) => { + const pathBin = path.join(tmp, "path-bin"); + const pathBrew = path.join(pathBin, "brew"); + await writeExecutable(pathBrew); + + const env: NodeJS.ProcessEnv = { PATH: pathBin }; + + expect(resolveBrewExecutable({ homeDir: path.join(tmp, "home"), env })).not.toBe(pathBrew); + }); + }); + it("always includes the standard macOS brew dirs after linuxbrew candidates", () => { const dirs = resolveBrewPathDirs({ homeDir: "/home/test" }); diff --git a/src/infra/dotenv.test.ts b/src/infra/dotenv.test.ts index ef17726deb2..2eb5a990d16 100644 --- a/src/infra/dotenv.test.ts +++ b/src/infra/dotenv.test.ts @@ -680,6 +680,14 @@ describe("workspace .env blocklist completeness", () => { "OPENCLAW_NODE_EXEC_HOST", "OPENCLAW_NODE_EXEC_FALLBACK", "OPENCLAW_ALLOW_PROJECT_LOCAL_BIN", + "PATH", + "HOMEBREW_BREW_FILE", + "HOMEBREW_PREFIX", + "SystemRoot", + "WINDIR", + "ProgramFiles", + "ProgramFiles(x86)", + "ProgramW6432", "SYNOLOGY_CHAT_INCOMING_URL", "SYNOLOGY_NAS_HOST", ]; diff --git a/src/infra/dotenv.ts b/src/infra/dotenv.ts index c1e42288490..aaf1facc402 100644 --- a/src/infra/dotenv.ts +++ b/src/infra/dotenv.ts @@ -74,10 +74,16 @@ const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([ "OPENCLAW_STATE_DIR", "OPENCLAW_TEST_TAILSCALE_BINARY", "PI_CODING_AGENT_DIR", + "PATH", "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH", + "PROGRAMFILES", + "PROGRAMFILES(X86)", + "PROGRAMW6432", "SYNOLOGY_CHAT_INCOMING_URL", "SYNOLOGY_NAS_HOST", + "SYSTEMROOT", "UV_PYTHON", + "WINDIR", ]); // Block endpoint redirection for any service without overfitting per-provider names. diff --git a/src/infra/restart-stale-pids.test.ts b/src/infra/restart-stale-pids.test.ts index 0be731a2b85..56bacb49e64 100644 --- a/src/infra/restart-stale-pids.test.ts +++ b/src/infra/restart-stale-pids.test.ts @@ -90,6 +90,15 @@ vi.mock("./windows-port-pids.js", () => ({ mockReadWindowsProcessArgsResult(pid, timeoutMs), })); +vi.mock("./windows-install-roots.js", () => ({ + getWindowsInstallRoots: () => ({ + systemRoot: "C:\\Windows", + programFiles: "C:\\Program Files", + programFilesX86: "C:\\Program Files (x86)", + programW6432: null, + }), +})); + import { resolveLsofCommandSync } from "./ports-lsof.js"; let __testing: typeof import("./restart-stale-pids.js").__testing; let cleanStaleGatewayProcessesSync: typeof import("./restart-stale-pids.js").cleanStaleGatewayProcessesSync; @@ -980,8 +989,10 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { it("does not report Windows pids as killed when taskkill fails", () => { const origDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + const originalSystemRoot = process.env.SystemRoot; const stalePid = process.pid + 911; Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + process.env.SystemRoot = "C:\\PoisonedWindows"; try { let fakeNow = 0; __testing.setDateNowOverride(() => fakeNow); @@ -1012,12 +1023,17 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { expect(cleanStaleGatewayProcessesSync()).toEqual([]); expect(mockSpawnSync).toHaveBeenCalledWith( - expect.stringContaining("taskkill.exe"), + "C:\\Windows\\System32\\taskkill.exe", ["/T", "/PID", String(stalePid)], expect.objectContaining({ timeout: 5000 }), ); } finally { __testing.setDateNowOverride(null); + if (originalSystemRoot === undefined) { + delete process.env.SystemRoot; + } else { + process.env.SystemRoot = originalSystemRoot; + } if (origDescriptor) { Object.defineProperty(process, "platform", origDescriptor); } @@ -1063,13 +1079,13 @@ describe.skipIf(isWindows)("restart-stale-pids", () => { expect(cleanStaleGatewayProcessesSync()).toEqual([]); expect(mockSpawnSync).toHaveBeenNthCalledWith( 1, - expect.stringContaining("taskkill.exe"), + "C:\\Windows\\System32\\taskkill.exe", ["/T", "/PID", String(stalePid)], expect.objectContaining({ timeout: 5000 }), ); expect(mockSpawnSync).toHaveBeenNthCalledWith( 2, - expect.stringContaining("taskkill.exe"), + "C:\\Windows\\System32\\taskkill.exe", ["/F", "/T", "/PID", String(stalePid)], expect.objectContaining({ timeout: 5000 }), ); diff --git a/src/infra/restart-stale-pids.ts b/src/infra/restart-stale-pids.ts index e8485d8c7cd..2d4c8b7e882 100644 --- a/src/infra/restart-stale-pids.ts +++ b/src/infra/restart-stale-pids.ts @@ -6,6 +6,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { isGatewayArgv } from "./gateway-process-argv.js"; import { resolveLsofCommandSync } from "./ports-lsof.js"; +import { getWindowsInstallRoots } from "./windows-install-roots.js"; import { readWindowsListeningPidsOnPortSync, readWindowsListeningPidsResultSync, @@ -424,8 +425,8 @@ function terminateStaleProcessesSync(pids: number[]): number[] { * Sends a graceful taskkill first (/T for tree), waits, then escalates to /F. */ function terminateStaleProcessesWindows(pids: number[]): number[] { - const taskkillPath = path.join( - process.env.SystemRoot ?? "C:\\Windows", + const taskkillPath = path.win32.join( + getWindowsInstallRoots().systemRoot, "System32", "taskkill.exe", );