diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index 36eacd1157df..23462c893e94 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -388,6 +388,23 @@ describe("runDaemonInstall", () => { }); }); + it("captures service install warnings in json install output", async () => { + installDaemonServiceAndEmitMock.mockImplementationOnce(async (params?: unknown) => { + await (params as { install: () => Promise }).install(); + }); + service.install.mockImplementationOnce(async (args?: unknown) => { + (args as { warn?: (message: string) => void }).warn?.( + "Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.", + ); + }); + + await runDaemonInstall({ json: true, force: true }); + + expect(actionState.warnings).toContain( + "Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.", + ); + }); + it("does not treat env-template gateway.auth.token as plaintext during install", async () => { loadConfigMock.mockReturnValue({ gateway: { auth: { mode: "token", token: "${OPENCLAW_GATEWAY_TOKEN}" } }, diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index 1d8382c5d3e5..08d7850e9817 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -270,6 +270,13 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { }, config: cfg, }); + const warn = (message: string) => { + if (json) { + warnings.push(message); + } else { + defaultRuntime.log(message); + } + }; await installDaemonServiceAndEmit({ serviceNoun: "Gateway", @@ -281,6 +288,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { await service.install({ env: installEnv, stdout, + warn, programArguments, workingDirectory, environment, diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index 749ee0192b5d..8f77c9f0453d 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -2,6 +2,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { GatewayService } from "../../daemon/service.js"; +import type { GatewayServiceControlArgs } from "../../daemon/service-types.js"; import { defaultRuntime, resetLifecycleRuntimeLogs, @@ -445,6 +446,25 @@ describe("runServiceRestart token drift", () => { expect(service.restart).toHaveBeenCalledTimes(1); }); + it("captures service restart warnings in json restart output", async () => { + service.restart.mockImplementationOnce(async (args?: GatewayServiceControlArgs) => { + args?.warn?.( + "Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.", + ); + return { outcome: "completed" }; + }); + + await runServiceRestart(createServiceRunArgs()); + + const payload = readJsonLog<{ warnings?: string[] }>(); + expect(payload.warnings).toContain( + "Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.", + ); + expect(service.restart).toHaveBeenCalledWith( + expect.objectContaining({ warn: expect.any(Function) }), + ); + }); + it("writes restart force and wait options into the service-manager intent", async () => { service.readRuntime.mockResolvedValue({ status: "running", pid: 1234 }); @@ -497,17 +517,49 @@ describe("runServiceRestart token drift", () => { expect(payload.message).toBe("restart scheduled, gateway will restart momentarily"); }); + it("captures service start warnings in json start output", async () => { + service.restart.mockImplementationOnce(async (args?: GatewayServiceControlArgs) => { + args?.warn?.( + "Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.", + ); + return { outcome: "completed" }; + }); + + await runServiceStart({ + serviceNoun: "Gateway", + service, + renderStartHints: () => [], + opts: { json: true }, + }); + + const payload = readJsonLog<{ warnings?: string[] }>(); + expect(payload.warnings).toContain( + "Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.", + ); + expect(service.restart).toHaveBeenCalledWith( + expect.objectContaining({ warn: expect.any(Function) }), + ); + }); + it("repairs stale loaded services during start before reporting success", async () => { service.readCommand.mockResolvedValue({ programArguments: ["openclaw", "gateway"], environment: { OPENCLAW_SERVICE_VERSION: "2026.4.24" }, }); - const repairLoadedService = vi.fn(async () => ({ - result: "started" as const, - message: "Gateway service definition repaired and started.", - warnings: ["service was installed by OpenClaw 2026.4.24, current CLI is 2026.5.2"], - loaded: true, - })); + type RepairLoadedService = NonNullable< + Parameters[0]["repairLoadedService"] + >; + const repairLoadedService = vi.fn(async (ctx) => { + ctx.warn?.( + "Existing generated LaunchAgent env wrapper contains custom behavior and will be overwritten.", + ); + return { + result: "started" as const, + message: "Gateway service definition repaired and started.", + warnings: ["service was installed by OpenClaw 2026.4.24, current CLI is 2026.5.2"], + loaded: true, + }; + }); await runServiceStart({ serviceNoun: "Gateway", @@ -527,7 +579,12 @@ describe("runServiceRestart token drift", () => { }>(); expect(payload.result).toBe("started"); expect(payload.message).toBe("Gateway service definition repaired and started."); - expect(payload.warnings?.[0]).toContain("service was installed by OpenClaw"); + expect(payload.warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining("service was installed by OpenClaw"), + expect.stringContaining("custom behavior and will be overwritten"), + ]), + ); expect(payload.service?.loaded).toBe(true); }); diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index 021f2c3eca08..e820ded93202 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -45,6 +45,7 @@ type RestartPostCheckContext = { json: boolean; stdout: Writable; warnings: string[]; + warn?: (message: string) => void; fail: (message: string, hints?: string[]) => void; }; @@ -58,6 +59,7 @@ type ServiceRecoveryResult = { type ServiceRecoveryContext = { json: boolean; stdout: Writable; + warn?: (message: string) => void; fail: (message: string, hints?: string[]) => void; }; @@ -91,6 +93,14 @@ function emitActionMessage(params: { } } +function mergeWarnings( + captured: readonly string[], + reported?: readonly string[], +): string[] | undefined { + const combined = [...captured, ...(reported ?? [])]; + return combined.length > 0 ? combined : undefined; +} + async function handleServiceNotLoaded(params: { serviceNoun: string; service: GatewayService; @@ -249,7 +259,8 @@ export async function runServiceStart(params: { repairLoadedService?: (ctx: ServiceStartRepairContext) => Promise; }) { const json = Boolean(params.opts?.json); - const { stdout, emit, fail } = createDaemonActionContext({ action: "start", json }); + const { stdout, warnings, emit, fail } = createDaemonActionContext({ action: "start", json }); + const warn = json ? (message: string) => warnings.push(message) : undefined; const loaded = await resolveServiceLoadedOrFail({ serviceNoun: params.serviceNoun, service: params.service, @@ -275,13 +286,13 @@ export async function runServiceStart(params: { } if (!loaded) { try { - const handled = await params.onNotLoaded?.({ json, stdout, fail }); + const handled = await params.onNotLoaded?.({ json, stdout, warn, fail }); if (handled) { emit({ ok: true, result: handled.result, message: handled.message, - warnings: handled.warnings, + warnings: mergeWarnings(warnings, handled.warnings), service: buildDaemonServiceSnapshot(params.service, handled.loaded ?? false), }); if (!json && handled.message) { @@ -296,7 +307,11 @@ export async function runServiceStart(params: { } } try { - const startResult = await startGatewayService(params.service, { env: process.env, stdout }); + const startResult = await startGatewayService(params.service, { + env: process.env, + stdout, + warn, + }); if (startResult.outcome === "missing-install") { await handleServiceNotLoaded({ serviceNoun: params.serviceNoun, @@ -320,6 +335,7 @@ export async function runServiceStart(params: { result: "scheduled", message: restartStatus.message, service: buildDaemonServiceSnapshot(params.service, startResult.state.loaded), + warnings: warnings.length ? warnings : undefined, }, }); return; @@ -329,6 +345,7 @@ export async function runServiceStart(params: { const handled = await params.repairLoadedService?.({ json, stdout, + warn, fail, state: startResult.state, issues: startResult.issues, @@ -338,7 +355,7 @@ export async function runServiceStart(params: { ok: true, result: handled.result, message: handled.message, - warnings: handled.warnings, + warnings: mergeWarnings(warnings, handled.warnings), service: buildDaemonServiceSnapshot(params.service, handled.loaded ?? true), }); if (!json && handled.message) { @@ -363,6 +380,7 @@ export async function runServiceStart(params: { ok: true, result: "started", service: buildDaemonServiceSnapshot(params.service, startResult.state.loaded), + warnings: warnings.length ? warnings : undefined, }); } catch (err) { const hints = params.renderStartHints(); @@ -470,8 +488,8 @@ export async function runServiceRestart(params: { onNotLoaded?: (ctx: ServiceRecoveryContext) => Promise; }): Promise { const json = Boolean(params.opts?.json); - const { stdout, emit, fail } = createDaemonActionContext({ action: "restart", json }); - const warnings: string[] = []; + const { stdout, warnings, emit, fail } = createDaemonActionContext({ action: "restart", json }); + const warn = json ? (message: string) => warnings.push(message) : undefined; const restartIntent = params.opts?.restartIntent; let handledRecovery: ServiceRecoveryResult | null = null; let recoveredLoadedState: boolean | null = null; @@ -519,7 +537,7 @@ export async function runServiceRestart(params: { if (!loaded) { try { - handledRecovery = (await params.onNotLoaded?.({ json, stdout, fail })) ?? null; + handledRecovery = (await params.onNotLoaded?.({ json, stdout, warn, fail })) ?? null; } catch (err) { fail(`${params.serviceNoun} restart failed: ${String(err)}`); return false; @@ -590,7 +608,7 @@ export async function runServiceRestart(params: { }); } try { - restartResult = await params.service.restart({ env: process.env, stdout }); + restartResult = await params.service.restart({ env: process.env, stdout, warn }); } catch (err) { if (wroteRestartIntent) { clearGatewayRestartIntentSync(); @@ -603,7 +621,13 @@ export async function runServiceRestart(params: { return emitScheduledRestart(restartStatus, loaded || recoveredLoadedState === true); } if (params.postRestartCheck) { - const postRestartResult = await params.postRestartCheck({ json, stdout, warnings, fail }); + const postRestartResult = await params.postRestartCheck({ + json, + stdout, + warnings, + warn, + fail, + }); if (postRestartResult) { restartStatus = describeGatewayServiceRestart(params.serviceNoun, postRestartResult); if (restartStatus.scheduled) { diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index 78f213e00132..7b7a8f8a60a5 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -280,11 +280,12 @@ export async function runDaemonStart(opts: DaemonLifecycleOptions = {}) { process.platform === "darwin" ? async () => await recoverInstalledLaunchAgent({ result: "started" }) : undefined, - repairLoadedService: async ({ json, stdout, state, issues }) => + repairLoadedService: async ({ json, stdout, warn, state, issues }) => await repairLoadedGatewayServiceForStart({ service, json, stdout, + warn, state, issues, }), @@ -352,7 +353,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi } return null; }, - postRestartCheck: async ({ warnings, fail, stdout }) => { + postRestartCheck: async ({ warnings, fail, stdout, warn }) => { if (restartedWithoutServiceManager) { // SIGUSR1 restarts have no service-manager state to watch; use listener health only. const health = await waitForGatewayHealthyListener({ @@ -402,7 +403,7 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi } await terminateStaleGatewayPids(health.staleGatewayPids); - const retryRestart = await service.restart({ env: process.env, stdout }); + const retryRestart = await service.restart({ env: process.env, stdout, warn }); if (retryRestart.outcome === "scheduled") { return retryRestart; } diff --git a/src/cli/daemon-cli/start-repair.ts b/src/cli/daemon-cli/start-repair.ts index f1d80b3b623f..9edc91fcc5f3 100644 --- a/src/cli/daemon-cli/start-repair.ts +++ b/src/cli/daemon-cli/start-repair.ts @@ -22,6 +22,7 @@ export async function repairLoadedGatewayServiceForStart(params: { issues: GatewayServiceStartRepairIssue[]; json: boolean; stdout: NodeJS.WritableStream; + warn?: (message: string) => void; }): Promise<{ result: "started"; message: string; warnings?: string[]; loaded: boolean }> { const { snapshot: configSnapshot, writeOptions: configWriteOptions } = await readConfigFileSnapshotForWrite(); @@ -75,6 +76,7 @@ export async function repairLoadedGatewayServiceForStart(params: { await params.service.install({ env: installEnv as GatewayServiceEnv, stdout: params.stdout, + warn: params.warn, programArguments, workingDirectory, environment, diff --git a/src/cli/node-cli/daemon.ts b/src/cli/node-cli/daemon.ts index 2b86adf03e0d..03e743247847 100644 --- a/src/cli/node-cli/daemon.ts +++ b/src/cli/node-cli/daemon.ts @@ -156,6 +156,13 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) { } }, }); + const warn = (message: string) => { + if (json) { + warnings.push(message); + } else { + defaultRuntime.log(message); + } + }; await installDaemonServiceAndEmit({ serviceNoun: "Node", @@ -167,6 +174,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) { await service.install({ env: process.env, stdout, + warn, programArguments, workingDirectory, environment, diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 3e556cd23b83..756cbcd0424d 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -681,6 +681,7 @@ export async function maybeRepairGatewayServiceConfig( await (updateRepairMode ? service.stage : service.install)({ env: serviceInstallEnv, stdout: process.stdout, + warn: (message) => note(message, "Gateway"), programArguments: updatedPlan.programArguments, workingDirectory: updatedPlan.workingDirectory, environment: updatedPlan.environment, diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 73f2236aa27e..e2f6ccadf64b 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -9,6 +9,7 @@ import { } from "../../packages/gateway-protocol/src/connect-error-details.js"; import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { withProgress } from "../cli/progress.js"; +import { OPENCLAW_WRAPPER_ENV_KEY } from "../daemon/program-args.js"; import { readRestartSentinel } from "../infra/restart-sentinel.js"; import type { RuntimeEnv } from "../runtime.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; @@ -91,6 +92,25 @@ export function resolvePairingRecoveryContext(params: { }; } +function normalizeStatusWrapperPath(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function resolveServiceWrapperContextHint(params: { + serviceWrapperPath?: string | null; + cliWrapperPath?: string | null; +}): string | null { + const serviceWrapperPath = normalizeStatusWrapperPath(params.serviceWrapperPath); + if (!serviceWrapperPath) { + return null; + } + if (normalizeStatusWrapperPath(params.cliWrapperPath) === serviceWrapperPath) { + return null; + } + return `The installed gateway service uses ${OPENCLAW_WRAPPER_ENV_KEY} (${sanitizeTerminalText(serviceWrapperPath)}), but this CLI process is not running with that same wrapper. Missing-secret diagnostics may describe the current CLI process rather than the installed gateway service context.`; +} + /** Runs `openclaw status`, including JSON/all routing and optional deep probes. */ export async function statusCommand( opts: { @@ -247,6 +267,13 @@ export async function statusCommand( for (const entry of secretDiagnostics) { runtime.log(`- ${entry}`); } + const wrapperContextHint = resolveServiceWrapperContextHint({ + serviceWrapperPath: daemon.wrapperPath, + cliWrapperPath: process.env[OPENCLAW_WRAPPER_ENV_KEY], + }); + if (wrapperContextHint) { + runtime.log(theme.warn(wrapperContextHint)); + } runtime.log(""); } diff --git a/src/commands/status.daemon.ts b/src/commands/status.daemon.ts index 0f55575ace6b..bc8631cd8fad 100644 --- a/src/commands/status.daemon.ts +++ b/src/commands/status.daemon.ts @@ -16,6 +16,7 @@ type DaemonStatusSummary = { runtime: Awaited>["runtime"]; runtimeShort: string | null; layout: Awaited>["layout"]; + wrapperPath: Awaited>["wrapperPath"]; }; async function buildDaemonStatusSummary( @@ -34,6 +35,7 @@ async function buildDaemonStatusSummary( runtime: summary.runtime, runtimeShort: formatDaemonRuntimeShort(summary.runtime), layout: summary.layout, + wrapperPath: summary.wrapperPath, }; } diff --git a/src/commands/status.service-summary.ts b/src/commands/status.service-summary.ts index 8ef99ae61b57..18c02e1e235d 100644 --- a/src/commands/status.service-summary.ts +++ b/src/commands/status.service-summary.ts @@ -1,11 +1,13 @@ // Reads service manager state for status reports. // Converts gateway/node launchd/systemd state into a compact summary shape. +import { OPENCLAW_WRAPPER_ENV_KEY } from "../daemon/program-args.js"; import { summarizeGatewayServiceLayout, type GatewayServiceLayoutSummary, } from "../daemon/service-layout.js"; import type { GatewayServiceRuntime } from "../daemon/service-runtime.js"; +import type { GatewayServiceCommandConfig } from "../daemon/service-types.js"; import { readGatewayServiceState, type GatewayService } from "../daemon/service.js"; type ServiceStatusSummary = { @@ -17,8 +19,16 @@ type ServiceStatusSummary = { loadedText: string; runtime: GatewayServiceRuntime | undefined; layout?: GatewayServiceLayoutSummary; + wrapperPath?: string; }; +function normalizeServiceWrapperPath( + command: GatewayServiceCommandConfig | null, +): string | undefined { + const wrapperPath = command?.environment?.[OPENCLAW_WRAPPER_ENV_KEY]?.trim(); + return wrapperPath || undefined; +} + /** Reads a daemon service summary, falling back to unknown when service inspection fails. */ export async function readServiceStatusSummary( service: GatewayService, @@ -27,6 +37,7 @@ export async function readServiceStatusSummary( try { const state = await readGatewayServiceState(service, { env: process.env }); const layout = await summarizeGatewayServiceLayout(state.command); + const wrapperPath = normalizeServiceWrapperPath(state.command); const managedByOpenClaw = state.installed; // A running unmanaged process still counts as installed for status display. const externallyManaged = !managedByOpenClaw && state.running; @@ -45,6 +56,7 @@ export async function readServiceStatusSummary( loadedText, runtime: state.runtime, ...(layout ? { layout } : {}), + ...(wrapperPath ? { wrapperPath } : {}), }; } catch { // Status output should survive service-manager errors and show an unknown row. diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index d1036fc27bc0..5ca2268054d4 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -211,6 +211,7 @@ async function createStatusServiceSummary( loadedText: service.loadedText, runtime, runtimeShort: runtime?.pid ? `pid ${runtime.pid}` : null, + wrapperPath: command?.environment?.OPENCLAW_WRAPPER?.trim() || undefined, }; } @@ -393,8 +394,20 @@ async function createMockStatusScanResult(params: { includePluginCompatibility?: } async function withEnvVar(key: string, value: string, run: () => Promise): Promise { + return await withOptionalEnvVar(key, value, run); +} + +async function withOptionalEnvVar( + key: string, + value: string | undefined, + run: () => Promise, +): Promise { const prevValue = process.env[key]; - process.env[key] = value; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } try { return await run(); } finally { @@ -1321,6 +1334,47 @@ describe("statusCommand", () => { expect(runtime.error).not.toHaveBeenCalled(); }); + it("notes when secret diagnostics may come from a CLI process outside the service wrapper context", async () => { + const wrapperPath = "/usr/local/bin/openclaw-doppler"; + const service = mocks.resolveGatewayService(); + mocks.resolveGatewayService.mockReturnValue({ + ...service, + readCommand: async () => ({ + programArguments: [wrapperPath, "node", "dist/entry.js", "gateway"], + environment: { OPENCLAW_WRAPPER: wrapperPath }, + sourcePath: "/tmp/Library/LaunchAgents/ai.openclaw.gateway.plist", + }), + }); + mocks.loadConfig.mockReturnValue({ + session: {}, + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }); + + await withOptionalEnvVar("OPENCLAW_WRAPPER", undefined, async () => { + const logs = await runStatusAndGetLogs(); + expectLogsInclude(logs, "Secret diagnostics:"); + expectLogsInclude(logs, "installed gateway service uses OPENCLAW_WRAPPER"); + expectLogsInclude(logs, "not running with that same wrapper"); + expectLogsInclude(logs, "current CLI process rather than the installed gateway service"); + }); + + await withEnvVar("OPENCLAW_WRAPPER", wrapperPath, async () => { + const logs = await runStatusAndGetLogs(); + expectLogsInclude(logs, "Secret diagnostics:"); + expectLogsExclude(logs, "not running with that same wrapper"); + }); + }); + it("surfaces channel runtime errors from the gateway", async () => { mocks.loadConfig.mockReturnValue({ session: {}, diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 8565b78ceddf..0538b21b783f 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -846,6 +846,81 @@ describe("launchd install", () => { expect(command?.environmentValueSources?.OPENAI_API_KEY).toBe("file"); }); + it("warns before overwriting a customized generated LaunchAgent env wrapper", async () => { + const env = createDefaultLaunchdEnv(); + const wrapperPath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh"; + await installLaunchAgent({ + env, + stdout: new PassThrough(), + programArguments: defaultProgramArguments, + environment: { OPENCLAW_GATEWAY_PORT: "18789" }, + }); + const generatedWrapper = state.files.get(wrapperPath); + if (!generatedWrapper) { + throw new Error("expected generated wrapper"); + } + state.files.set( + wrapperPath, + generatedWrapper.replace('exec "$@"', 'echo "custom-secret-provider-marker"\nexec "$@"'), + ); + + const stdout = new PassThrough(); + let output = ""; + stdout.on("data", (chunk: Buffer) => { + output += chunk.toString("utf8"); + }); + + await installLaunchAgent({ + env, + stdout, + programArguments: defaultProgramArguments, + environment: { OPENCLAW_GATEWAY_PORT: "18789" }, + }); + + expect(output).toContain("Warning:"); + expect(output).toContain("contains custom behavior and will be overwritten"); + expect(output).toContain("openclaw gateway install --wrapper "); + expect(output).toContain("OPENCLAW_WRAPPER"); + expect(state.files.get(wrapperPath)).toBe(generatedWrapper); + }); + + it("warns before overwriting a customized generated LaunchAgent env wrapper during restart rewrite", async () => { + const env = createDefaultLaunchdEnv(); + const wrapperPath = "/Users/test/.openclaw/service-env/ai.openclaw.gateway-env-wrapper.sh"; + await installLaunchAgent({ + env, + stdout: new PassThrough(), + programArguments: defaultProgramArguments, + environment: { OPENCLAW_GATEWAY_PORT: "18789" }, + }); + const generatedWrapper = state.files.get(wrapperPath); + if (!generatedWrapper) { + throw new Error("expected generated wrapper"); + } + state.files.set( + wrapperPath, + generatedWrapper.replace('exec "$@"', 'echo "custom-secret-provider-marker"\nexec "$@"'), + ); + state.launchctlCalls.length = 0; + + const stdout = new PassThrough(); + let output = ""; + stdout.on("data", (chunk: Buffer) => { + output += chunk.toString("utf8"); + }); + + await restartLaunchAgent({ + env, + stdout, + }); + + expect(output).toContain("Warning:"); + expect(output).toContain("contains custom behavior and will be overwritten"); + expect(output).toContain("openclaw gateway install --wrapper "); + expect(output).toContain("OPENCLAW_WRAPPER"); + expect(state.files.get(wrapperPath)).toBe(generatedWrapper); + }); + it("repairs a mangled label-derived service-env wrapper path on restart", async () => { const callerEnv = createDefaultLaunchdEnv(); const serviceEnv = { diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 4b001f76710b..436c20d8977c 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -178,6 +178,36 @@ exec "$@" `; } +async function resolveLaunchAgentEnvironmentWrapperOverwriteWarnings(params: { + wrapperPath: string; + generatedWrapper: string; +}): Promise { + const existingWrapper = await fs.readFile(params.wrapperPath, "utf8").catch(() => null); + if (existingWrapper === null || existingWrapper === params.generatedWrapper) { + return []; + } + return [ + `Existing generated LaunchAgent env wrapper at ${params.wrapperPath} contains custom behavior and will be overwritten; move custom behavior to openclaw gateway install --wrapper or OPENCLAW_WRAPPER.`, + ]; +} + +function writeLaunchAgentOverwriteWarnings( + stdout: NodeJS.WritableStream | undefined, + warn: ((message: string) => void) | undefined, + warnings: readonly string[], +): void { + for (const warning of warnings) { + if (warn) { + warn(warning); + continue; + } + if (!stdout) { + continue; + } + stdout.write(`${formatLine("Warning", warning)}\n`); + } +} + function isLaunchAgentEnvironmentWrapperArgs(params: { programArguments: string[]; envFilePath: string; @@ -194,7 +224,12 @@ async function prepareLaunchAgentProgramArguments(params: { label: string; programArguments: string[]; environment: GatewayServiceEnv | undefined; -}): Promise<{ programArguments: string[]; inlineEnvironment?: GatewayServiceEnv }> { + stdout?: NodeJS.WritableStream; + warn?: (message: string) => void; +}): Promise<{ + programArguments: string[]; + inlineEnvironment?: GatewayServiceEnv; +}> { const entries = collectLaunchAgentEnvironmentEntries(params.environment); if (entries.length === 0) { return { programArguments: params.programArguments }; @@ -205,13 +240,19 @@ async function prepareLaunchAgentProgramArguments(params: { const envDir = resolveLaunchAgentEnvDir(params.env); const envFilePath = resolveLaunchAgentEnvFilePath(params.env, params.label); const wrapperPath = resolveLaunchAgentEnvWrapperPath(params.env, params.label); + const generatedWrapper = buildLaunchAgentEnvironmentWrapper(); await ensureSecureDirectory(envDir, LAUNCH_AGENT_PRIVATE_DIR_MODE); await fs.writeFile(envFilePath, buildLaunchAgentEnvironmentFile(entries), { encoding: "utf8", mode: LAUNCH_AGENT_ENV_FILE_MODE, }); await fs.chmod(envFilePath, LAUNCH_AGENT_ENV_FILE_MODE).catch(() => undefined); - await fs.writeFile(wrapperPath, buildLaunchAgentEnvironmentWrapper(), { + const overwriteWarnings = await resolveLaunchAgentEnvironmentWrapperOverwriteWarnings({ + wrapperPath, + generatedWrapper, + }); + writeLaunchAgentOverwriteWarnings(params.stdout, params.warn, overwriteWarnings); + await fs.writeFile(wrapperPath, generatedWrapper, { encoding: "utf8", mode: LAUNCH_AGENT_ENV_WRAPPER_MODE, }); @@ -868,7 +909,9 @@ async function writeLaunchAgentPlist({ workingDirectory, environment, description, -}: Omit): Promise<{ plistPath: string; stdoutPath: string }> { + stdout, + warn, +}: GatewayServiceInstallArgs): Promise<{ plistPath: string; stdoutPath: string }> { const { logDir, stdoutPath } = resolveGatewaySupervisorLogPaths(env, { platform: "darwin" }); await ensureSecureDirectory(logDir); @@ -897,6 +940,8 @@ async function writeLaunchAgentPlist({ label, programArguments, environment, + stdout, + warn, }); const serviceDescription = resolveGatewayServiceDescription({ env, environment, description }); @@ -918,7 +963,7 @@ export async function stageLaunchAgent({ stdout, ...args }: GatewayServiceInstallArgs): Promise<{ plistPath: string }> { - const { plistPath, stdoutPath } = await writeLaunchAgentPlist(args); + const { plistPath, stdoutPath } = await writeLaunchAgentPlist({ ...args, stdout }); writeFormattedLines( stdout, [ @@ -968,10 +1013,14 @@ async function rewriteLaunchAgentPlistForRestart({ env, label, plistPath, + stdout, + warn, }: { env: GatewayServiceEnv; label: string; plistPath: string; + stdout?: NodeJS.WritableStream; + warn?: (message: string) => void; }): Promise { const existing = await readLaunchAgentProgramArgumentsFromFile( plistPath, @@ -993,6 +1042,8 @@ async function rewriteLaunchAgentPlistForRestart({ label, programArguments: existing.programArguments, environment: existing.environment, + stdout, + warn, }); const plist = buildLaunchAgentPlist({ label, @@ -1036,6 +1087,7 @@ async function ensureLaunchAgentLoadedAfterFailure(params: { export async function restartLaunchAgent({ stdout, env, + warn, }: GatewayServiceControlArgs): Promise { const serviceEnv = env ?? (process.env as GatewayServiceEnv); const domain = resolveGuiDomain(); @@ -1051,6 +1103,8 @@ export async function restartLaunchAgent({ env: serviceEnv, label, plistPath, + stdout, + warn, }); const handoff = scheduleDetachedLaunchdRestartHandoff({ env: serviceEnv, @@ -1081,6 +1135,8 @@ export async function restartLaunchAgent({ env: serviceEnv, label, plistPath, + stdout, + warn, }); // `openclaw gateway restart` is an explicit operator request to bring the diff --git a/src/daemon/service-types.ts b/src/daemon/service-types.ts index a6df025f0043..b7846fbf3ad0 100644 --- a/src/daemon/service-types.ts +++ b/src/daemon/service-types.ts @@ -8,6 +8,7 @@ export type GatewayServiceEnv = Record; export type GatewayServiceInstallArgs = { env: GatewayServiceEnv; stdout: NodeJS.WritableStream; + warn?: (message: string) => void; programArguments: string[]; workingDirectory?: string; environment?: GatewayServiceEnv; @@ -26,6 +27,7 @@ export type GatewayServiceControlArgs = { stdout: NodeJS.WritableStream; env?: GatewayServiceEnv; disable?: boolean; + warn?: (message: string) => void; }; export type GatewayServiceRestartResult = { outcome: "completed" } | { outcome: "scheduled" };