fix: harden gateway launchd and configure sections

This commit is contained in:
Peter Steinberger
2026-05-17 03:30:53 +01:00
parent 524185a68e
commit ca236d098d
22 changed files with 514 additions and 118 deletions
+2
View File
@@ -42,6 +42,8 @@ Docs: https://docs.openclaw.ai
- Agents/auth: include the checked credential source in missing API key errors, so users can see which env var, profile, or config path to fix. Fixes #82785. Thanks @loeclos.
- Providers/GitHub Copilot: hash Responses replay item ids with sha256 instead of a weak 32-bit hash and build same-provider Copilot tool-call ids distinctly, so concurrent tool-call replays no longer collide and reject follow-up turns.
- Agents/replay: normalize malformed assistant replay content before transport conversion while preserving empty-stop replay repair, so bad provider history no longer crashes with non-iterable content. Fixes #43795. (#82748) Thanks @IWhatsskill.
- Gateway/macOS: write LaunchAgent stdout under `~/Library/Logs/openclaw`, suppress stderr, and attach stdin to `/dev/null` so launchd startup avoids symlinked state-dir log failures and silent module-evaluation hangs. Fixes #40207 and #46153. Thanks @dhruvkelawala and @frankr.
- CLI/configure: let model-only section setup enter provider auth directly instead of first asking where the Gateway runs, unblocking OAuth/token setup in terminals where that unrelated prompt is unresponsive. Fixes #39223. Thanks @LevityLeads.
- Providers/Anthropic-messages: extract `reasoning_content` from `thinking` blocks during assistant replay so proxy providers that route through the Anthropic-messages transport preserve reasoning context across tool-call follow-up turns. Thanks @Sunnyone2three.
- Agents/GitHub Copilot: normalize replayed Responses tool-call IDs before dispatch so resumed sessions with historical overlong tool IDs continue instead of failing Copilot schema validation. (#82750) Thanks @galiniliev.
- CLI/web: resolve provider-scoped web search/fetch SecretRefs for `infer web ... --provider ...` while leaving unrelated plugin secrets untouched. Fixes #82621. Thanks @leno23.
+1 -1
View File
@@ -55,7 +55,7 @@ Available sections:
Notes:
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
- The full wizard and gateway-related sections ask where the Gateway runs and update `gateway.mode`. Section filters that do not include `gateway`, `daemon`, or `health` go directly to the requested setup.
- After local config writes, configure installs selected downloadable plugins when the chosen setup path requires them. Remote gateway config does not install local plugin packages.
- Channel-oriented services (Slack/Discord/Matrix/Microsoft Teams) prompt for channel/room allowlists during setup. You can enter names or IDs; the wizard resolves names to IDs when possible.
- If you run the daemon install step, token auth requires a token, and `gateway.auth.token` is SecretRef-managed, configure validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata.
+1 -1
View File
@@ -1560,7 +1560,7 @@ lives on the [Models FAQ](/help/faq-models).
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 launchd stdout: `~/Library/Logs/openclaw/gateway.log` (profiles use `gateway-<profile>.log`; stderr is suppressed)
- Linux: `journalctl --user -u openclaw-gateway[-<profile>].service -n 200 --no-pager`
- Windows: `schtasks /Query /TN "OpenClaw Gateway (<profile>)" /V /FO LIST`
+2 -1
View File
@@ -49,7 +49,8 @@ Behavior:
Logging:
- launchd stdout/err: `/tmp/openclaw/openclaw-gateway.log`
- launchd stdout: `~/Library/Logs/openclaw/gateway.log` (profiles use `gateway-<profile>.log`)
- launchd stderr: suppressed
## Version compatibility
+45
View File
@@ -37,6 +37,11 @@ vi.mock("../../daemon/restart-logs.js", () => ({
stdoutPath: "/tmp/gateway.out.log",
stderrPath: "/tmp/gateway.err.log",
}),
resolveGatewaySupervisorLogPaths: () => ({
logDir: "/Users/test/Library/Logs/openclaw",
stdoutPath: "/Users/test/Library/Logs/openclaw/gateway.log",
stderrPath: "/Users/test/Library/Logs/openclaw/gateway.err.log",
}),
resolveGatewayRestartLogPath: () => "/tmp/gateway-restart.log",
}));
@@ -160,6 +165,46 @@ describe("printDaemonStatus", () => {
expectMockLineContains(runtime.error, formatCliCommand("openclaw gateway restart"));
});
it("prints macOS launchd stdout and suppressed stderr when gateway is not listening", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "darwin" });
try {
printDaemonStatus(
{
service: {
label: "LaunchAgent",
loaded: true,
loadedText: "loaded",
notLoadedText: "not loaded",
runtime: { status: "running", pid: 8000 },
command: { programArguments: [], environment: { HOME: "/Users/test" } },
},
gateway: {
bindMode: "loopback",
bindHost: "127.0.0.1",
port: 18789,
portSource: "env/config",
probeUrl: "ws://127.0.0.1:18789",
},
port: {
port: 18789,
status: "free",
listeners: [],
hints: [],
},
extraServices: [],
},
{ json: false },
);
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform });
}
expectMockLineContains(runtime.error, "Gateway port 18789 is not listening");
expectMockLineContains(runtime.error, "/Users/test/Library/Logs/openclaw/gateway.log");
expectMockLineContains(runtime.error, "Errors: suppressed");
});
it("prints probe kind and capability separately", () => {
printDaemonStatus(
{
+6 -3
View File
@@ -4,7 +4,10 @@ import {
resolveGatewaySystemdServiceName,
} from "../../daemon/constants.js";
import { renderGatewayServiceCleanupHints } from "../../daemon/inspect.js";
import { resolveGatewayLogPaths, resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js";
import {
resolveGatewayRestartLogPath,
resolveGatewaySupervisorLogPaths,
} from "../../daemon/restart-logs.js";
import {
isSystemdUnavailableDetail,
renderSystemdUnavailableHints,
@@ -393,9 +396,9 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
errorText(`Logs: journalctl --user -u ${unit}.service -n 200 --no-pager`),
);
} else if (process.platform === "darwin") {
const logs = resolveGatewayLogPaths(serviceEnv);
const logs = resolveGatewaySupervisorLogPaths(serviceEnv, { platform: "darwin" });
defaultRuntime.error(`${errorText("Logs:")} ${shortenHomePath(logs.stdoutPath)}`);
defaultRuntime.error(`${errorText("Errors:")} ${shortenHomePath(logs.stderrPath)}`);
defaultRuntime.error(`${errorText("Errors:")} suppressed`);
}
defaultRuntime.error(
`${errorText("Restart log:")} ${shortenHomePath(resolveGatewayRestartLogPath(serviceEnv))}`,
+60 -11
View File
@@ -26,6 +26,8 @@ const mocks = vi.hoisted(() => {
waitForGatewayReachable: vi.fn(),
resolveControlUiLinks: vi.fn(),
summarizeExistingConfig: vi.fn(),
promptAuthConfig: vi.fn(),
promptGatewayConfig: vi.fn(),
promptRemoteGatewayConfig: vi.fn(async (cfg: OpenClawConfig) => ({
...cfg,
gateway: { mode: "remote", remote: { url: "wss://gateway.example.test" } },
@@ -86,11 +88,11 @@ vi.mock("./health-format.js", () => ({
}));
vi.mock("./configure.gateway.js", () => ({
promptGatewayConfig: vi.fn(),
promptGatewayConfig: mocks.promptGatewayConfig,
}));
vi.mock("./configure.gateway-auth.js", () => ({
promptAuthConfig: vi.fn(),
promptAuthConfig: mocks.promptAuthConfig,
}));
vi.mock("./configure.channels.js", () => ({
@@ -276,6 +278,13 @@ describe("runConfigureWizard", () => {
]);
mocks.setupSearch.mockReset();
mocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) => cfg);
mocks.promptAuthConfig.mockReset();
mocks.promptAuthConfig.mockImplementation(async (cfg: OpenClawConfig) => cfg);
mocks.promptGatewayConfig.mockReset();
mocks.promptGatewayConfig.mockImplementation(async (cfg: OpenClawConfig) => ({
config: cfg,
port: 18789,
}));
});
it("persists gateway.mode=local when only the run mode is selected", async () => {
@@ -328,6 +337,46 @@ describe("runConfigureWizard", () => {
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("does not gate model-only configure behind Gateway run-mode selection", async () => {
setupBaseWizardState();
await runConfigureWizard({ command: "configure", sections: ["model"] }, createRuntime());
expect(mocks.promptAuthConfig).toHaveBeenCalledOnce();
expect(mocks.clackSelect).not.toHaveBeenCalledWith(
expect.objectContaining({ message: "Where will the Gateway run?" }),
);
expect(mocks.probeGatewayReachable).not.toHaveBeenCalledWith(
expect.objectContaining({ timeoutMs: 300 }),
);
expect(mocks.ensureControlUiAssetsBuilt).not.toHaveBeenCalled();
expect(mocks.resolveControlUiLinks).not.toHaveBeenCalled();
expect(requireWriteConfig().gateway).toBeUndefined();
});
it("runs model-only configure for existing remote Gateway configs", async () => {
setupBaseWizardState({
gateway: { mode: "remote", remote: { url: "wss://gateway.example.test" } },
});
await runConfigureWizard({ command: "configure", sections: ["model"] }, createRuntime());
expect(mocks.promptAuthConfig).toHaveBeenCalledOnce();
expect(mocks.promptRemoteGatewayConfig).not.toHaveBeenCalled();
expect(getGateway(requireWriteConfig()).mode).toBe("remote");
expect(mocks.ensureControlUiAssetsBuilt).not.toHaveBeenCalled();
expect(mocks.resolveControlUiLinks).not.toHaveBeenCalled();
expect(mocks.probeGatewayReachable).not.toHaveBeenCalled();
expect(mocks.note).toHaveBeenCalledWith(
[
"Remote Gateway:",
"wss://gateway.example.test",
"Docs: https://docs.openclaw.ai/gateway/remote",
].join("\n"),
"Gateway",
);
});
it("persists provider-owned web search config changes returned by setupSearch", async () => {
setupBaseWizardState();
mocks.setupSearch.mockImplementation(async (cfg: OpenClawConfig) =>
@@ -337,7 +386,7 @@ describe("runConfigureWizard", () => {
})(cfg),
);
queueWizardPrompts({
select: ["local"],
select: [],
confirm: [true, false],
});
@@ -347,7 +396,7 @@ describe("runConfigureWizard", () => {
mockCallArg(mocks.setupSearch, "setupSearch"),
"setupSearch config",
);
expect(getGateway(setupConfig).mode).toBe("local");
expect(setupConfig.gateway).toBeUndefined();
const written = requireWriteConfig();
const search = getWebSearch(written);
expect(search.provider).toBe("firecrawl");
@@ -365,7 +414,7 @@ describe("runConfigureWizard", () => {
setupBaseWizardState();
mocks.resolveSearchProviderOptions.mockReturnValue([]);
queueWizardPrompts({
select: ["local"],
select: [],
confirm: [true, false],
});
@@ -385,7 +434,7 @@ describe("runConfigureWizard", () => {
it("does not load managed search provider options when web search is disabled", async () => {
setupBaseWizardState();
queueWizardPrompts({
select: ["local"],
select: [],
confirm: [false, true],
});
@@ -404,7 +453,7 @@ describe("runConfigureWizard", () => {
it("defers channel status checks until a channel is selected", async () => {
setupBaseWizardState();
queueWizardPrompts({
select: ["local", "configure"],
select: ["configure"],
confirm: [],
});
@@ -412,7 +461,7 @@ describe("runConfigureWizard", () => {
const setupChannelsCall = mocks.setupChannels.mock.calls[0] as Array<unknown> | undefined;
const setupChannelsConfig = requireRecord(setupChannelsCall?.[0], "setupChannels config");
expect(getGateway(setupChannelsConfig).mode).toBe("local");
expect(setupChannelsConfig.gateway).toBeUndefined();
const setupChannelsOptions = requireRecord(setupChannelsCall?.[3], "setupChannels options");
expect(setupChannelsOptions.deferStatusUntilSelection).toBe(true);
expect(setupChannelsOptions.skipStatusNote).toBe(true);
@@ -439,7 +488,7 @@ describe("runConfigureWizard", () => {
})(cfg),
);
queueWizardPrompts({
select: ["local"],
select: [],
confirm: [true, false],
});
@@ -461,7 +510,7 @@ describe("runConfigureWizard", () => {
},
});
queueWizardPrompts({
select: ["local", "cached"],
select: ["cached"],
confirm: [true, true, false, true],
});
@@ -527,7 +576,7 @@ describe("runConfigureWizard", () => {
};
setupBaseWizardState(baseConfig);
queueWizardPrompts({
select: ["local"],
select: [],
confirm: [],
});
+93 -66
View File
@@ -52,6 +52,7 @@ import {
} from "./onboard-helpers.js";
import { promptRemoteGatewayConfig } from "./onboard-remote.js";
import { setupSkills } from "./onboard-skills.js";
import type { OnboardMode } from "./onboard-types.js";
type ConfigureSectionChoice = WizardSection | "__continue";
type SetupPluginConfigModule = typeof import("../wizard/setup.plugin-config.js");
@@ -406,74 +407,86 @@ export async function runConfigureWizard(
}
}
const localUrl = "ws://127.0.0.1:18789";
const remoteUrl = normalizeOptionalString(baseConfig.gateway?.remote?.url) ?? "";
const localProbePromise = (async () => {
const [baseLocalProbeToken, baseLocalProbePassword] = await Promise.all([
resolveGatewaySecretInputForWizard({
cfg: baseConfig,
value: baseConfig.gateway?.auth?.token,
path: "gateway.auth.token",
}),
resolveGatewaySecretInputForWizard({
cfg: baseConfig,
value: baseConfig.gateway?.auth?.password,
path: "gateway.auth.password",
}),
]);
return probeGatewayReachable({
url: localUrl,
token: process.env.OPENCLAW_GATEWAY_TOKEN ?? baseLocalProbeToken,
password: process.env.OPENCLAW_GATEWAY_PASSWORD ?? baseLocalProbePassword,
timeoutMs: GATEWAY_HINT_PROBE_TIMEOUT_MS,
});
})();
const remoteProbePromise = remoteUrl
? (async () => {
const baseRemoteProbeToken = await resolveGatewaySecretInputForWizard({
const selectedSections = opts.sections;
const shouldPromptGatewayRunMode =
!selectedSections ||
selectedSections.includes("gateway") ||
selectedSections.includes("daemon") ||
selectedSections.includes("health");
const promptGatewayRunMode = async (): Promise<OnboardMode> => {
const localUrl = "ws://127.0.0.1:18789";
const remoteUrl = normalizeOptionalString(baseConfig.gateway?.remote?.url) ?? "";
const localProbePromise = (async () => {
const [baseLocalProbeToken, baseLocalProbePassword] = await Promise.all([
resolveGatewaySecretInputForWizard({
cfg: baseConfig,
value: baseConfig.gateway?.remote?.token,
path: "gateway.remote.token",
});
return probeGatewayReachable({
url: remoteUrl,
token: baseRemoteProbeToken,
timeoutMs: GATEWAY_HINT_PROBE_TIMEOUT_MS,
});
})()
: Promise.resolve(null);
const [localProbe, remoteProbe] = await Promise.all([localProbePromise, remoteProbePromise]);
value: baseConfig.gateway?.auth?.token,
path: "gateway.auth.token",
}),
resolveGatewaySecretInputForWizard({
cfg: baseConfig,
value: baseConfig.gateway?.auth?.password,
path: "gateway.auth.password",
}),
]);
return probeGatewayReachable({
url: localUrl,
token: process.env.OPENCLAW_GATEWAY_TOKEN ?? baseLocalProbeToken,
password: process.env.OPENCLAW_GATEWAY_PASSWORD ?? baseLocalProbePassword,
timeoutMs: GATEWAY_HINT_PROBE_TIMEOUT_MS,
});
})();
const remoteProbePromise = remoteUrl
? (async () => {
const baseRemoteProbeToken = await resolveGatewaySecretInputForWizard({
cfg: baseConfig,
value: baseConfig.gateway?.remote?.token,
path: "gateway.remote.token",
});
return probeGatewayReachable({
url: remoteUrl,
token: baseRemoteProbeToken,
timeoutMs: GATEWAY_HINT_PROBE_TIMEOUT_MS,
});
})()
: Promise.resolve(null);
const [localProbe, remoteProbe] = await Promise.all([localProbePromise, remoteProbePromise]);
return guardCancel(
await select({
message: "Where will the Gateway run?",
options: [
{
value: "local",
label: "Local (this machine)",
hint: localProbe.ok
? `Gateway reachable (${localUrl})`
: `No gateway detected (${localUrl})`,
},
{
value: "remote",
label: "Remote (info-only)",
hint: !remoteUrl
? "No remote URL configured yet"
: remoteProbe?.ok
? `Gateway reachable (${remoteUrl})`
: `Configured but unreachable (${remoteUrl})`,
},
],
}),
runtime,
);
};
const mode = guardCancel(
await select({
message: "Where will the Gateway run?",
options: [
{
value: "local",
label: "Local (this machine)",
hint: localProbe.ok
? `Gateway reachable (${localUrl})`
: `No gateway detected (${localUrl})`,
},
{
value: "remote",
label: "Remote (info-only)",
hint: !remoteUrl
? "No remote URL configured yet"
: remoteProbe?.ok
? `Gateway reachable (${remoteUrl})`
: `Configured but unreachable (${remoteUrl})`,
},
],
}),
runtime,
);
const mode = shouldPromptGatewayRunMode ? await promptGatewayRunMode() : "local";
const metadataMode: OnboardMode =
shouldPromptGatewayRunMode || baseConfig.gateway?.mode !== "remote" ? mode : "remote";
const shouldSkipGatewaySummary = !shouldPromptGatewayRunMode;
if (mode === "remote") {
if (shouldPromptGatewayRunMode && mode === "remote") {
let remoteConfig = await promptRemoteGatewayConfig(baseConfig, prompter);
remoteConfig = applyWizardMetadata(remoteConfig, {
command: opts.command,
mode,
mode: metadataMode,
});
const committed = await commitConfigWithPendingPluginInstalls({
nextConfig: remoteConfig,
@@ -489,7 +502,7 @@ export async function runConfigureWizard(
let nextConfig = { ...baseConfig };
let mergeBaseConfig = structuredClone(baseConfig);
let didSetGatewayMode = false;
if (nextConfig.gateway?.mode !== "local") {
if (shouldPromptGatewayRunMode && nextConfig.gateway?.mode !== "local") {
nextConfig = {
...nextConfig,
gateway: {
@@ -508,7 +521,7 @@ export async function runConfigureWizard(
const persistConfig = async () => {
nextConfig = applyWizardMetadata(nextConfig, {
command: opts.command,
mode,
mode: metadataMode,
});
// Retry loop: if config was mutated by a plugin, re-read and merge before retry
@@ -630,8 +643,8 @@ export async function runConfigureWizard(
gatewayPort = Number.parseInt(portInput, 10);
};
if (opts.sections) {
const selected = opts.sections;
if (selectedSections) {
const selected = selectedSections;
if (!selected || selected.length === 0) {
outro("No configuration changes selected.");
return;
@@ -767,6 +780,20 @@ export async function runConfigureWizard(
}
}
if (shouldSkipGatewaySummary) {
const remoteUrl = normalizeOptionalString(nextConfig.gateway?.remote?.url);
if (remoteUrl) {
note(
["Remote Gateway:", remoteUrl, "Docs: https://docs.openclaw.ai/gateway/remote"].join(
"\n",
),
"Gateway",
);
}
outro("Configuration updated.");
return;
}
const controlUiAssets = await ensureControlUiAssetsBuilt(runtime);
if (!controlUiAssets.ok && controlUiAssets.message) {
runtime.error(controlUiAssets.message);
+13 -6
View File
@@ -11,6 +11,9 @@ const restartLogMocks = vi.hoisted(() => ({
resolveGatewayLogPaths: vi.fn<() => GatewayLogPaths>(() => {
throw new Error("skip log tail");
}),
resolveGatewaySupervisorLogPaths: vi.fn<() => GatewayLogPaths>(() => {
throw new Error("skip log tail");
}),
resolveGatewayRestartLogPath: vi.fn<() => string>(() => "/tmp/gateway-restart.log"),
}));
@@ -25,6 +28,7 @@ const gatewayMocks = vi.hoisted(() => ({
vi.mock("../../daemon/restart-logs.js", () => ({
resolveGatewayLogPaths: restartLogMocks.resolveGatewayLogPaths,
resolveGatewaySupervisorLogPaths: restartLogMocks.resolveGatewaySupervisorLogPaths,
resolveGatewayRestartLogPath: restartLogMocks.resolveGatewayRestartLogPath,
}));
@@ -87,6 +91,9 @@ describe("status-all diagnosis port checks", () => {
restartLogMocks.resolveGatewayLogPaths.mockImplementation(() => {
throw new Error("skip log tail");
});
restartLogMocks.resolveGatewaySupervisorLogPaths.mockImplementation(() => {
throw new Error("skip log tail");
});
restartLogMocks.resolveGatewayRestartLogPath.mockReturnValue("/tmp/gateway-restart.log");
gatewayMocks.readFileTailLines.mockResolvedValue([]);
gatewayMocks.summarizeLogTail.mockImplementation((lines: string[]) => lines);
@@ -232,10 +239,10 @@ describe("status-all diagnosis port checks", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "darwin" });
try {
restartLogMocks.resolveGatewayLogPaths.mockReturnValue({
logDir: "/tmp/openclaw/logs",
stdoutPath: "/tmp/openclaw/logs/gateway.log",
stderrPath: "/tmp/openclaw/logs/gateway.err.log",
restartLogMocks.resolveGatewaySupervisorLogPaths.mockReturnValue({
logDir: "/Users/test/Library/Logs/openclaw",
stdoutPath: "/Users/test/Library/Logs/openclaw/gateway.log",
stderrPath: "/Users/test/Library/Logs/openclaw/gateway.err.log",
});
restartLogMocks.resolveGatewayRestartLogPath.mockReturnValue(
"/tmp/openclaw/logs/gateway-restart.log",
@@ -255,10 +262,10 @@ describe("status-all diagnosis port checks", () => {
const output = params.lines.join("\n");
expect(gatewayMocks.readFileTailLines).not.toHaveBeenCalledWith(
"/tmp/openclaw/logs/gateway.err.log",
"/Users/test/Library/Logs/openclaw/gateway.err.log",
40,
);
expect(output).toContain("# stdout: /tmp/openclaw/logs/gateway.log");
expect(output).toContain("# stdout: /Users/test/Library/Logs/openclaw/gateway.log");
expect(output).toContain("gateway stdout current");
expect(output).not.toContain("# stderr:");
expect(output).not.toContain("failed to bind gateway socket stale");
+8 -2
View File
@@ -1,6 +1,10 @@
import type { ProgressReporter } from "../../cli/progress.js";
import { formatConfigIssueLine } from "../../config/issue-format.js";
import { resolveGatewayLogPaths, resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js";
import {
resolveGatewayLogPaths,
resolveGatewayRestartLogPath,
resolveGatewaySupervisorLogPaths,
} from "../../daemon/restart-logs.js";
import {
formatPortDiagnostics,
isDualStackLoopbackGatewayListeners,
@@ -231,7 +235,9 @@ export async function appendStatusAllDiagnosis(params: {
params.progress.setLabel("Reading logs…");
const logPaths = (() => {
try {
return resolveGatewayLogPaths(process.env);
return process.platform === "darwin"
? resolveGatewaySupervisorLogPaths(process.env, { platform: "darwin" })
: resolveGatewayLogPaths(process.env);
} catch {
return null;
}
+9 -6
View File
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { readLastGatewayErrorLine } from "./diagnostics.js";
import { resolveGatewayLogPaths } from "./restart-logs.js";
import { resolveGatewayLogPaths, resolveGatewaySupervisorLogPaths } from "./restart-logs.js";
const tempDirs: string[] = [];
@@ -22,11 +22,14 @@ function makeTempStateDir(): string {
describe("readLastGatewayErrorLine", () => {
it("ignores stale launchd stderr when stderr is suppressed", async () => {
const stateDir = makeTempStateDir();
const env = { OPENCLAW_STATE_DIR: stateDir };
const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env);
fs.mkdirSync(logDir, { recursive: true });
fs.writeFileSync(stderrPath, "failed to bind gateway socket stale\n", "utf8");
fs.writeFileSync(stdoutPath, "gateway stdout current\n", "utf8");
const homeDir = makeTempStateDir();
const env = { HOME: homeDir, OPENCLAW_STATE_DIR: stateDir };
const stateLogs = resolveGatewayLogPaths(env);
const launchdLogs = resolveGatewaySupervisorLogPaths(env, { platform: "darwin" });
fs.mkdirSync(stateLogs.logDir, { recursive: true });
fs.mkdirSync(launchdLogs.logDir, { recursive: true });
fs.writeFileSync(stateLogs.stderrPath, "failed to bind gateway socket stale\n", "utf8");
fs.writeFileSync(launchdLogs.stdoutPath, "gateway stdout current\n", "utf8");
await expect(readLastGatewayErrorLine(env, { platform: "darwin" })).resolves.toBe(
"gateway stdout current",
+7 -3
View File
@@ -1,5 +1,5 @@
import fs from "node:fs/promises";
import { resolveGatewayLogPaths } from "./restart-logs.js";
import { resolveGatewayLogPaths, resolveGatewaySupervisorLogPaths } from "./restart-logs.js";
const GATEWAY_LOG_ERROR_PATTERNS = [
/refusing to bind gateway/i,
@@ -28,8 +28,12 @@ export async function readLastGatewayErrorLine(
env: NodeJS.ProcessEnv,
options?: { platform?: NodeJS.Platform },
): Promise<string | null> {
const readStderr = (options?.platform ?? process.platform) !== "darwin";
const { stdoutPath, stderrPath } = resolveGatewayLogPaths(env);
const platform = options?.platform ?? process.platform;
const readStderr = platform !== "darwin";
const { stdoutPath, stderrPath } =
platform === "darwin"
? resolveGatewaySupervisorLogPaths(env, { platform })
: resolveGatewayLogPaths(env);
const stderrRaw = readStderr ? await fs.readFile(stderrPath, "utf8").catch(() => "") : "";
const stdoutRaw = await fs.readFile(stdoutPath, "utf8").catch(() => "");
const lines = [...stderrRaw.split(/\r?\n/), ...stdoutRaw.split(/\r?\n/)].map((line) =>
+2 -1
View File
@@ -9,6 +9,7 @@ export const LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS = 20;
// 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_PROCESS_TYPE = "Interactive";
export const LAUNCH_AGENT_STDIN_PATH = "/dev/null";
const plistEscape = (value: string): string =>
value
@@ -180,5 +181,5 @@ export function buildLaunchAgentPlist({
? `\n <key>Comment</key>\n <string>${plistEscape(comment.trim())}</string>`
: "";
const envXml = renderEnvDict(environment);
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n <dict>\n <key>Label</key>\n <string>${plistEscape(label)}</string>\n ${commentXml}\n <key>RunAtLoad</key>\n <true/>\n <key>KeepAlive</key>\n <true/>\n <key>ExitTimeOut</key>\n <integer>${LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS}</integer>\n <key>ProcessType</key>\n <string>${LAUNCH_AGENT_PROCESS_TYPE}</string>\n <key>ThrottleInterval</key>\n <integer>${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}</integer>\n <key>Umask</key>\n <integer>${LAUNCH_AGENT_UMASK_DECIMAL}</integer>\n <key>ProgramArguments</key>\n <array>${argsXml}\n </array>\n ${workingDirXml}\n <key>StandardOutPath</key>\n <string>${plistEscape(stdoutPath)}</string>\n <key>StandardErrorPath</key>\n <string>${plistEscape(stderrPath)}</string>${envXml}\n </dict>\n</plist>\n`;
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n <dict>\n <key>Label</key>\n <string>${plistEscape(label)}</string>\n ${commentXml}\n <key>RunAtLoad</key>\n <true/>\n <key>KeepAlive</key>\n <true/>\n <key>ExitTimeOut</key>\n <integer>${LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS}</integer>\n <key>ProcessType</key>\n <string>${LAUNCH_AGENT_PROCESS_TYPE}</string>\n <key>ThrottleInterval</key>\n <integer>${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}</integer>\n <key>Umask</key>\n <integer>${LAUNCH_AGENT_UMASK_DECIMAL}</integer>\n <key>ProgramArguments</key>\n <array>${argsXml}\n </array>\n ${workingDirXml}\n <key>StandardInPath</key>\n <string>${plistEscape(LAUNCH_AGENT_STDIN_PATH)}</string>\n <key>StandardOutPath</key>\n <string>${plistEscape(stdoutPath)}</string>\n <key>StandardErrorPath</key>\n <string>${plistEscape(stderrPath)}</string>${envXml}\n </dict>\n</plist>\n`;
}
@@ -94,6 +94,26 @@ describe("scheduleDetachedLaunchdRestartHandoff", () => {
expect(args[1]).not.toContain('basename "$service_target"');
});
it("bootouts and bootstraps for reload mode", () => {
spawnMock.mockReturnValue({ pid: 4242, unref: unrefMock });
scheduleDetachedLaunchdRestartHandoff({
env: {
HOME: "/Users/test",
OPENCLAW_PROFILE: "default",
},
mode: "reload",
waitForPid: 9876,
});
const [, args] = requireSpawnCall();
expect(args[1]).toContain("openclaw restart attempt source=launchd-handoff mode=reload");
expect(args[1]).toContain('launchctl enable "$service_target"');
expect(args[1]).toContain('launchctl bootout "$service_target"');
expect(args[1]).toContain('if launchctl bootstrap "$domain" "$plist_path"; then');
expect(args[1]).not.toContain('launchctl kickstart -k "$service_target"');
});
it("sanitizes restart helper environment overrides before spawning", () => {
spawnMock.mockReturnValue({ pid: 4242, unref: unrefMock });
+25 -1
View File
@@ -8,7 +8,7 @@ import { sanitizeForLog } from "../terminal/ansi.js";
import { resolveGatewayLaunchAgentLabel } from "./constants.js";
import { renderPosixRestartLogSetup } from "./restart-logs.js";
type LaunchdRestartHandoffMode = "kickstart" | "start-after-exit";
type LaunchdRestartHandoffMode = "kickstart" | "reload" | "start-after-exit";
type LaunchdRestartHandoffResult = {
ok: boolean;
@@ -150,6 +150,30 @@ exit "$status"
`;
}
if (mode === "reload") {
// Reloading is required after plist content changes; kickstart alone keeps
// launchd's already-loaded stdout/stderr/stdin paths.
return `service_target="$1"
domain="$2"
plist_path="$3"
${waitForCallerPid}
status=0
launchctl enable "$service_target"
launchctl bootout "$service_target" >/dev/null 2>&1 || true
if launchctl bootstrap "$domain" "$plist_path"; then
status=0
else
status=$?
fi
if [ "$status" -eq 0 ]; then
printf '[%s] openclaw restart done source=launchd-handoff mode=${mode}\\n' "$(date -u +%FT%TZ)" >&2
else
printf '[%s] openclaw restart failed source=launchd-handoff mode=${mode} status=%s\\n' "$(date -u +%FT%TZ)" "$status" >&2
fi
exit "$status"
`;
}
const verifyLaunchdReload = `print_retry_count="${START_AFTER_EXIT_PRINT_RETRY_COUNT}"
while [ "$print_retry_count" -gt 0 ]; do
if launchctl print "$service_target" >/dev/null 2>&1; then
+105
View File
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS,
LAUNCH_AGENT_PROCESS_TYPE,
LAUNCH_AGENT_STDIN_PATH,
LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS,
LAUNCH_AGENT_UMASK_DECIMAL,
} from "./launchd-plist.js";
@@ -751,6 +752,10 @@ describe("launchd install", () => {
const plist = state.files.get(plistPath) ?? "";
expect(plist).toContain("<key>KeepAlive</key>");
expect(plist).toContain("<true/>");
expect(plist).toContain("<key>StandardInPath</key>");
expect(plist).toContain(`<string>${LAUNCH_AGENT_STDIN_PATH}</string>`);
expect(plist).toContain("<key>StandardOutPath</key>");
expect(plist).toContain("<string>/Users/test/Library/Logs/openclaw/gateway.log</string>");
expect(plist).not.toContain("<key>SuccessfulExit</key>");
expect(plist).toContain("<key>ExitTimeOut</key>");
expect(plist).toContain(`<integer>${LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS}</integer>`);
@@ -792,7 +797,9 @@ describe("launchd install", () => {
});
const plist = state.files.get(plistPath) ?? "";
expect(plist).toContain("<key>StandardInPath</key>");
expect(plist).toContain("<key>StandardOutPath</key>");
expect(plist).toContain("<string>/Users/test/Library/Logs/openclaw/gateway.log</string>");
expect(plist).toContain("<key>StandardErrorPath</key>");
expect(plist).toContain("<string>/dev/null</string>");
expect(plist).toContain("<key>KeepAlive</key>");
@@ -1158,6 +1165,45 @@ describe("launchd install", () => {
expect(launchctlCommandNames()).not.toContain("bootstrap");
});
it("reloads launchd after rewriting an existing plist", async () => {
const env = {
...createDefaultLaunchdEnv(),
OPENCLAW_GATEWAY_PORT: "18789",
};
const plistPath = resolveLaunchAgentPlistPath(env);
state.files.set(
plistPath,
[
'<?xml version="1.0" encoding="UTF-8"?>',
'<plist version="1.0">',
" <dict>",
" <key>Label</key>",
" <string>ai.openclaw.gateway</string>",
" <key>ProgramArguments</key>",
" <array>",
" <string>node</string>",
" <string>gateway.js</string>",
" </array>",
" <key>StandardOutPath</key>",
" <string>/Users/test/.openclaw-default/logs/gateway.log</string>",
" </dict>",
"</plist>",
].join("\n"),
);
await restartLaunchAgent({
env,
stdout: new PassThrough(),
});
const plist = state.files.get(plistPath) ?? "";
expect(plist).toContain("<key>StandardInPath</key>");
expect(plist).toContain("<string>/dev/null</string>");
expect(plist).toContain("<string>/Users/test/Library/Logs/openclaw/gateway.log</string>");
expect(launchctlCommandNames()).toEqual(["enable", "bootout", "enable", "bootstrap"]);
expect(launchctlCommandNames()).not.toContain("kickstart");
});
it("uses the configured gateway port for stale cleanup", async () => {
const env = {
...createDefaultLaunchdEnv(),
@@ -1196,6 +1242,24 @@ describe("launchd install", () => {
...createDefaultLaunchdEnv(),
OPENCLAW_GATEWAY_PORT: "19002",
};
const plistPath = resolveLaunchAgentPlistPath(env);
const originalPlist = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<plist version="1.0">',
" <dict>",
" <key>Label</key>",
" <string>ai.openclaw.gateway</string>",
" <key>ProgramArguments</key>",
" <array>",
" <string>node</string>",
" <string>gateway.js</string>",
" </array>",
" <key>StandardOutPath</key>",
" <string>/Users/test/.openclaw-default/logs/gateway.log</string>",
" </dict>",
"</plist>",
].join("\n");
state.files.set(plistPath, originalPlist);
inspectPortUsage.mockResolvedValue({
port: 19002,
status: "busy",
@@ -1215,6 +1279,8 @@ describe("launchd install", () => {
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19002);
expect(inspectPortUsage).toHaveBeenCalledWith(19002);
expect(state.files.get(plistPath)).toBe(originalPlist);
expect(state.fileWrites).toHaveLength(0);
expect(launchctlCommandNames()).not.toContain("kickstart");
});
@@ -1305,6 +1371,45 @@ describe("launchd install", () => {
expect(state.launchctlCalls).toStrictEqual([]);
});
it("hands plist reload off when current LaunchAgent needs rewritten paths", async () => {
const env = createDefaultLaunchdEnv();
const plistPath = resolveLaunchAgentPlistPath(env);
launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReturnValue(true);
state.files.set(
plistPath,
[
'<?xml version="1.0" encoding="UTF-8"?>',
'<plist version="1.0">',
" <dict>",
" <key>Label</key>",
" <string>ai.openclaw.gateway</string>",
" <key>ProgramArguments</key>",
" <array>",
" <string>node</string>",
" <string>gateway.js</string>",
" </array>",
" <key>StandardOutPath</key>",
" <string>/Users/test/.openclaw-default/logs/gateway.log</string>",
" </dict>",
"</plist>",
].join("\n"),
);
const result = await restartLaunchAgent({
env,
stdout: new PassThrough(),
});
expect(result).toEqual({ outcome: "scheduled" });
expect(launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff).toHaveBeenCalledWith({
env,
mode: "reload",
waitForPid: process.pid,
});
expect(state.files.get(plistPath)).toContain("/Users/test/Library/Logs/openclaw/gateway.log");
expect(state.launchctlCalls).toStrictEqual([]);
});
it("surfaces detached handoff failures", async () => {
const env = createDefaultLaunchdEnv();
launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReturnValue(true);
+37 -8
View File
@@ -25,7 +25,7 @@ import {
} from "./launchd-restart-handoff.js";
import { formatLine, toPosixPath, writeFormattedLines } from "./output.js";
import { resolveGatewayStateDir, resolveHomeDir } from "./paths.js";
import { resolveGatewayLogPaths } from "./restart-logs.js";
import { resolveGatewaySupervisorLogPaths } from "./restart-logs.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
import type {
@@ -822,7 +822,7 @@ async function writeLaunchAgentPlist({
environment,
description,
}: Omit<GatewayServiceInstallArgs, "stdout">): Promise<{ plistPath: string; stdoutPath: string }> {
const { logDir, stdoutPath } = resolveGatewayLogPaths(env);
const { logDir, stdoutPath } = resolveGatewaySupervisorLogPaths(env, { platform: "darwin" });
await ensureSecureDirectory(logDir);
const domain = resolveGuiDomain();
@@ -925,13 +925,13 @@ async function rewriteLaunchAgentPlistForRestart({
env: GatewayServiceEnv;
label: string;
plistPath: string;
}): Promise<void> {
}): Promise<boolean> {
const existing = await readLaunchAgentProgramArgumentsFromFile(plistPath);
if (!existing?.programArguments.length) {
return;
return false;
}
const { logDir, stdoutPath } = resolveGatewayLogPaths(env);
const { logDir, stdoutPath } = resolveGatewaySupervisorLogPaths(env, { platform: "darwin" });
await ensureSecureDirectory(logDir);
const serviceDescription = resolveGatewayServiceDescription({
@@ -953,8 +953,13 @@ async function rewriteLaunchAgentPlistForRestart({
stderrPath: LAUNCH_AGENT_STDERR_PATH,
environment: prepared.inlineEnvironment,
});
const previousPlist = await fs.readFile(plistPath, "utf8").catch(() => "");
if (previousPlist === plist) {
return false;
}
await fs.writeFile(plistPath, plist, { encoding: "utf8", mode: LAUNCH_AGENT_PLIST_MODE });
await fs.chmod(plistPath, LAUNCH_AGENT_PLIST_MODE).catch(() => undefined);
return true;
}
async function ensureLaunchAgentLoadedAfterFailure(params: {
@@ -992,9 +997,14 @@ export async function restartLaunchAgent({
// detached handoff. A direct `kickstart -k` would terminate the caller before
// it can finish the restart command.
if (isCurrentProcessLaunchdServiceLabel(label)) {
const plistReloadNeeded = await rewriteLaunchAgentPlistForRestart({
env: serviceEnv,
label,
plistPath,
});
const handoff = scheduleDetachedLaunchdRestartHandoff({
env: serviceEnv,
mode: "kickstart",
mode: plistReloadNeeded ? "reload" : "kickstart",
waitForPid: process.pid,
});
if (!handoff.ok) {
@@ -1017,11 +1027,31 @@ export async function restartLaunchAgent({
);
}
}
const plistReloadNeeded = await rewriteLaunchAgentPlistForRestart({
env: serviceEnv,
label,
plistPath,
});
// `openclaw gateway restart` is an explicit operator request to bring the
// LaunchAgent back, so clear any persisted disabled state before restart.
await execLaunchctl(["enable", serviceTarget]);
if (plistReloadNeeded) {
const bootout = await execLaunchctl(["bootout", serviceTarget]);
if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) {
throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`);
}
await bootstrapLaunchAgentOrThrow({
domain,
serviceTarget,
plistPath,
actionHint: "openclaw gateway restart",
});
writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget);
return { outcome: "completed" };
}
const start = await execLaunchctl(["kickstart", "-k", serviceTarget]);
if (start.code === 0) {
writeLaunchAgentActionLine(stdout, "Restarted LaunchAgent", serviceTarget);
@@ -1033,8 +1063,7 @@ export async function restartLaunchAgent({
throw new Error(`launchctl kickstart failed: ${start.stderr || start.stdout}`.trim());
}
// If the service was previously booted out, re-register the plist and retry.
await rewriteLaunchAgentPlistForRestart({ env: serviceEnv, label, plistPath });
// If the service was previously booted out, re-register the rewritten plist and retry.
await bootstrapLaunchAgentOrThrow({
domain,
serviceTarget,
+30
View File
@@ -5,6 +5,7 @@ import {
renderPosixRestartLogSetup,
resolveGatewayLogPaths,
resolveGatewayRestartLogPath,
resolveGatewaySupervisorLogPaths,
} from "./restart-logs.js";
describe("restart log conventions", () => {
@@ -35,6 +36,35 @@ describe("restart log conventions", () => {
);
});
it("keeps macOS LaunchAgent stdout outside the state directory", () => {
const env = {
HOME: "/Users/test",
OPENCLAW_STATE_DIR: "/Volumes/External/openclaw",
};
expect(resolveGatewaySupervisorLogPaths(env, { platform: "darwin" })).toEqual({
logDir: "/Users/test/Library/Logs/openclaw",
stdoutPath: "/Users/test/Library/Logs/openclaw/gateway.log",
stderrPath: "/Users/test/Library/Logs/openclaw/gateway.err.log",
});
expect(resolveGatewayRestartLogPath(env)).toBe(
`/Volumes/External/openclaw/logs/${GATEWAY_RESTART_LOG_FILENAME}`,
);
});
it("keeps macOS LaunchAgent logs profile-aware in the shared user log directory", () => {
const env = {
HOME: "/Users/test",
OPENCLAW_PROFILE: "work",
};
expect(resolveGatewaySupervisorLogPaths(env, { platform: "darwin" })).toEqual({
logDir: "/Users/test/Library/Logs/openclaw",
stdoutPath: "/Users/test/Library/Logs/openclaw/gateway-work.log",
stderrPath: "/Users/test/Library/Logs/openclaw/gateway-work.err.log",
});
});
it("renders best-effort POSIX log setup with escaped paths", () => {
const setup = renderPosixRestartLogSetup({
HOME: "/Users/test's",
+37 -4
View File
@@ -1,18 +1,31 @@
import path from "node:path";
import { quoteCmdScriptArg } from "./cmd-argv.js";
import { resolveGatewayStateDir } from "./paths.js";
import { resolveGatewayProfileSuffix } from "./constants.js";
import { resolveGatewayStateDir, resolveHomeDir } from "./paths.js";
import type { GatewayServiceEnv } from "./service-types.js";
export const GATEWAY_RESTART_LOG_FILENAME = "gateway-restart.log";
export function resolveGatewayLogPaths(env: GatewayServiceEnv): {
export type GatewayLogPaths = {
logDir: string;
stdoutPath: string;
stderrPath: string;
} {
};
function resolveGatewayLogPrefix(env: GatewayServiceEnv): string {
return env.OPENCLAW_LOG_PREFIX?.trim() || "gateway";
}
function resolveMacLaunchAgentLogPrefix(env: GatewayServiceEnv): string {
return (
env.OPENCLAW_LOG_PREFIX?.trim() || `gateway${resolveGatewayProfileSuffix(env.OPENCLAW_PROFILE)}`
);
}
export function resolveGatewayLogPaths(env: GatewayServiceEnv): GatewayLogPaths {
const stateDir = resolveGatewayStateDir(env);
const logDir = path.join(stateDir, "logs");
const prefix = env.OPENCLAW_LOG_PREFIX?.trim() || "gateway";
const prefix = resolveGatewayLogPrefix(env);
return {
logDir,
stdoutPath: path.join(logDir, `${prefix}.log`),
@@ -20,6 +33,26 @@ export function resolveGatewayLogPaths(env: GatewayServiceEnv): {
};
}
export function resolveMacLaunchAgentLogPaths(env: GatewayServiceEnv): GatewayLogPaths {
const home = resolveHomeDir(env).replaceAll("\\", "/");
const logDir = path.posix.join(home, "Library", "Logs", "openclaw");
const prefix = resolveMacLaunchAgentLogPrefix(env);
return {
logDir,
stdoutPath: path.posix.join(logDir, `${prefix}.log`),
stderrPath: path.posix.join(logDir, `${prefix}.err.log`),
};
}
export function resolveGatewaySupervisorLogPaths(
env: GatewayServiceEnv,
options?: { platform?: NodeJS.Platform },
): GatewayLogPaths {
return (options?.platform ?? process.platform) === "darwin"
? resolveMacLaunchAgentLogPaths(env)
: resolveGatewayLogPaths(env);
}
export function resolveGatewayRestartLogPath(env: GatewayServiceEnv): string {
return path.join(resolveGatewayLogPaths(env).logDir, GATEWAY_RESTART_LOG_FILENAME);
}
+2 -1
View File
@@ -7,6 +7,7 @@ describe("buildPlatformRuntimeLogHints", () => {
buildPlatformRuntimeLogHints({
platform: "darwin",
env: {
HOME: "/Users/test",
OPENCLAW_STATE_DIR: "/tmp/openclaw-state",
OPENCLAW_LOG_PREFIX: "gateway",
},
@@ -14,7 +15,7 @@ describe("buildPlatformRuntimeLogHints", () => {
windowsTaskName: "OpenClaw Gateway",
}),
).toEqual([
"Launchd stdout (if installed): /tmp/openclaw-state/logs/gateway.log",
"Launchd stdout (if installed): /Users/test/Library/Logs/openclaw/gateway.log",
"Launchd stderr (if installed): suppressed",
"Restart attempts: /tmp/openclaw-state/logs/gateway-restart.log",
]);
+2 -2
View File
@@ -1,5 +1,5 @@
import { toPosixPath } from "./output.js";
import { resolveGatewayLogPaths, resolveGatewayRestartLogPath } from "./restart-logs.js";
import { resolveGatewayRestartLogPath, resolveGatewaySupervisorLogPaths } from "./restart-logs.js";
function toDarwinDisplayPath(value: string): string {
return toPosixPath(value).replace(/^[A-Za-z]:/, "");
@@ -14,7 +14,7 @@ export function buildPlatformRuntimeLogHints(params: {
const platform = params.platform ?? process.platform;
const env = { ...process.env, ...params.env };
if (platform === "darwin") {
const logs = resolveGatewayLogPaths(env);
const logs = resolveGatewaySupervisorLogPaths(env, { platform });
return [
`Launchd stdout (if installed): ${toDarwinDisplayPath(logs.stdoutPath)}`,
"Launchd stderr (if installed): suppressed",
@@ -5,12 +5,18 @@ const resolveGatewayLogPathsMock = vi.fn(() => ({
stdoutPath: "C:\\tmp\\openclaw-state\\logs\\gateway.log",
stderrPath: "C:\\tmp\\openclaw-state\\logs\\gateway.err.log",
}));
const resolveGatewaySupervisorLogPathsMock = vi.fn(() => ({
logDir: "C:\\Users\\test\\Library\\Logs\\openclaw",
stdoutPath: "C:\\Users\\test\\Library\\Logs\\openclaw\\gateway.log",
stderrPath: "C:\\Users\\test\\Library\\Logs\\openclaw\\gateway.err.log",
}));
const resolveGatewayRestartLogPathMock = vi.fn(
() => "C:\\tmp\\openclaw-state\\logs\\gateway-restart.log",
);
vi.mock("./restart-logs.js", () => ({
resolveGatewayLogPaths: resolveGatewayLogPathsMock,
resolveGatewaySupervisorLogPaths: resolveGatewaySupervisorLogPathsMock,
resolveGatewayRestartLogPath: resolveGatewayRestartLogPathMock,
}));
@@ -29,7 +35,7 @@ describe("buildPlatformRuntimeLogHints", () => {
windowsTaskName: "OpenClaw Gateway",
}),
).toEqual([
"Launchd stdout (if installed): /tmp/openclaw-state/logs/gateway.log",
"Launchd stdout (if installed): /Users/test/Library/Logs/openclaw/gateway.log",
"Launchd stderr (if installed): suppressed",
"Restart attempts: /tmp/openclaw-state/logs/gateway-restart.log",
]);