Compare commits

...

7 Commits

Author SHA1 Message Date
Vincent Koc
81c6e1ed92 Daemon: keep launchd alive on supervised exits 2026-03-06 22:20:57 -05:00
Vincent Koc
2d84590373 Config: sanitize validation logs and keep systemd restarts 2026-03-06 19:32:17 -05:00
Vincent Koc
bc3565f5f4
Merge branch 'main' into vincentkoc-code/config-log-spam-dedupe 2026-03-06 18:19:02 -05:00
Vincent Koc
c404d723c8
Merge branch 'main' into vincentkoc-code/config-log-spam-dedupe 2026-03-06 15:10:43 -05:00
Vincent Koc
5395ec88d0 Docs: clarify gateway log surfaces 2026-03-06 14:46:08 -05:00
Vincent Koc
bcb8475691 Daemon: back off unhealthy gateway restarts 2026-03-06 14:46:01 -05:00
Vincent Koc
c026374fb1 Config: dedupe repeated config warning logs 2026-03-06 14:45:49 -05:00
11 changed files with 363 additions and 35 deletions

View File

@ -15,6 +15,10 @@ OpenClaw has two log “surfaces”:
- **Console output** (what you see in the terminal / Debug UI).
- **File logs** (JSON lines) written by the gateway logger.
Supervised installs add a third surface:
- **Service stdout/stderr logs** written by the launcher itself (for example launchd or systemd).
## File-based logger
- Default rolling log file is under `/tmp/openclaw/` (one file per day): `openclaw-YYYY-MM-DD.log`
@ -39,6 +43,17 @@ openclaw logs --follow
raise the file log level.
- To capture verbose-only details in file logs, set `logging.level` to `debug` or
`trace`.
- `openclaw logs --follow` tails this rolling file logger, not service stdout/stderr logs.
## Service stdout/stderr logs
When the gateway runs under a service manager, startup failures and uncaught stderr can also land in supervisor-managed logs:
- macOS CLI LaunchAgent: `$OPENCLAW_STATE_DIR/logs/gateway.log` and `$OPENCLAW_STATE_DIR/logs/gateway.err.log`
- macOS app bundle launcher: `/tmp/openclaw/openclaw-gateway.log`
- Linux systemd user service: `journalctl --user -u openclaw-gateway.service -n 200 --no-pager`
These logs are separate from the rolling `/tmp/openclaw/openclaw-YYYY-MM-DD.log` file logger. If disk usage grows on macOS, inspect both surfaces.
## Console capture

View File

@ -2594,6 +2594,15 @@ Service/supervisor logs (when the gateway runs via launchd/systemd):
- macOS: `$OPENCLAW_STATE_DIR/logs/gateway.log` and `gateway.err.log` (default: `~/.openclaw/logs/...`; profiles use `~/.openclaw-<profile>/logs/...`)
- Linux: `journalctl --user -u openclaw-gateway[-<profile>].service -n 200 --no-pager`
- Windows: `schtasks /Query /TN "OpenClaw Gateway (<profile>)" /V /FO LIST`
- macOS app-bundled launcher stdout/err: `/tmp/openclaw/openclaw-gateway.log`
If the macOS service logs have grown unexpectedly and you already fixed the underlying error, rotate/truncate them manually:
```bash
mkdir -p "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/logs"
: > "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/logs/gateway.log"
: > "${OPENCLAW_STATE_DIR:-$HOME/.openclaw}/logs/gateway.err.log"
```
See [Troubleshooting](/gateway/troubleshooting#log-locations) for more.

View File

@ -0,0 +1,149 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createConfigIO } from "./io.js";
async function withTempConfig(
run: (params: {
home: string;
configPath: string;
logger: { warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
loadConfig: () => Record<string, unknown>;
}) => Promise<void>,
): Promise<void> {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-log-"));
const configDir = path.join(home, ".openclaw");
const configPath = path.join(configDir, "openclaw.json");
await fs.mkdir(configDir, { recursive: true });
const logger = { warn: vi.fn(), error: vi.fn() };
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
logger,
});
try {
await run({
home,
configPath,
logger,
loadConfig: () => io.loadConfig() as Record<string, unknown>,
});
} finally {
await fs.rm(home, { recursive: true, force: true });
}
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("config io warning/error logging", () => {
it("dedupes identical warning payloads until the config changes", async () => {
await withTempConfig(async ({ configPath, logger, loadConfig }) => {
await fs.writeFile(
configPath,
JSON.stringify(
{
plugins: {
entries: {
discord: { enabled: false, config: {} },
},
},
},
null,
2,
),
);
loadConfig();
loadConfig();
expect(logger.warn).toHaveBeenCalledTimes(1);
await fs.writeFile(configPath, JSON.stringify({}, null, 2));
loadConfig();
await fs.writeFile(
configPath,
JSON.stringify(
{
plugins: {
entries: {
slack: { enabled: false, config: {} },
},
},
},
null,
2,
),
);
loadConfig();
expect(logger.warn).toHaveBeenCalledTimes(2);
expect(logger.warn.mock.calls[1]?.[0]).toContain("plugins.entries.slack");
});
});
it("dedupes identical invalid config errors until the config becomes valid again", async () => {
await withTempConfig(async ({ configPath, logger, loadConfig }) => {
await fs.writeFile(
configPath,
JSON.stringify({ gateway: { port: "not-a-number" } }, null, 2),
);
expect(loadConfig()).toEqual({});
expect(loadConfig()).toEqual({});
expect(logger.error).toHaveBeenCalledTimes(1);
await fs.writeFile(configPath, JSON.stringify({ gateway: { port: 18789 } }, null, 2));
expect(loadConfig()).toMatchObject({ gateway: { port: 18789 } });
await fs.writeFile(configPath, JSON.stringify({ gateway: { port: "still-bad" } }, null, 2));
expect(loadConfig()).toEqual({});
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<typeof import("./validation.js")>();
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 });
}
});
});

