From ca236d098d625362595ba4e1d1e5f35bf9f4777c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 03:30:53 +0100 Subject: [PATCH] fix: harden gateway launchd and configure sections --- CHANGELOG.md | 2 + docs/cli/configure.md | 2 +- docs/help/faq.md | 2 +- docs/platforms/mac/bundled-gateway.md | 3 +- src/cli/daemon-cli/status.print.test.ts | 45 +++++ src/cli/daemon-cli/status.print.ts | 9 +- src/commands/configure.wizard.test.ts | 71 ++++++-- src/commands/configure.wizard.ts | 159 ++++++++++-------- src/commands/status-all/diagnosis.test.ts | 19 ++- src/commands/status-all/diagnosis.ts | 10 +- src/daemon/diagnostics.test.ts | 15 +- src/daemon/diagnostics.ts | 10 +- src/daemon/launchd-plist.ts | 3 +- src/daemon/launchd-restart-handoff.test.ts | 20 +++ src/daemon/launchd-restart-handoff.ts | 26 ++- src/daemon/launchd.test.ts | 105 ++++++++++++ src/daemon/launchd.ts | 45 ++++- src/daemon/restart-logs.test.ts | 30 ++++ src/daemon/restart-logs.ts | 41 ++++- src/daemon/runtime-hints.test.ts | 3 +- src/daemon/runtime-hints.ts | 4 +- .../runtime-hints.windows-paths.test.ts | 8 +- 22 files changed, 514 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f11ad3231599..08baa2699ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/cli/configure.md b/docs/cli/configure.md index dc2b77481c8a..5858e4dedee7 100644 --- a/docs/cli/configure.md +++ b/docs/cli/configure.md @@ -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. diff --git a/docs/help/faq.md b/docs/help/faq.md index 647014166782..f40a86211325 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -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-/logs/...`) + - macOS launchd stdout: `~/Library/Logs/openclaw/gateway.log` (profiles use `gateway-.log`; stderr is suppressed) - Linux: `journalctl --user -u openclaw-gateway[-].service -n 200 --no-pager` - Windows: `schtasks /Query /TN "OpenClaw Gateway ()" /V /FO LIST` diff --git a/docs/platforms/mac/bundled-gateway.md b/docs/platforms/mac/bundled-gateway.md index 60ec30fc5bdb..55cc0de7dbe0 100644 --- a/docs/platforms/mac/bundled-gateway.md +++ b/docs/platforms/mac/bundled-gateway.md @@ -49,7 +49,8 @@ Behavior: Logging: -- launchd stdout/err: `/tmp/openclaw/openclaw-gateway.log` +- launchd stdout: `~/Library/Logs/openclaw/gateway.log` (profiles use `gateway-.log`) +- launchd stderr: suppressed ## Version compatibility diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index b2b3a3246fab..db20593e3863 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -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( { diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 0f1c21c15391..3019a82680c8 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -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))}`, diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index e7a4b62cddd7..6e75a6f1f94b 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -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 | 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: [], }); diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index b5ac3bca261a..558d62a4407b 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -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 => { + 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); diff --git a/src/commands/status-all/diagnosis.test.ts b/src/commands/status-all/diagnosis.test.ts index 825e33f44ca0..1e017a4dcb6f 100644 --- a/src/commands/status-all/diagnosis.test.ts +++ b/src/commands/status-all/diagnosis.test.ts @@ -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"); diff --git a/src/commands/status-all/diagnosis.ts b/src/commands/status-all/diagnosis.ts index b66782959a0d..4567cb1f6a42 100644 --- a/src/commands/status-all/diagnosis.ts +++ b/src/commands/status-all/diagnosis.ts @@ -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; } diff --git a/src/daemon/diagnostics.test.ts b/src/daemon/diagnostics.test.ts index 4a7091eb1bc7..a6bf7f28ffd1 100644 --- a/src/daemon/diagnostics.test.ts +++ b/src/daemon/diagnostics.test.ts @@ -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", diff --git a/src/daemon/diagnostics.ts b/src/daemon/diagnostics.ts index f1480418cf34..f85755a4ad07 100644 --- a/src/daemon/diagnostics.ts +++ b/src/daemon/diagnostics.ts @@ -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 { - 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) => diff --git a/src/daemon/launchd-plist.ts b/src/daemon/launchd-plist.ts index 72442f4ecf5b..74263266f701 100644 --- a/src/daemon/launchd-plist.ts +++ b/src/daemon/launchd-plist.ts @@ -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 Comment\n ${plistEscape(comment.trim())}` : ""; const envXml = renderEnvDict(environment); - return `\n\n\n \n Label\n ${plistEscape(label)}\n ${commentXml}\n RunAtLoad\n \n KeepAlive\n \n ExitTimeOut\n ${LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS}\n ProcessType\n ${LAUNCH_AGENT_PROCESS_TYPE}\n ThrottleInterval\n ${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}\n Umask\n ${LAUNCH_AGENT_UMASK_DECIMAL}\n ProgramArguments\n ${argsXml}\n \n ${workingDirXml}\n StandardOutPath\n ${plistEscape(stdoutPath)}\n StandardErrorPath\n ${plistEscape(stderrPath)}${envXml}\n \n\n`; + return `\n\n\n \n Label\n ${plistEscape(label)}\n ${commentXml}\n RunAtLoad\n \n KeepAlive\n \n ExitTimeOut\n ${LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS}\n ProcessType\n ${LAUNCH_AGENT_PROCESS_TYPE}\n ThrottleInterval\n ${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}\n Umask\n ${LAUNCH_AGENT_UMASK_DECIMAL}\n ProgramArguments\n ${argsXml}\n \n ${workingDirXml}\n StandardInPath\n ${plistEscape(LAUNCH_AGENT_STDIN_PATH)}\n StandardOutPath\n ${plistEscape(stdoutPath)}\n StandardErrorPath\n ${plistEscape(stderrPath)}${envXml}\n \n\n`; } diff --git a/src/daemon/launchd-restart-handoff.test.ts b/src/daemon/launchd-restart-handoff.test.ts index 61dd5b4a3b14..e82ecea1db97 100644 --- a/src/daemon/launchd-restart-handoff.test.ts +++ b/src/daemon/launchd-restart-handoff.test.ts @@ -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 }); diff --git a/src/daemon/launchd-restart-handoff.ts b/src/daemon/launchd-restart-handoff.ts index fb105cae5e53..1a0dd8dadc28 100644 --- a/src/daemon/launchd-restart-handoff.ts +++ b/src/daemon/launchd-restart-handoff.ts @@ -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 diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 2d5d7efa73a5..0d2f5949bc26 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -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("KeepAlive"); expect(plist).toContain(""); + expect(plist).toContain("StandardInPath"); + expect(plist).toContain(`${LAUNCH_AGENT_STDIN_PATH}`); + expect(plist).toContain("StandardOutPath"); + expect(plist).toContain("/Users/test/Library/Logs/openclaw/gateway.log"); expect(plist).not.toContain("SuccessfulExit"); expect(plist).toContain("ExitTimeOut"); expect(plist).toContain(`${LAUNCH_AGENT_EXIT_TIMEOUT_SECONDS}`); @@ -792,7 +797,9 @@ describe("launchd install", () => { }); const plist = state.files.get(plistPath) ?? ""; + expect(plist).toContain("StandardInPath"); expect(plist).toContain("StandardOutPath"); + expect(plist).toContain("/Users/test/Library/Logs/openclaw/gateway.log"); expect(plist).toContain("StandardErrorPath"); expect(plist).toContain("/dev/null"); expect(plist).toContain("KeepAlive"); @@ -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, + [ + '', + '', + " ", + " Label", + " ai.openclaw.gateway", + " ProgramArguments", + " ", + " node", + " gateway.js", + " ", + " StandardOutPath", + " /Users/test/.openclaw-default/logs/gateway.log", + " ", + "", + ].join("\n"), + ); + + await restartLaunchAgent({ + env, + stdout: new PassThrough(), + }); + + const plist = state.files.get(plistPath) ?? ""; + expect(plist).toContain("StandardInPath"); + expect(plist).toContain("/dev/null"); + expect(plist).toContain("/Users/test/Library/Logs/openclaw/gateway.log"); + 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 = [ + '', + '', + " ", + " Label", + " ai.openclaw.gateway", + " ProgramArguments", + " ", + " node", + " gateway.js", + " ", + " StandardOutPath", + " /Users/test/.openclaw-default/logs/gateway.log", + " ", + "", + ].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, + [ + '', + '', + " ", + " Label", + " ai.openclaw.gateway", + " ProgramArguments", + " ", + " node", + " gateway.js", + " ", + " StandardOutPath", + " /Users/test/.openclaw-default/logs/gateway.log", + " ", + "", + ].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); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 510cde0fdff2..84ec01fc8718 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -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): 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 { +}): Promise { 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, diff --git a/src/daemon/restart-logs.test.ts b/src/daemon/restart-logs.test.ts index 5fa98b31bd5b..85392ec5dd0e 100644 --- a/src/daemon/restart-logs.test.ts +++ b/src/daemon/restart-logs.test.ts @@ -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", diff --git a/src/daemon/restart-logs.ts b/src/daemon/restart-logs.ts index fb7241256ffb..db448b6a736c 100644 --- a/src/daemon/restart-logs.ts +++ b/src/daemon/restart-logs.ts @@ -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); } diff --git a/src/daemon/runtime-hints.test.ts b/src/daemon/runtime-hints.test.ts index 771377216833..7cad36324aca 100644 --- a/src/daemon/runtime-hints.test.ts +++ b/src/daemon/runtime-hints.test.ts @@ -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", ]); diff --git a/src/daemon/runtime-hints.ts b/src/daemon/runtime-hints.ts index 58850017d363..ffb11ed2a1a9 100644 --- a/src/daemon/runtime-hints.ts +++ b/src/daemon/runtime-hints.ts @@ -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", diff --git a/src/daemon/runtime-hints.windows-paths.test.ts b/src/daemon/runtime-hints.windows-paths.test.ts index 9374d85d6766..9e7ff45bb884 100644 --- a/src/daemon/runtime-hints.windows-paths.test.ts +++ b/src/daemon/runtime-hints.windows-paths.test.ts @@ -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", ]);