Compare commits
7 Commits
main
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81c6e1ed92 | ||
|
|
2d84590373 | ||
|
|
bc3565f5f4 | ||
|
|
c404d723c8 | ||
|
|
5395ec88d0 | ||
|
|
bcb8475691 | ||
|
|
c026374fb1 |
@ -15,6 +15,10 @@ OpenClaw has two log “surfaces”:
|
|||||||
- **Console output** (what you see in the terminal / Debug UI).
|
- **Console output** (what you see in the terminal / Debug UI).
|
||||||
- **File logs** (JSON lines) written by the gateway logger.
|
- **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
|
## File-based logger
|
||||||
|
|
||||||
- Default rolling log file is under `/tmp/openclaw/` (one file per day): `openclaw-YYYY-MM-DD.log`
|
- 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.
|
raise the file log level.
|
||||||
- To capture verbose-only details in file logs, set `logging.level` to `debug` or
|
- To capture verbose-only details in file logs, set `logging.level` to `debug` or
|
||||||
`trace`.
|
`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
|
## Console capture
|
||||||
|
|
||||||
|
|||||||
@ -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/...`)
|
- 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`
|
- Linux: `journalctl --user -u openclaw-gateway[-<profile>].service -n 200 --no-pager`
|
||||||
- Windows: `schtasks /Query /TN "OpenClaw Gateway (<profile>)" /V /FO LIST`
|
- 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.
|
See [Troubleshooting](/gateway/troubleshooting#log-locations) for more.
|
||||||
|
|
||||||
|
|||||||
149
src/config/io.logging.test.ts
Normal file
149
src/config/io.logging.test.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
137
src/config/io.ts
137
src/config/io.ts
@ -13,6 +13,7 @@ import {
|
|||||||
shouldDeferShellEnvFallback,
|
shouldDeferShellEnvFallback,
|
||||||
shouldEnableShellEnvFallback,
|
shouldEnableShellEnvFallback,
|
||||||
} from "../infra/shell-env.js";
|
} from "../infra/shell-env.js";
|
||||||
|
import { sanitizeTerminalText } from "../terminal/safe-text.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js";
|
import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js";
|
||||||
import { maintainConfigBackups } from "./backup-rotation.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;
|
/^(?<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 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";
|
type ConfigWriteAuditResult = "rename" | "copy-fallback" | "failed";
|
||||||
|
|
||||||
@ -544,19 +550,73 @@ export type ConfigIoDeps = {
|
|||||||
logger?: Pick<typeof console, "error" | "warn">;
|
logger?: Pick<typeof console, "error" | "warn">;
|
||||||
};
|
};
|
||||||
|
|
||||||
function warnOnConfigMiskeys(raw: unknown, logger: Pick<typeof console, "warn">): void {
|
function getConfigLogFingerprintState(configPath: string): ConfigLogFingerprintState {
|
||||||
if (!raw || typeof raw !== "object") {
|
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;
|
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;
|
const gateway = (raw as Record<string, unknown>).gateway;
|
||||||
if (!gateway || typeof gateway !== "object") {
|
if (!gateway || typeof gateway !== "object") {
|
||||||
return;
|
return warnings;
|
||||||
}
|
}
|
||||||
if ("token" in (gateway as Record<string, unknown>)) {
|
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.',
|
'Config uses "gateway.token". This key is ignored; use "gateway.auth.token" instead.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return warnings;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stampConfigVersion(cfg: OpenClawConfig): OpenClawConfig {
|
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;
|
const touched = cfg.meta?.lastTouchedVersion;
|
||||||
if (!touched) {
|
if (!touched) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
const cmp = compareOpenClawVersions(VERSION, touched);
|
const cmp = compareOpenClawVersions(VERSION, touched);
|
||||||
if (cmp === null) {
|
if (cmp === null) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
if (cmp < 0) {
|
if (cmp < 0) {
|
||||||
logger.warn(
|
return `Config was last written by a newer OpenClaw (${touched}); current version is ${VERSION}.`;
|
||||||
`Config was last written by a newer OpenClaw (${touched}); current version is ${VERSION}.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveConfigPathForDeps(deps: Required<ConfigIoDeps>): string {
|
function resolveConfigPathForDeps(deps: Required<ConfigIoDeps>): string {
|
||||||
@ -683,6 +742,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
try {
|
try {
|
||||||
maybeLoadDotEnvForConfig(deps.env);
|
maybeLoadDotEnvForConfig(deps.env);
|
||||||
if (!deps.fs.existsSync(configPath)) {
|
if (!deps.fs.existsSync(configPath)) {
|
||||||
|
clearConfigMessageFingerprint(configPath, "invalid");
|
||||||
|
clearConfigMessageFingerprint(configPath, "warnings");
|
||||||
if (shouldEnableShellEnvFallback(deps.env) && !shouldDeferShellEnvFallback(deps.env)) {
|
if (shouldEnableShellEnvFallback(deps.env) && !shouldDeferShellEnvFallback(deps.env)) {
|
||||||
loadShellEnvFallback({
|
loadShellEnvFallback({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@ -700,8 +761,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
resolveConfigIncludesForRead(parsed, configPath, deps),
|
resolveConfigIncludesForRead(parsed, configPath, deps),
|
||||||
deps.env,
|
deps.env,
|
||||||
);
|
);
|
||||||
warnOnConfigMiskeys(resolvedConfig, deps.logger);
|
const warningMessages = getConfigMiskeyWarnings(resolvedConfig);
|
||||||
if (typeof resolvedConfig !== "object" || resolvedConfig === null) {
|
if (typeof resolvedConfig !== "object" || resolvedConfig === null) {
|
||||||
|
clearConfigMessageFingerprint(configPath, "invalid");
|
||||||
|
clearConfigMessageFingerprint(configPath, "warnings");
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
const preValidationDuplicates = findDuplicateAgentDirs(resolvedConfig as OpenClawConfig, {
|
const preValidationDuplicates = findDuplicateAgentDirs(resolvedConfig as OpenClawConfig, {
|
||||||
@ -713,25 +776,40 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
}
|
}
|
||||||
const validated = validateConfigObjectWithPlugins(resolvedConfig);
|
const validated = validateConfigObjectWithPlugins(resolvedConfig);
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
const details = validated.issues
|
const details = formatConfigIssueDetails(validated.issues);
|
||||||
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
|
clearConfigMessageFingerprint(configPath, "warnings");
|
||||||
.join("\n");
|
logConfigMessageOnce({
|
||||||
if (!loggedInvalidConfigs.has(configPath)) {
|
configPath,
|
||||||
loggedInvalidConfigs.add(configPath);
|
kind: "invalid",
|
||||||
deps.logger.error(`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 }).code = "INVALID_CONFIG";
|
||||||
(error as { code?: string; details?: string }).details = details;
|
(error as { code?: string; details?: string }).details = details;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
clearConfigMessageFingerprint(configPath, "invalid");
|
||||||
if (validated.warnings.length > 0) {
|
if (validated.warnings.length > 0) {
|
||||||
const details = validated.warnings
|
const details = formatConfigIssueDetails(validated.warnings);
|
||||||
.map((iss) => `- ${iss.path || "<root>"}: ${iss.message}`)
|
warningMessages.push(`Config warnings:\\n${details}`);
|
||||||
.join("\n");
|
}
|
||||||
deps.logger.warn(`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(
|
const cfg = applyTalkConfigNormalization(
|
||||||
applyModelDefaults(
|
applyModelDefaults(
|
||||||
applyCompactionDefaults(
|
applyCompactionDefaults(
|
||||||
@ -948,7 +1026,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
warnIfConfigFromFuture(validated.config, deps.logger);
|
const futureVersionWarning = getFutureVersionWarning(validated.config);
|
||||||
const snapshotConfig = normalizeConfigPaths(
|
const snapshotConfig = normalizeConfigPaths(
|
||||||
applyTalkApiKey(
|
applyTalkApiKey(
|
||||||
applyTalkConfigNormalization(
|
applyTalkConfigNormalization(
|
||||||
@ -974,7 +1052,12 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
config: snapshotConfig,
|
config: snapshotConfig,
|
||||||
hash,
|
hash,
|
||||||
issues: [],
|
issues: [],
|
||||||
warnings: validated.warnings,
|
warnings: futureVersionWarning
|
||||||
|
? [
|
||||||
|
...validated.warnings,
|
||||||
|
{ path: "meta.lastTouchedVersion", message: futureVersionWarning },
|
||||||
|
]
|
||||||
|
: validated.warnings,
|
||||||
legacyIssues,
|
legacyIssues,
|
||||||
},
|
},
|
||||||
envSnapshotForRestore: readResolution.envSnapshotForRestore,
|
envSnapshotForRestore: readResolution.envSnapshotForRestore,
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
// launchd applies ThrottleInterval to any rapid relaunch, including
|
// launchd applies ThrottleInterval to any rapid relaunch, including config-crash
|
||||||
// intentional gateway restarts. Keep it low so CLI restarts and forced
|
// loops and clean supervised exits. Keep KeepAlive=true so intentional restarts
|
||||||
// reinstalls do not stall for a full minute.
|
// still come back up, and use a higher throttle to slow unhealthy restart storms.
|
||||||
export const LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS = 1;
|
export const LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS = 30;
|
||||||
// launchd stores plist integer values in decimal; 0o077 renders as 63 (owner-only files).
|
// launchd stores plist integer values in decimal; 0o077 renders as 63 (owner-only files).
|
||||||
export const LAUNCH_AGENT_UMASK_DECIMAL = 0o077;
|
export const LAUNCH_AGENT_UMASK_DECIMAL = 0o077;
|
||||||
|
|
||||||
|
|||||||
@ -189,7 +189,7 @@ describe("launchd install", () => {
|
|||||||
expect(plist).toContain(`<string>${tmpDir}</string>`);
|
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();
|
const env = createDefaultLaunchdEnv();
|
||||||
await installLaunchAgent({
|
await installLaunchAgent({
|
||||||
env,
|
env,
|
||||||
@ -201,7 +201,6 @@ describe("launchd install", () => {
|
|||||||
const plist = state.files.get(plistPath) ?? "";
|
const plist = state.files.get(plistPath) ?? "";
|
||||||
expect(plist).toContain("<key>KeepAlive</key>");
|
expect(plist).toContain("<key>KeepAlive</key>");
|
||||||
expect(plist).toContain("<true/>");
|
expect(plist).toContain("<true/>");
|
||||||
expect(plist).not.toContain("<key>SuccessfulExit</key>");
|
|
||||||
expect(plist).toContain("<key>Umask</key>");
|
expect(plist).toContain("<key>Umask</key>");
|
||||||
expect(plist).toContain(`<integer>${LAUNCH_AGENT_UMASK_DECIMAL}</integer>`);
|
expect(plist).toContain(`<integer>${LAUNCH_AGENT_UMASK_DECIMAL}</integer>`);
|
||||||
expect(plist).toContain("<key>ThrottleInterval</key>");
|
expect(plist).toContain("<key>ThrottleInterval</key>");
|
||||||
|
|||||||
@ -171,7 +171,10 @@ async function auditLaunchdPlist(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasRunAtLoad = /<key>RunAtLoad<\/key>\s*<true\s*\/>/i.test(content);
|
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) {
|
if (!hasRunAtLoad) {
|
||||||
issues.push({
|
issues.push({
|
||||||
code: SERVICE_AUDIT_CODES.launchdRunAtLoad,
|
code: SERVICE_AUDIT_CODES.launchdRunAtLoad,
|
||||||
|
|||||||
@ -21,6 +21,16 @@ describe("buildSystemdUnit", () => {
|
|||||||
expect(unit).toContain("KillMode=control-group");
|
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", () => {
|
it("rejects environment values with line breaks", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
buildSystemdUnit({
|
buildSystemdUnit({
|
||||||
|
|||||||
@ -58,6 +58,8 @@ export function buildSystemdUnit({
|
|||||||
"[Service]",
|
"[Service]",
|
||||||
`ExecStart=${execStart}`,
|
`ExecStart=${execStart}`,
|
||||||
"Restart=always",
|
"Restart=always",
|
||||||
|
// systemd already has burst protection; keep a shorter retry than launchd so
|
||||||
|
// supervised restart requests still relaunch promptly on Linux.
|
||||||
"RestartSec=5",
|
"RestartSec=5",
|
||||||
// Keep service children in the same lifecycle so restarts do not leave
|
// Keep service children in the same lifecycle so restarts do not leave
|
||||||
// orphan ACP/runtime workers behind.
|
// orphan ACP/runtime workers behind.
|
||||||
|
|||||||
@ -415,4 +415,56 @@ describe("startGatewayConfigReloader", () => {
|
|||||||
await reloader.stop();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -89,6 +89,7 @@ export function startGatewayConfigReloader(opts: {
|
|||||||
let stopped = false;
|
let stopped = false;
|
||||||
let restartQueued = false;
|
let restartQueued = false;
|
||||||
let missingConfigRetries = 0;
|
let missingConfigRetries = 0;
|
||||||
|
let invalidSnapshotFingerprint: string | null = null;
|
||||||
|
|
||||||
const scheduleAfter = (wait: number) => {
|
const scheduleAfter = (wait: number) => {
|
||||||
if (stopped) {
|
if (stopped) {
|
||||||
@ -140,10 +141,15 @@ export function startGatewayConfigReloader(opts: {
|
|||||||
|
|
||||||
const handleInvalidSnapshot = (snapshot: ConfigFileSnapshot): boolean => {
|
const handleInvalidSnapshot = (snapshot: ConfigFileSnapshot): boolean => {
|
||||||
if (snapshot.valid) {
|
if (snapshot.valid) {
|
||||||
|
invalidSnapshotFingerprint = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const issues = formatConfigIssueLines(snapshot.issues, "").join(", ");
|
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;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user