diff --git a/src/config/io.logging.test.ts b/src/config/io.logging.test.ts index 0de0a477ec1..1177a70127c 100644 --- a/src/config/io.logging.test.ts +++ b/src/config/io.logging.test.ts @@ -102,4 +102,48 @@ describe("config io warning/error logging", () => { expect(logger.error).toHaveBeenCalledTimes(2); }); }); + + it("sanitizes config validation details before logging", async () => { + vi.resetModules(); + vi.doMock("./validation.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + validateConfigObjectWithPlugins: vi.fn(() => ({ + ok: false as const, + issues: [ + { + path: "plugins.entries.bad\nkey\u001b[31m", + message: "invalid\tvalue\r\n\u001b[2J", + }, + ], + })), + }; + }); + + const { createConfigIO: createConfigIOWithMock } = await import("./io.js"); + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-log-sanitize-")); + const configDir = path.join(home, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile(configPath, JSON.stringify({ gateway: { port: 18789 } }, null, 2)); + const logger = { warn: vi.fn(), error: vi.fn() }; + const io = createConfigIOWithMock({ + env: {} as NodeJS.ProcessEnv, + homedir: () => home, + logger, + }); + + try { + expect(io.loadConfig()).toEqual({}); + const logged = logger.error.mock.calls[0]?.[0] ?? ""; + expect(logged).toContain("plugins.entries.bad\\nkey"); + expect(logged).toContain("invalid\\tvalue\\r\\n"); + expect(logged).not.toContain("\u001b"); + } finally { + vi.doUnmock("./validation.js"); + vi.resetModules(); + await fs.rm(home, { recursive: true, force: true }); + } + }); }); diff --git a/src/config/io.ts b/src/config/io.ts index 88fcf81fade..dfd2e999167 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -13,6 +13,7 @@ import { shouldDeferShellEnvFallback, shouldEnableShellEnvFallback, } from "../infra/shell-env.js"; +import { sanitizeTerminalText } from "../terminal/safe-text.js"; import { VERSION } from "../version.js"; import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js"; import { maintainConfigBackups } from "./backup-rotation.js"; @@ -591,6 +592,16 @@ function clearConfigMessageFingerprint( } } +function formatConfigIssueDetails(issues: Array<{ path: string; message: string }>): string { + return issues + .map((iss) => { + const safePath = sanitizeTerminalText(iss.path || ""); + const safeMessage = sanitizeTerminalText(iss.message); + return `- ${safePath}: ${safeMessage}`; + }) + .join("\n"); +} + function getConfigMiskeyWarnings(raw: unknown): string[] { const warnings: string[] = []; if (!raw || typeof raw !== "object") { @@ -765,26 +776,24 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { } const validated = validateConfigObjectWithPlugins(resolvedConfig); if (!validated.ok) { - const details = validated.issues - .map((iss) => `- ${iss.path || ""}: ${iss.message}`) - .join("\n"); + const details = formatConfigIssueDetails(validated.issues); clearConfigMessageFingerprint(configPath, "warnings"); logConfigMessageOnce({ configPath, kind: "invalid", - message: `Invalid config at ${configPath}:\\n${details}`, + message: `Invalid config at ${sanitizeTerminalText(configPath)}:\\n${details}`, logger: deps.logger, }); - const error = new Error(`Invalid config at ${configPath}:\n${details}`); + const error = new Error( + `Invalid config at ${sanitizeTerminalText(configPath)}:\n${details}`, + ); (error as { code?: string; details?: string }).code = "INVALID_CONFIG"; (error as { code?: string; details?: string }).details = details; throw error; } clearConfigMessageFingerprint(configPath, "invalid"); if (validated.warnings.length > 0) { - const details = validated.warnings - .map((iss) => `- ${iss.path || ""}: ${iss.message}`) - .join("\n"); + const details = formatConfigIssueDetails(validated.warnings); warningMessages.push(`Config warnings:\\n${details}`); } const futureVersionWarning = getFutureVersionWarning(validated.config); diff --git a/src/daemon/systemd-unit.test.ts b/src/daemon/systemd-unit.test.ts index aa32f30f2bf..51890482e6e 100644 --- a/src/daemon/systemd-unit.test.ts +++ b/src/daemon/systemd-unit.test.ts @@ -21,14 +21,14 @@ describe("buildSystemdUnit", () => { expect(unit).toContain("KillMode=control-group"); }); - it("restarts only on failure", () => { + it("keeps restart always for supervised restarts", () => { const unit = buildSystemdUnit({ description: "OpenClaw Gateway", programArguments: ["/usr/bin/openclaw", "gateway", "run"], environment: {}, }); - expect(unit).toContain("Restart=on-failure"); - expect(unit).not.toContain("Restart=always"); + expect(unit).toContain("Restart=always"); + expect(unit).not.toContain("Restart=on-failure"); }); it("rejects environment values with line breaks", () => { diff --git a/src/daemon/systemd-unit.ts b/src/daemon/systemd-unit.ts index 4e9d0d72a5f..06d907f7c58 100644 --- a/src/daemon/systemd-unit.ts +++ b/src/daemon/systemd-unit.ts @@ -57,7 +57,9 @@ export function buildSystemdUnit({ "", "[Service]", `ExecStart=${execStart}`, - "Restart=on-failure", + "Restart=always", + // systemd already has burst protection; keep a shorter retry than launchd so + // supervised restart requests still relaunch promptly on Linux. "RestartSec=5", // Keep service children in the same lifecycle so restarts do not leave // orphan ACP/runtime workers behind.