diff --git a/CHANGELOG.md b/CHANGELOG.md index 7516a1e1e54..64e5055ef69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,10 @@ Docs: https://docs.openclaw.ai split-brain installs from stopping or rewriting newer gateway services. Fixes #57079. - Gateway: reserve `/healthz` and `/readyz` ahead of plugin, canvas, and Control UI HTTP stages so liveness/readiness probes still answer when a later route handler stalls. Fixes #69674. Thanks @Xike-Creek. +- Logging: load `logging.file` and redaction settings directly from the active + OpenClaw config path in bundled runtimes, so packaged gateways stop falling + back to `/tmp/openclaw`. Fixes #59370, #67168, and #61295. Thanks @KeaneYan, + @Pan9hu, and @zsjlovelike. - Agents/groups: treat clean empty assistant stops as silent `NO_REPLY` only for always-on groups where silent replies are allowed, while keeping direct and mention-gated sessions on the incomplete-turn retry path. Thanks @MagnaAI. - macOS/Node: keep native remote app nodes from advertising `browser.proxy`, start browser-capable CLI node services through the restored diff --git a/src/logging/config.test.ts b/src/logging/config.test.ts index b8566bd34e9..57156e31327 100644 --- a/src/logging/config.test.ts +++ b/src/logging/config.test.ts @@ -1,31 +1,78 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { readLoggingConfig } from "./config.js"; -const loadConfigMock = vi.hoisted(() => vi.fn()); - -vi.mock("../config/config.js", async () => { - const actual = await vi.importActual("../config/config.js"); - return { - ...actual, - loadConfig: () => loadConfigMock(), - }; -}); - const originalArgv = process.argv; +const originalConfigPath = process.env.OPENCLAW_CONFIG_PATH; +let tempDirs: string[] = []; + +function writeConfig(source: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-logging-config-")); + tempDirs.push(dir); + const configPath = path.join(dir, "openclaw.json"); + fs.writeFileSync(configPath, source); + process.env.OPENCLAW_CONFIG_PATH = configPath; + return configPath; +} describe("readLoggingConfig", () => { afterEach(() => { process.argv = originalArgv; - loadConfigMock.mockReset(); + if (originalConfigPath === undefined) { + delete process.env.OPENCLAW_CONFIG_PATH; + } else { + process.env.OPENCLAW_CONFIG_PATH = originalConfigPath; + } + for (const dir of tempDirs) { + fs.rmSync(dir, { force: true, recursive: true }); + } + tempDirs = []; }); it("skips mutating config loads for config schema", async () => { process.argv = ["node", "openclaw", "config", "schema"]; - loadConfigMock.mockImplementation(() => { - throw new Error("loadConfig should not be called"); - }); + const configPath = writeConfig(`{ logging: { file: "/tmp/should-not-read.log" } }`); + fs.rmSync(configPath); expect(readLoggingConfig()).toBeUndefined(); - expect(loadConfigMock).not.toHaveBeenCalled(); + }); + + it("reads logging config directly from the active config path", () => { + writeConfig(`{ + logging: { + level: "debug", + file: "/tmp/openclaw-custom.log", + maxFileBytes: 1234, + }, + }`); + + expect(readLoggingConfig()).toMatchObject({ + level: "debug", + file: "/tmp/openclaw-custom.log", + maxFileBytes: 1234, + }); + }); + + it("supports JSON5 comments and trailing commas", () => { + writeConfig(`{ + // users commonly keep comments in openclaw.json + logging: { + consoleLevel: "warn", + }, + }`); + + expect(readLoggingConfig()).toMatchObject({ + consoleLevel: "warn", + }); + }); + + it("returns undefined for missing or malformed config files", () => { + process.env.OPENCLAW_CONFIG_PATH = path.join(os.tmpdir(), "openclaw-missing-config.json"); + expect(readLoggingConfig()).toBeUndefined(); + + writeConfig(`{ logging: `); + expect(readLoggingConfig()).toBeUndefined(); }); }); diff --git a/src/logging/config.ts b/src/logging/config.ts index a45450dd519..987bca7a437 100644 --- a/src/logging/config.ts +++ b/src/logging/config.ts @@ -1,32 +1,47 @@ +import fs from "node:fs"; +import JSON5 from "json5"; import { getCommandPathWithRootOptions } from "../cli/argv.js"; +import { resolveConfigPath } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resolveNodeRequireFromMeta } from "./node-require.js"; type LoggingConfig = OpenClawConfig["logging"]; -const requireConfig = resolveNodeRequireFromMeta(import.meta.url); +let cachedLoggingConfig: + | { + path: string; + logging: LoggingConfig | undefined; + } + | undefined; export function shouldSkipMutatingLoggingConfigRead(argv: string[] = process.argv): boolean { const [primary, secondary] = getCommandPathWithRootOptions(argv, 2); return primary === "config" && (secondary === "schema" || secondary === "validate"); } +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + export function readLoggingConfig(): LoggingConfig | undefined { if (shouldSkipMutatingLoggingConfigRead()) { return undefined; } try { - const loaded = requireConfig?.("../config/config.js") as - | { - loadConfig?: () => OpenClawConfig; - } - | undefined; - const parsed = loaded?.loadConfig?.(); - const logging = parsed?.logging; - if (!logging || typeof logging !== "object" || Array.isArray(logging)) { + const configPath = resolveConfigPath(); + if (cachedLoggingConfig?.path === configPath) { + return cachedLoggingConfig.logging; + } + if (!fs.existsSync(configPath)) { return undefined; } - return logging as LoggingConfig; + const parsed = JSON5.parse(fs.readFileSync(configPath, "utf8")); + const logging = isObjectRecord(parsed) ? parsed.logging : undefined; + const resolved = isObjectRecord(logging) ? (logging as LoggingConfig) : undefined; + cachedLoggingConfig = { + path: configPath, + logging: resolved, + }; + return resolved; } catch { return undefined; } diff --git a/src/logging/console.ts b/src/logging/console.ts index e1371932646..03876962090 100644 --- a/src/logging/console.ts +++ b/src/logging/console.ts @@ -6,7 +6,6 @@ import { readLoggingConfig, shouldSkipMutatingLoggingConfigRead } from "./config import { resolveEnvLogLevelOverride } from "./env-log-level.js"; import { type LogLevel, normalizeLogLevel } from "./levels.js"; import { getLogger } from "./logger.js"; -import { resolveNodeRequireFromMeta } from "./node-require.js"; import { redactSensitiveText } from "./redact.js"; import { loggingState } from "./state.js"; import { formatLocalIsoWithOffset, formatTimestamp } from "./timestamps.js"; @@ -19,20 +18,8 @@ type ConsoleSettings = { }; export type ConsoleLoggerSettings = ConsoleSettings; -const requireConfig = resolveNodeRequireFromMeta(import.meta.url); type ConsoleConfigLoader = () => OpenClawConfig["logging"] | undefined; -const loadConfigFallbackDefault: ConsoleConfigLoader = () => { - try { - const loaded = requireConfig?.("../config/config.js") as - | { - loadConfig?: () => OpenClawConfig; - } - | undefined; - return loaded?.loadConfig?.().logging; - } catch { - return undefined; - } -}; +const loadConfigFallbackDefault: ConsoleConfigLoader = () => undefined; let loadConfigFallback: ConsoleConfigLoader = loadConfigFallbackDefault; export function setConsoleConfigLoaderForTests(loader?: ConsoleConfigLoader): void { diff --git a/src/logging/logger-redaction-behavior.test.ts b/src/logging/logger-redaction-behavior.test.ts index 127a3a47417..9697701341a 100644 --- a/src/logging/logger-redaction-behavior.test.ts +++ b/src/logging/logger-redaction-behavior.test.ts @@ -5,12 +5,24 @@ import { createSuiteLogPathTracker } from "./log-test-helpers.js"; const secret = "sk-testsecret1234567890abcd"; const logPathTracker = createSuiteLogPathTracker("openclaw-log-redaction-"); +const originalConfigPath = process.env.OPENCLAW_CONFIG_PATH; +const originalTestFileLog = process.env.OPENCLAW_TEST_FILE_LOG; beforeAll(async () => { await logPathTracker.setup(); }); afterEach(() => { + if (originalConfigPath === undefined) { + delete process.env.OPENCLAW_CONFIG_PATH; + } else { + process.env.OPENCLAW_CONFIG_PATH = originalConfigPath; + } + if (originalTestFileLog === undefined) { + delete process.env.OPENCLAW_TEST_FILE_LOG; + } else { + process.env.OPENCLAW_TEST_FILE_LOG = originalTestFileLog; + } resetLogger(); setLoggerOverride(null); }); @@ -42,4 +54,25 @@ describe("file log redaction", () => { expect(content).toContain("Authorization: Bearer"); expect(content).not.toContain(secret); }); + + it("uses logging.file from the active config path", () => { + const logPath = logPathTracker.nextPath(); + const configPath = logPathTracker.nextPath(); + fs.writeFileSync( + configPath, + JSON.stringify({ + logging: { + level: "info", + file: logPath, + }, + }), + ); + process.env.OPENCLAW_CONFIG_PATH = configPath; + process.env.OPENCLAW_TEST_FILE_LOG = "1"; + + getLogger().info({ message: "configured log path works" }); + + const content = fs.readFileSync(logPath, "utf8"); + expect(content).toContain("configured log path works"); + }); }); diff --git a/src/logging/logger-settings.test.ts b/src/logging/logger-settings.test.ts index b594b24c3ac..48319bf4fa0 100644 --- a/src/logging/logger-settings.test.ts +++ b/src/logging/logger-settings.test.ts @@ -1,23 +1,15 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const { fallbackRequireMock, readLoggingConfigMock, shouldSkipMutatingLoggingConfigReadMock } = - vi.hoisted(() => ({ - readLoggingConfigMock: vi.fn(() => undefined), - shouldSkipMutatingLoggingConfigReadMock: vi.fn(() => false), - fallbackRequireMock: vi.fn(() => { - throw new Error("config fallback should not be used in this test"); - }), - })); +const { readLoggingConfigMock, shouldSkipMutatingLoggingConfigReadMock } = vi.hoisted(() => ({ + readLoggingConfigMock: vi.fn<() => unknown>(() => undefined), + shouldSkipMutatingLoggingConfigReadMock: vi.fn(() => false), +})); vi.mock("./config.js", () => ({ readLoggingConfig: readLoggingConfigMock, shouldSkipMutatingLoggingConfigRead: shouldSkipMutatingLoggingConfigReadMock, })); -vi.mock("./node-require.js", () => ({ - resolveNodeRequireFromMeta: () => fallbackRequireMock, -})); - let originalTestFileLog: string | undefined; let originalOpenClawLogLevel: string | undefined; let logging: typeof import("../logging.js"); @@ -31,10 +23,10 @@ beforeEach(() => { originalOpenClawLogLevel = process.env.OPENCLAW_LOG_LEVEL; delete process.env.OPENCLAW_TEST_FILE_LOG; delete process.env.OPENCLAW_LOG_LEVEL; - readLoggingConfigMock.mockClear(); + readLoggingConfigMock.mockReset(); + readLoggingConfigMock.mockReturnValue(undefined); shouldSkipMutatingLoggingConfigReadMock.mockReset(); shouldSkipMutatingLoggingConfigReadMock.mockReturnValue(false); - fallbackRequireMock.mockClear(); logging.resetLogger(); logging.setLoggerOverride(null); }); @@ -60,22 +52,31 @@ describe("getResolvedLoggerSettings", () => { const settings = logging.getResolvedLoggerSettings(); expect(settings.level).toBe("silent"); expect(readLoggingConfigMock).not.toHaveBeenCalled(); - expect(fallbackRequireMock).not.toHaveBeenCalled(); }); it("reads logging config when test file logging is explicitly enabled", () => { process.env.OPENCLAW_TEST_FILE_LOG = "1"; + readLoggingConfigMock.mockReturnValue({ + level: "debug", + file: "/tmp/openclaw-configured.log", + maxFileBytes: 2048, + }); + const settings = logging.getResolvedLoggerSettings(); - expect(settings.level).toBe("info"); + + expect(settings).toMatchObject({ + level: "debug", + file: "/tmp/openclaw-configured.log", + maxFileBytes: 2048, + }); }); - it("skips fallback config loads for config schema", () => { + it("uses defaults when config schema skips logging config reads", () => { process.env.OPENCLAW_TEST_FILE_LOG = "1"; shouldSkipMutatingLoggingConfigReadMock.mockReturnValue(true); const settings = logging.getResolvedLoggerSettings(); expect(settings.level).toBe("info"); - expect(fallbackRequireMock).not.toHaveBeenCalled(); }); }); diff --git a/src/logging/logger.ts b/src/logging/logger.ts index 455d4883698..8a750c9b800 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -17,7 +17,6 @@ import { import { readLoggingConfig, shouldSkipMutatingLoggingConfigRead } from "./config.js"; import { resolveEnvLogLevelOverride } from "./env-log-level.js"; import { type LogLevel, levelToMinLevel, normalizeLogLevel } from "./levels.js"; -import { resolveNodeRequireFromMeta } from "./node-require.js"; import { redactSensitiveText } from "./redact.js"; import { loggingState } from "./state.js"; import { formatTimestamp } from "./timestamps.js"; @@ -58,8 +57,6 @@ const LOG_SUFFIX = ".log"; const MAX_LOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h const DEFAULT_MAX_LOG_FILE_BYTES = 500 * 1024 * 1024; // 500 MB -const requireConfig = resolveNodeRequireFromMeta(import.meta.url); - type LogObj = { date?: Date } & Record; type ResolvedSettings = { @@ -355,20 +352,8 @@ function resolveSettings(): ResolvedSettings { }; } - let cfg: OpenClawConfig["logging"] | undefined = + const cfg: OpenClawConfig["logging"] | undefined = (loggingState.overrideSettings as LoggerSettings | null) ?? readLoggingConfig(); - if (!cfg && !shouldSkipMutatingLoggingConfigRead()) { - try { - const loaded = requireConfig?.("../config/config.js") as - | { - loadConfig?: () => OpenClawConfig; - } - | undefined; - cfg = loaded?.loadConfig?.().logging; - } catch { - cfg = undefined; - } - } const defaultLevel = process.env.VITEST === "true" && process.env.OPENCLAW_TEST_FILE_LOG !== "1" ? "silent" : "info"; const fromConfig = normalizeLogLevel(cfg?.level, defaultLevel); diff --git a/src/logging/redact.test.ts b/src/logging/redact.test.ts index 3e3d0449850..ebaf0319376 100644 --- a/src/logging/redact.test.ts +++ b/src/logging/redact.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; import { getDefaultRedactPatterns, redactSensitiveLines, @@ -7,6 +10,28 @@ import { } from "./redact.js"; const defaults = getDefaultRedactPatterns(); +const originalConfigPath = process.env.OPENCLAW_CONFIG_PATH; +let tempDirs: string[] = []; + +function writeConfig(source: string): void { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-redact-config-")); + tempDirs.push(dir); + const configPath = path.join(dir, "openclaw.json"); + fs.writeFileSync(configPath, source); + process.env.OPENCLAW_CONFIG_PATH = configPath; +} + +afterEach(() => { + if (originalConfigPath === undefined) { + delete process.env.OPENCLAW_CONFIG_PATH; + } else { + process.env.OPENCLAW_CONFIG_PATH = originalConfigPath; + } + for (const dir of tempDirs) { + fs.rmSync(dir, { force: true, recursive: true }); + } + tempDirs = []; +}); describe("redactSensitiveText", () => { it("masks env assignments while keeping the key", () => { @@ -134,6 +159,18 @@ describe("redactSensitiveText", () => { expect(output).toBe(input); }); + it("honors logging redaction settings from the active config path", () => { + writeConfig(`{ + logging: { + redactSensitive: "off", + }, + }`); + + expect(redactSensitiveText("OPENAI_API_KEY=sk-1234567890abcdef")).toBe( + "OPENAI_API_KEY=sk-1234567890abcdef", + ); + }); + it("does not resolve patterns when mode is off", () => { const options = { mode: "off" as const, diff --git a/src/logging/redact.ts b/src/logging/redact.ts index 2be750805e2..9a870fd1c91 100644 --- a/src/logging/redact.ts +++ b/src/logging/redact.ts @@ -1,10 +1,7 @@ -import type { OpenClawConfig } from "../config/types.openclaw.js"; import { compileConfigRegex } from "../security/config-regex.js"; -import { resolveNodeRequireFromMeta } from "./node-require.js"; +import { readLoggingConfig } from "./config.js"; import { replacePatternBounded } from "./redact-bounded.js"; -const requireConfig = resolveNodeRequireFromMeta(import.meta.url); - export type RedactSensitiveMode = "off" | "tools"; type RedactPattern = string | RegExp; @@ -117,17 +114,7 @@ function redactText(text: string, patterns: RegExp[]): string { } function resolveConfigRedaction(): RedactOptions { - let cfg: OpenClawConfig["logging"] | undefined; - try { - const loaded = requireConfig?.("../config/config.js") as - | { - loadConfig?: () => OpenClawConfig; - } - | undefined; - cfg = loaded?.loadConfig?.().logging; - } catch { - cfg = undefined; - } + const cfg = readLoggingConfig(); return { mode: normalizeMode(cfg?.redactSensitive), patterns: cfg?.redactPatterns,