View File

@ -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";
@ -81,7 +82,12 @@ const OPEN_DM_POLICY_ALLOW_FROM_RE =
/^(?<policyPath>[a-z0-9_.-]+)\s*=\s*"open"\s+requires\s+(?<allowPath>[a-z0-9_.-]+)(?:\s+\(or\s+[a-z0-9_.-]+\))?\s+to include "\*"$/i;
const CONFIG_AUDIT_LOG_FILENAME = "config-audit.jsonl";
const loggedInvalidConfigs = new Set<string>();
type ConfigLogFingerprintState = {
invalid?: string;
warnings?: string;
};
const configLogFingerprintByPath = new Map<string, ConfigLogFingerprintState>();
type ConfigWriteAuditResult = "rename" | "copy-fallback" | "failed";
@ -544,19 +550,73 @@ export type ConfigIoDeps = {
logger?: Pick<typeof console, "error" | "warn">;
};
function warnOnConfigMiskeys(raw: unknown, logger: Pick<typeof console, "warn">): void {
if (!raw || typeof raw !== "object") {
function getConfigLogFingerprintState(configPath: string): ConfigLogFingerprintState {
let state = configLogFingerprintByPath.get(configPath);
if (!state) {
state = {};
configLogFingerprintByPath.set(configPath, state);
}
return state;
}
function logConfigMessageOnce(params: {
configPath: string;
kind: keyof ConfigLogFingerprintState;
message: string;
logger: Pick<typeof console, "error" | "warn">;
}): void {
const state = getConfigLogFingerprintState(params.configPath);
const fingerprint = hashConfigRaw(`${params.kind}:${params.message}`);
if (state[params.kind] === fingerprint) {
return;
}
state[params.kind] = fingerprint;
if (params.kind === "invalid") {
params.logger.error(params.message);
return;
}
params.logger.warn(params.message);
}
function clearConfigMessageFingerprint(
configPath: string,
kind: keyof ConfigLogFingerprintState,
): void {
const state = configLogFingerprintByPath.get(configPath);
if (!state) {
return;
}
delete state[kind];
if (!state.invalid && !state.warnings) {
configLogFingerprintByPath.delete(configPath);
}
}
function formatConfigIssueDetails(issues: Array<{ path: string; message: string }>): string {
return issues
.map((iss) => {
const safePath = sanitizeTerminalText(iss.path || "<root>");
const safeMessage = sanitizeTerminalText(iss.message);
return `- ${safePath}: ${safeMessage}`;
})
.join("\n");
}
function getConfigMiskeyWarnings(raw: unknown): string[] {
const warnings: string[] = [];
if (!raw || typeof raw !== "object") {
return warnings;
}
const gateway = (raw as Record<string, unknown>).gateway;
if (!gateway || typeof gateway !== "object") {
return;
return warnings;
}
if ("token" in (gateway as Record<string, unknown>)) {
logger.warn(
warnings.push(
'Config uses "gateway.token". This key is ignored; use "gateway.auth.token" instead.',
);
}
return warnings;
}
function stampConfigVersion(cfg: OpenClawConfig): OpenClawConfig {
@ -571,20 +631,19 @@ function stampConfigVersion(cfg: OpenClawConfig): OpenClawConfig {
};
}
function warnIfConfigFromFuture(cfg: OpenClawConfig, logger: Pick<typeof console, "warn">): void {
function getFutureVersionWarning(cfg: OpenClawConfig): string | null {
const touched = cfg.meta?.lastTouchedVersion;
if (!touched) {
return;
return null;
}
const cmp = compareOpenClawVersions(VERSION, touched);
if (cmp === null) {
return;
return null;
}
if (cmp < 0) {
logger.warn(
`Config was last written by a newer OpenClaw (${touched}); current version is ${VERSION}.`,
);
return `Config was last written by a newer OpenClaw (${touched}); current version is ${VERSION}.`;
}
return null;
}
function resolveConfigPathForDeps(deps: Required<ConfigIoDeps>): string {
@ -683,6 +742,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
try {
maybeLoadDotEnvForConfig(deps.env);
if (!deps.fs.existsSync(configPath)) {
clearConfigMessageFingerprint(configPath, "invalid");
clearConfigMessageFingerprint(configPath, "warnings");
if (shouldEnableShellEnvFallback(deps.env) && !shouldDeferShellEnvFallback(deps.env)) {
loadShellEnvFallback({
enabled: true,
@ -700,8 +761,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
resolveConfigIncludesForRead(parsed, configPath, deps),
deps.env,
);
warnOnConfigMiskeys(resolvedConfig, deps.logger);
const warningMessages = getConfigMiskeyWarnings(resolvedConfig);
if (typeof resolvedConfig !== "object" || resolvedConfig === null) {
clearConfigMessageFingerprint(configPath, "invalid");
clearConfigMessageFingerprint(configPath, "warnings");
return {};
}
const preValidationDuplicates = findDuplicateAgentDirs(resolvedConfig as OpenClawConfig, {
@ -713,25 +776,40 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}
const validated = validateConfigObjectWithPlugins(resolvedConfig);
if (!validated.ok) {
const details = validated.issues
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
.join("\n");
if (!loggedInvalidConfigs.has(configPath)) {
loggedInvalidConfigs.add(configPath);
deps.logger.error(`Invalid config at ${configPath}:\\n${details}`);
}
const error = new Error(`Invalid config at ${configPath}:\n${details}`);
const details = formatConfigIssueDetails(validated.issues);
clearConfigMessageFingerprint(configPath, "warnings");
logConfigMessageOnce({
configPath,
kind: "invalid",
message: `Invalid config at ${sanitizeTerminalText(configPath)}:\\n${details}`,
logger: deps.logger,
});
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 || "<root>"}: ${iss.message}`)
.join("\n");
deps.logger.warn(`Config warnings:\\n${details}`);
const details = formatConfigIssueDetails(validated.warnings);
warningMessages.push(`Config warnings:\\n${details}`);
}
const futureVersionWarning = getFutureVersionWarning(validated.config);
if (futureVersionWarning) {
warningMessages.push(futureVersionWarning);
}
if (warningMessages.length > 0) {
logConfigMessageOnce({
configPath,
kind: "warnings",
message: warningMessages.join("\n"),
logger: deps.logger,
});
} else {
clearConfigMessageFingerprint(configPath, "warnings");
}
warnIfConfigFromFuture(validated.config, deps.logger);
const cfg = applyTalkConfigNormalization(
applyModelDefaults(
applyCompactionDefaults(
@ -948,7 +1026,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
};
}
warnIfConfigFromFuture(validated.config, deps.logger);
const futureVersionWarning = getFutureVersionWarning(validated.config);
const snapshotConfig = normalizeConfigPaths(
applyTalkApiKey(
applyTalkConfigNormalization(
@ -974,7 +1052,12 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
config: snapshotConfig,
hash,
issues: [],
warnings: validated.warnings,
warnings: futureVersionWarning
? [
...validated.warnings,
{ path: "meta.lastTouchedVersion", message: futureVersionWarning },
]
: validated.warnings,
legacyIssues,
},
envSnapshotForRestore: readResolution.envSnapshotForRestore,

View File

@ -1,9 +1,9 @@
import fs from "node:fs/promises";
// launchd applies ThrottleInterval to any rapid relaunch, including
// intentional gateway restarts. Keep it low so CLI restarts and forced
// reinstalls do not stall for a full minute.
export const LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS = 1;
// launchd applies ThrottleInterval to any rapid relaunch, including config-crash
// loops and clean supervised exits. Keep KeepAlive=true so intentional restarts
// still come back up, and use a higher throttle to slow unhealthy restart storms.
export const LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS = 30;
// launchd stores plist integer values in decimal; 0o077 renders as 63 (owner-only files).
export const LAUNCH_AGENT_UMASK_DECIMAL = 0o077;

View File

@ -189,7 +189,7 @@ describe("launchd install", () => {
expect(plist).toContain(`<string>${tmpDir}</string>`);
});
it("writes KeepAlive=true policy with restrictive umask", async () => {
it("writes KeepAlive policy with restrictive umask", async () => {
const env = createDefaultLaunchdEnv();
await installLaunchAgent({
env,
@ -201,7 +201,6 @@ describe("launchd install", () => {
const plist = state.files.get(plistPath) ?? "";
expect(plist).toContain("<key>KeepAlive</key>");
expect(plist).toContain("<true/>");
expect(plist).not.toContain("<key>SuccessfulExit</key>");
expect(plist).toContain("<key>Umask</key>");
expect(plist).toContain(`<integer>${LAUNCH_AGENT_UMASK_DECIMAL}</integer>`);
expect(plist).toContain("<key>ThrottleInterval</key>");

View File

@ -171,7 +171,10 @@ async function auditLaunchdPlist(
}
const hasRunAtLoad = /<key>RunAtLoad<\/key>\s*<true\s*\/>/i.test(content);
const hasKeepAlive = /<key>KeepAlive<\/key>\s*<true\s*\/>/i.test(content);
const hasKeepAlive =
/<key>KeepAlive<\/key>\s*(?:<true\s*\/>|<dict>[\s\S]*?<key>SuccessfulExit<\/key>\s*<false\s*\/>[\s\S]*?<\/dict>)/i.test(
content,
);
if (!hasRunAtLoad) {
issues.push({
code: SERVICE_AUDIT_CODES.launchdRunAtLoad,

View File

@ -21,6 +21,16 @@ describe("buildSystemdUnit", () => {
expect(unit).toContain("KillMode=control-group");
});
it("keeps restart always for supervised restarts", () => {
const unit = buildSystemdUnit({
description: "OpenClaw Gateway",
programArguments: ["/usr/bin/openclaw", "gateway", "run"],
environment: {},
});
expect(unit).toContain("Restart=always");
expect(unit).not.toContain("Restart=on-failure");
});
it("rejects environment values with line breaks", () => {
expect(() =>
buildSystemdUnit({

View File

@ -58,6 +58,8 @@ export function buildSystemdUnit({
"[Service]",
`ExecStart=${execStart}`,
"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.

View File

@ -415,4 +415,56 @@ describe("startGatewayConfigReloader", () => {
await reloader.stop();
}
});
it("dedupes unchanged invalid snapshots and re-logs when the invalid payload changes", async () => {
const readSnapshot = vi
.fn<() => Promise<ConfigFileSnapshot>>()
.mockResolvedValueOnce(
makeSnapshot({
valid: false,
issues: [
{ path: "plugins.entries.discord", message: "plugin disabled but config is present" },
],
hash: "invalid-1",
}),
)
.mockResolvedValueOnce(
makeSnapshot({
valid: false,
issues: [
{ path: "plugins.entries.discord", message: "plugin disabled but config is present" },
],
hash: "invalid-1",
}),
)
.mockResolvedValueOnce(
makeSnapshot({
valid: false,
issues: [
{ path: "plugins.entries.slack", message: "plugin disabled but config is present" },
],
hash: "invalid-2",
}),
);
const { watcher, log, reloader } = createReloaderHarness(readSnapshot);
watcher.emit("change");
await vi.runOnlyPendingTimersAsync();
watcher.emit("change");
await vi.runOnlyPendingTimersAsync();
watcher.emit("change");
await vi.runOnlyPendingTimersAsync();
expect(log.warn).toHaveBeenCalledTimes(2);
expect(log.warn).toHaveBeenNthCalledWith(
1,
"config reload skipped (invalid config): plugins.entries.discord: plugin disabled but config is present",
);
expect(log.warn).toHaveBeenNthCalledWith(
2,
"config reload skipped (invalid config): plugins.entries.slack: plugin disabled but config is present",
);
await reloader.stop();
});
});

View File

@ -89,6 +89,7 @@ export function startGatewayConfigReloader(opts: {
let stopped = false;
let restartQueued = false;
let missingConfigRetries = 0;
let invalidSnapshotFingerprint: string | null = null;
const scheduleAfter = (wait: number) => {
if (stopped) {
@ -140,10 +141,15 @@ export function startGatewayConfigReloader(opts: {
const handleInvalidSnapshot = (snapshot: ConfigFileSnapshot): boolean => {
if (snapshot.valid) {
invalidSnapshotFingerprint = null;
return false;
}
const issues = formatConfigIssueLines(snapshot.issues, "").join(", ");
opts.log.warn(`config reload skipped (invalid config): ${issues}`);
const fingerprint = snapshot.hash ?? issues;
if (invalidSnapshotFingerprint !== fingerprint) {
invalidSnapshotFingerprint = fingerprint;
opts.log.warn(`config reload skipped (invalid config): ${issues}`);
}
return true;
};