diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d4aa2da64..5c1aa6de223 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Cron: classify isolated runs as errors when final output narrates known execution-denial markers such as `SYSTEM_RUN_DENIED`, `INVALID_REQUEST`, or approval-binding refusal phrases, so blocked commands no longer appear green in cron history. Fixes #67172; carries forward #67186. Thanks @oc-gh-dr, @hclsys, and @1yihui. +- Gateway/install: add a validated `--wrapper`/`OPENCLAW_WRAPPER` service install path that persists executable LaunchAgent/systemd wrappers across forced reinstalls, updates, and doctor repairs instead of falling back to raw node/bun `ProgramArguments`. Fixes #69400. Thanks @willtmc. - macOS Gateway: write launchd services with a state-dir `WorkingDirectory`, use a durable state-dir temp path instead of freezing macOS session `TMPDIR`, create that temp directory before bootstrap, and label abort-shaped launchd exits as `SIGABRT/abort` in status output. Fixes #53679 and #70223; refs #71848. Thanks @dlturock, @stammi922, and @palladius. - Exec approvals: accept runtime-owned `source: "allow-always"` and `commandText` allowlist metadata in gateway and node approval-set payloads so Control UI round-trips no longer fail with `unexpected property 'source'`. Fixes #60000; carries forward #60064. Thanks @sd1471123, @sharkqwy, and @luoyanglang. - Exec/node: skip approval-plan preparation for full-trust `host=node` runs so interpreter and script commands no longer fail with `SYSTEM_RUN_DENIED: approval cannot safely bind` when effective policy is `security=full` and `ask=off`. Fixes #48457 and duplicate #69251. Thanks @ajtran303, @jaserNo1, @Blakeshannon, @lesliefag, and @AvIsBeastMC. diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 0abae2ff35d..a55cd1dbbe5 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -425,11 +425,13 @@ openclaw gateway uninstall - `gateway status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json` - - `gateway install`: `--port`, `--runtime `, `--token`, `--force`, `--json` + - `gateway install`: `--port`, `--runtime `, `--token`, `--wrapper `, `--force`, `--json` - `gateway uninstall|start|stop|restart`: `--json` - - `gateway install` supports `--port`, `--runtime`, `--token`, `--force`, `--json`. + - `gateway install` supports `--port`, `--runtime`, `--token`, `--wrapper`, `--force`, `--json`. + - `--wrapper ` makes the managed service start through an executable wrapper, writing `ProgramArguments` as ` gateway --port ...` and persisting `OPENCLAW_WRAPPER` in the service environment so forced reinstalls, updates, and doctor repairs keep using the same wrapper. `openclaw doctor` also reports the active wrapper. If `--wrapper` is omitted, install honors an existing `OPENCLAW_WRAPPER` from the shell or current service environment. + - To remove a persisted wrapper, reinstall with an empty wrapper environment, for example `OPENCLAW_WRAPPER= openclaw gateway install --force`. - Use `gateway restart` to restart a managed service. Do not chain `gateway stop` and `gateway start` as a restart substitute; on macOS, `gateway stop` intentionally disables the LaunchAgent before stopping it. - When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `gateway install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata. - If token auth requires a token and the configured token SecretRef is unresolved, install fails closed instead of persisting fallback plaintext. diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index b50e91d6c1f..968113ace74 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -33,12 +33,14 @@ const buildGatewayInstallPlan = vi.fn( port: number; token?: string; env?: NodeJS.ProcessEnv; + wrapperPath?: string; existingEnvironment?: Record; }) => ({ programArguments: ["/bin/node", "cli", "gateway", "--port", String(params.port)], workingDirectory: process.cwd(), environment: { OPENCLAW_GATEWAY_PORT: String(params.port), + ...(params.wrapperPath ? { OPENCLAW_WRAPPER: params.wrapperPath } : {}), ...(params.token ? { OPENCLAW_GATEWAY_TOKEN: params.token } : {}), }, }), @@ -61,7 +63,9 @@ vi.mock("../gateway/probe-auth.js", () => ({ })); vi.mock("../daemon/program-args.js", () => ({ + OPENCLAW_WRAPPER_ENV_KEY: "OPENCLAW_WRAPPER", resolveGatewayProgramArguments: (opts: unknown) => resolveGatewayProgramArguments(opts), + resolveOpenClawWrapperPath: async (value: string | undefined) => value?.trim() || undefined, })); vi.mock("../daemon/service.js", async () => { @@ -109,6 +113,7 @@ vi.mock("../commands/daemon-install-helpers.js", () => ({ port: number; token?: string; env?: NodeJS.ProcessEnv; + wrapperPath?: string; existingEnvironment?: Record; }) => buildGatewayInstallPlan(params), })); @@ -263,6 +268,7 @@ describe("daemon-cli coverage", () => { serviceReadCommand.mockResolvedValueOnce({ programArguments: ["/bin/node", "cli", "gateway", "--port", "18789"], environment: { + OPENCLAW_WRAPPER: "/usr/local/bin/openclaw-doppler", PATH: "/custom/go/bin:/usr/bin", GOPATH: "/Users/test/.local/gopath", GOBIN: "/Users/test/.local/gopath/bin", @@ -276,9 +282,32 @@ describe("daemon-cli coverage", () => { expect.objectContaining({ existingEnvironment: { PATH: "/custom/go/bin:/usr/bin", + OPENCLAW_WRAPPER: "/usr/local/bin/openclaw-doppler", GOPATH: "/Users/test/.local/gopath", GOBIN: "/Users/test/.local/gopath/bin", }, + env: expect.objectContaining({ + OPENCLAW_WRAPPER: "/usr/local/bin/openclaw-doppler", + }), + }), + ); + }); + + it("passes an explicit service wrapper into the install plan", async () => { + runtimeLogs.length = 0; + serviceIsLoaded.mockResolvedValueOnce(false); + + await runDaemonCommand([ + "daemon", + "install", + "--wrapper", + "/usr/local/bin/openclaw-doppler", + "--json", + ]); + + expect(buildGatewayInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ + wrapperPath: "/usr/local/bin/openclaw-doppler", }), ); }); diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index c426120ec7b..c0b65ee9382 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -10,6 +10,7 @@ import { resolveFutureConfigActionBlock } from "../../config/future-version-guar import { readConfigFileSnapshotForWrite } from "../../config/io.js"; import { resolveGatewayPort } from "../../config/paths.js"; import type { OpenClawConfig } from "../../config/types.js"; +import { OPENCLAW_WRAPPER_ENV_KEY, resolveOpenClawWrapperPath } from "../../daemon/program-args.js"; import { readEmbeddedGatewayToken } from "../../daemon/service-audit.js"; import { resolveGatewayService } from "../../daemon/service.js"; import type { GatewayServiceCommandConfig } from "../../daemon/service.js"; @@ -44,6 +45,13 @@ function mergeInstallInvocationEnv(params: { continue; } const upper = key.toUpperCase(); + if (upper === OPENCLAW_WRAPPER_ENV_KEY) { + const value = rawValue.trim(); + if (value) { + preservedServiceEnv[OPENCLAW_WRAPPER_ENV_KEY] = value; + } + continue; + } if ( upper === "HOME" || upper === "PATH" || @@ -99,6 +107,19 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { fail('Invalid --runtime (use "node" or "bun")'); return; } + let wrapperPath: string | undefined; + if (opts.wrapper !== undefined) { + try { + wrapperPath = await resolveOpenClawWrapperPath(opts.wrapper); + if (!wrapperPath) { + fail("Invalid --wrapper"); + return; + } + } catch (err) { + fail(`Invalid --wrapper: ${String(err)}`); + return; + } + } const service = resolveGatewayService(); let loaded = false; @@ -122,6 +143,14 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { env: process.env, existingServiceEnv, }); + if (!wrapperPath) { + try { + wrapperPath = await resolveOpenClawWrapperPath(installEnv[OPENCLAW_WRAPPER_ENV_KEY]); + } catch (err) { + fail(`Invalid ${OPENCLAW_WRAPPER_ENV_KEY}: ${String(err)}`); + return; + } + } if (loaded) { if (!opts.force) { const autoRefreshMessage = await getGatewayServiceAutoRefreshMessage({ @@ -130,6 +159,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { installEnv, port, runtime: runtimeRaw, + wrapperPath, existingEnvironment: existingServiceEnv, config: cfg, }); @@ -182,6 +212,7 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { env: installEnv, port, runtime: runtimeRaw, + wrapperPath, existingEnvironment: existingServiceEnv, warn: (message) => { if (json) { @@ -217,6 +248,7 @@ async function getGatewayServiceAutoRefreshMessage(params: { installEnv: NodeJS.ProcessEnv; port: number; runtime: GatewayDaemonRuntime; + wrapperPath?: string; existingEnvironment?: Record; config: OpenClawConfig; }): Promise { @@ -231,6 +263,7 @@ async function getGatewayServiceAutoRefreshMessage(params: { env: params.installEnv, port: params.port, runtime: params.runtime, + wrapperPath: params.wrapperPath, existingEnvironment: params.existingEnvironment, warn: () => undefined, config: params.config, @@ -242,6 +275,26 @@ async function getGatewayServiceAutoRefreshMessage(params: { return "Gateway service OPENCLAW_GATEWAY_TOKEN differs from the current install plan; refreshing the install."; } } + const wrapperRequested = Boolean( + params.wrapperPath || normalizeOptionalString(params.installEnv[OPENCLAW_WRAPPER_ENV_KEY]), + ); + if (wrapperRequested) { + const plannedInstall = await buildGatewayInstallPlan({ + env: params.installEnv, + port: params.port, + runtime: params.runtime, + wrapperPath: params.wrapperPath, + existingEnvironment: params.existingEnvironment, + warn: () => undefined, + config: params.config, + }); + if ( + plannedInstall.programArguments.join("\u0000") !== + currentCommand.programArguments.join("\u0000") + ) { + return "Gateway service command differs from the current wrapper install plan; refreshing the install."; + } + } const currentExecPath = currentCommand.programArguments[0]?.trim(); if (!currentExecPath) { return undefined; diff --git a/src/cli/daemon-cli/register-service-commands.ts b/src/cli/daemon-cli/register-service-commands.ts index fc77a5afcff..1992f77a309 100644 --- a/src/cli/daemon-cli/register-service-commands.ts +++ b/src/cli/daemon-cli/register-service-commands.ts @@ -77,6 +77,7 @@ export function addGatewayServiceCommands(parent: Command, opts?: { statusDescri .option("--port ", "Gateway port") .option("--runtime ", "Daemon runtime (node|bun). Default: node") .option("--token ", "Gateway token (token auth)") + .option("--wrapper ", "Executable wrapper for generated service ProgramArguments") .option("--force", "Reinstall/overwrite if already installed", false) .option("--json", "Output JSON", false) .action(async (cmdOpts, command) => { diff --git a/src/cli/daemon-cli/types.ts b/src/cli/daemon-cli/types.ts index 08a6d407329..3ae79327f81 100644 --- a/src/cli/daemon-cli/types.ts +++ b/src/cli/daemon-cli/types.ts @@ -19,6 +19,7 @@ export type DaemonInstallOptions = { port?: string | number; runtime?: string; token?: string; + wrapper?: string; force?: boolean; json?: boolean; }; diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index df00aeeb754..5561d069915 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ resolveSystemNodeInfo: vi.fn(), renderSystemNodeWarning: vi.fn(), buildServiceEnvironment: vi.fn(), + resolveOpenClawWrapperPath: vi.fn(), })); vi.mock("./daemon-install-auth-profiles-source.runtime.js", () => ({ @@ -29,7 +30,9 @@ vi.mock("../daemon/runtime-paths.js", () => ({ })); vi.mock("../daemon/program-args.js", () => ({ + OPENCLAW_WRAPPER_ENV_KEY: "OPENCLAW_WRAPPER", resolveGatewayProgramArguments: mocks.resolveGatewayProgramArguments, + resolveOpenClawWrapperPath: mocks.resolveOpenClawWrapperPath, })); vi.mock("../daemon/service-env.js", () => ({ @@ -75,6 +78,9 @@ function mockNodeGatewayPlanFixture( ? params.workingDirectory : "/Users/me"; mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node"); + mocks.resolveOpenClawWrapperPath.mockImplementation(async (value: string | undefined) => + value?.trim() ? path.resolve(value) : undefined, + ); mocks.resolveGatewayProgramArguments.mockResolvedValue({ programArguments: ["node", "gateway"], workingDirectory, @@ -205,6 +211,38 @@ describe("buildGatewayInstallPlan", () => { expect(plan.workingDirectory).toBeUndefined(); }); + it("passes OPENCLAW_WRAPPER through program args and managed service env", async () => { + const wrapperPath = path.resolve("/usr/local/bin/openclaw-doppler"); + mockNodeGatewayPlanFixture({ + serviceEnvironment: { + OPENCLAW_PORT: "3000", + OPENCLAW_WRAPPER: wrapperPath, + }, + }); + + const plan = await buildGatewayInstallPlan({ + env: isolatedPlanEnv({ + OPENCLAW_WRAPPER: wrapperPath, + }), + port: 3000, + runtime: "node", + }); + + expect(mocks.resolveGatewayProgramArguments).toHaveBeenCalledWith( + expect.objectContaining({ + wrapperPath, + }), + ); + expect(mocks.buildServiceEnvironment).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + OPENCLAW_WRAPPER: wrapperPath, + }), + }), + ); + expect(plan.environment.OPENCLAW_WRAPPER).toBe(wrapperPath); + }); + it("merges safe config env while dropping unsafe values and keeping service precedence", async () => { mockNodeGatewayPlanFixture({ serviceEnvironment: { diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 0f477141690..18e0ebe8d86 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -6,7 +6,11 @@ import { collectDurableServiceEnvVars } from "../config/state-dir-dotenv.js"; import type { OpenClawConfig } from "../config/types.js"; import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js"; import { resolveGatewayStateDir } from "../daemon/paths.js"; -import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; +import { + OPENCLAW_WRAPPER_ENV_KEY, + resolveGatewayProgramArguments, + resolveOpenClawWrapperPath, +} from "../daemon/program-args.js"; import { buildServiceEnvironment } from "../daemon/service-env.js"; import { isDangerousHostEnvOverrideVarName, @@ -276,6 +280,7 @@ export async function buildGatewayInstallPlan(params: { existingEnvironment?: Record; devMode?: boolean; nodePath?: string; + wrapperPath?: string; platform?: NodeJS.Platform; warn?: DaemonInstallWarnFn; /** Full config to extract env vars from (env vars + inline env keys). */ @@ -289,11 +294,18 @@ export async function buildGatewayInstallPlan(params: { devMode: params.devMode, nodePath: params.nodePath, }); + const wrapperPath = await resolveOpenClawWrapperPath( + params.wrapperPath ?? params.env[OPENCLAW_WRAPPER_ENV_KEY], + ); + const serviceInputEnv: Record = wrapperPath + ? { ...params.env, [OPENCLAW_WRAPPER_ENV_KEY]: wrapperPath } + : params.env; const { programArguments, workingDirectory } = await resolveGatewayProgramArguments({ port: params.port, dev: devMode, runtime: params.runtime, nodePath, + wrapperPath, }); await emitDaemonInstallRuntimeWarning({ env: params.env, @@ -303,11 +315,11 @@ export async function buildGatewayInstallPlan(params: { title: "Gateway runtime", }); const serviceEnvironment = buildServiceEnvironment({ - env: params.env, + env: serviceInputEnv, port: params.port, launchdLabel: platform === "darwin" - ? resolveGatewayLaunchAgentLabel(params.env.OPENCLAW_PROFILE) + ? resolveGatewayLaunchAgentLabel(serviceInputEnv.OPENCLAW_PROFILE) : undefined, platform, extraPathDirs: resolveDaemonNodeBinDir(nodePath), @@ -317,12 +329,12 @@ export async function buildGatewayInstallPlan(params: { return { programArguments, workingDirectory: resolveGatewayInstallWorkingDirectory({ - env: params.env, + env: serviceInputEnv, platform, workingDirectory, }), environment: await buildGatewayInstallEnvironment({ - env: params.env, + env: serviceInputEnv, config: params.config, authStore: params.authStore, warn: params.warn, diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 6b09c866fc8..bc69586e9e5 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -365,6 +365,49 @@ describe("maybeRepairGatewayServiceConfig", () => { expect(mocks.install).not.toHaveBeenCalled(); }); + it("keeps wrapper-managed gateway services aligned during entrypoint drift checks", async () => { + const wrapperPath = "/usr/local/bin/openclaw-doppler"; + mocks.readCommand.mockResolvedValue({ + programArguments: [wrapperPath, "gateway", "--port", "18789"], + environment: { + OPENCLAW_WRAPPER: wrapperPath, + }, + }); + mocks.auditGatewayServiceConfig.mockResolvedValue({ + ok: true, + issues: [], + }); + mocks.buildGatewayInstallPlan.mockImplementation(async ({ env }) => ({ + programArguments: [env.OPENCLAW_WRAPPER, "gateway", "--port", "18789"], + environment: { + OPENCLAW_WRAPPER: env.OPENCLAW_WRAPPER, + }, + })); + + await runRepair({ gateway: {} }); + + expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + OPENCLAW_WRAPPER: wrapperPath, + }), + existingEnvironment: expect.objectContaining({ + OPENCLAW_WRAPPER: wrapperPath, + }), + }), + ); + expect(mocks.note).not.toHaveBeenCalledWith( + expect.stringContaining("Gateway service entrypoint does not match the current install."), + "Gateway service config", + ); + expect(mocks.note).toHaveBeenCalledWith( + "Gateway service invokes OPENCLAW_WRAPPER: /usr/local/bin/openclaw-doppler", + "Gateway", + ); + expect(mocks.stage).not.toHaveBeenCalled(); + expect(mocks.install).not.toHaveBeenCalled(); + }); + it("still flags entrypoint mismatch when canonicalized paths differ", async () => { setupGatewayEntrypointRepairScenario({ currentEntrypoint: diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 6eb81e2ce45..a059d9e4547 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -11,6 +11,7 @@ import { renderGatewayServiceCleanupHints, type ExtraGatewayService, } from "../daemon/inspect.js"; +import { OPENCLAW_WRAPPER_ENV_KEY } from "../daemon/program-args.js"; import { renderSystemNodeWarning, resolveSystemNodeInfo } from "../daemon/runtime-paths.js"; import { auditGatewayServiceConfig, @@ -18,7 +19,7 @@ import { readEmbeddedGatewayToken, SERVICE_AUDIT_CODES, } from "../daemon/service-audit.js"; -import { resolveGatewayService } from "../daemon/service.js"; +import { resolveGatewayService, type GatewayServiceCommandConfig } from "../daemon/service.js"; import { uninstallLegacySystemdUnits } from "../daemon/systemd.js"; import type { RuntimeEnv } from "../runtime.js"; import { @@ -65,6 +66,25 @@ function findGatewayEntrypoint(programArguments?: string[]): string | null { return programArguments[gatewayIndex - 1] ?? null; } +function buildGatewayServiceRepairEnv( + command: GatewayServiceCommandConfig | null, +): NodeJS.ProcessEnv { + const wrapperPath = command?.environment?.[OPENCLAW_WRAPPER_ENV_KEY]?.trim(); + if (!wrapperPath || Object.hasOwn(process.env, OPENCLAW_WRAPPER_ENV_KEY)) { + return process.env; + } + return { + ...process.env, + [OPENCLAW_WRAPPER_ENV_KEY]: wrapperPath, + }; +} + +function resolveGatewayServiceWrapperPath( + command: GatewayServiceCommandConfig | null, +): string | null { + return normalizeOptionalString(command?.environment?.[OPENCLAW_WRAPPER_ENV_KEY]) ?? null; +} + async function normalizeExecutablePath(value: string): Promise { const resolvedPath = path.resolve(value); try { @@ -227,6 +247,11 @@ export async function maybeRepairGatewayServiceConfig( if (!command) { return; } + const serviceInstallEnv = buildGatewayServiceRepairEnv(command); + const serviceWrapperPath = resolveGatewayServiceWrapperPath(command); + if (serviceWrapperPath) { + note(`Gateway service invokes ${OPENCLAW_WRAPPER_ENV_KEY}: ${serviceWrapperPath}`, "Gateway"); + } const tokenRefConfigured = Boolean( resolveSecretInputRef({ @@ -276,10 +301,11 @@ export async function maybeRepairGatewayServiceConfig( const port = resolveGatewayPort(cfg, process.env); const runtimeChoice = detectGatewayRuntime(command.programArguments); const { programArguments } = await buildGatewayInstallPlan({ - env: process.env, + env: serviceInstallEnv, port, runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice, nodePath: systemNodePath ?? undefined, + existingEnvironment: command.environment, warn: (message, title) => note(message, title), config: cfg, }); @@ -389,16 +415,17 @@ export async function maybeRepairGatewayServiceConfig( const updatedPort = resolveGatewayPort(cfgForServiceInstall, process.env); const updatedPlan = await buildGatewayInstallPlan({ - env: process.env, + env: serviceInstallEnv, port: updatedPort, runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice, nodePath: systemNodePath ?? undefined, + existingEnvironment: command.environment, warn: (message, title) => note(message, title), config: cfgForServiceInstall, }); try { await (updateRepairMode ? service.stage : service.install)({ - env: process.env, + env: serviceInstallEnv, stdout: process.stdout, programArguments: updatedPlan.programArguments, workingDirectory: updatedPlan.workingDirectory, diff --git a/src/daemon/program-args.test.ts b/src/daemon/program-args.test.ts index 4c46687b076..43478050f64 100644 --- a/src/daemon/program-args.test.ts +++ b/src/daemon/program-args.test.ts @@ -8,6 +8,7 @@ const childProcessMocks = vi.hoisted(() => ({ const fsMocks = vi.hoisted(() => ({ access: vi.fn(), realpath: vi.fn(), + stat: vi.fn(), })); vi.mock("node:fs/promises", async () => { @@ -18,9 +19,11 @@ vi.mock("node:fs/promises", async () => { ...actual, access: fsMocks.access, realpath: fsMocks.realpath, + stat: fsMocks.stat, }, access: fsMocks.access, realpath: fsMocks.realpath, + stat: fsMocks.stat, }; }); @@ -175,4 +178,31 @@ describe("resolveGatewayProgramArguments", () => { ]); expect(result.workingDirectory).toBe(path.resolve("/repo")); }); + + it("uses an executable wrapper when provided", async () => { + const wrapperPath = path.resolve("/usr/local/bin/openclaw-doppler"); + fsMocks.stat.mockResolvedValue({ isFile: () => true } as never); + fsMocks.access.mockResolvedValue(undefined); + + const result = await resolveGatewayProgramArguments({ + port: 18789, + wrapperPath, + }); + + expect(result.programArguments).toEqual([wrapperPath, "gateway", "--port", "18789"]); + expect(result.workingDirectory).toBeUndefined(); + }); + + it("rejects a non-executable wrapper file", async () => { + const wrapperPath = path.resolve("/usr/local/bin/openclaw-doppler"); + fsMocks.stat.mockResolvedValue({ isFile: () => true } as never); + fsMocks.access.mockRejectedValue(new Error("EACCES")); + + await expect( + resolveGatewayProgramArguments({ + port: 18789, + wrapperPath, + }), + ).rejects.toThrow("OPENCLAW_WRAPPER must point to an executable file"); + }); }); diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts index b4148126954..dfc262a504d 100644 --- a/src/daemon/program-args.ts +++ b/src/daemon/program-args.ts @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { @@ -15,6 +16,8 @@ type GatewayProgramArgs = { type GatewayRuntimePreference = "auto" | "node" | "bun"; +export const OPENCLAW_WRAPPER_ENV_KEY = "OPENCLAW_WRAPPER"; + async function resolveCliEntrypointPathForService(): Promise { const argv1 = process.argv[1]; if (!argv1) { @@ -177,12 +180,42 @@ async function resolveBinaryPath(binary: string): Promise { } } +export async function resolveOpenClawWrapperPath( + inputPath: string | undefined, +): Promise { + const trimmed = inputPath?.trim(); + if (!trimmed) { + return undefined; + } + const resolved = path.resolve(trimmed); + try { + const stat = await fs.stat(resolved); + if (!stat.isFile()) { + throw new Error("not a regular file"); + } + await fs.access(resolved, fsConstants.X_OK); + } catch (error) { + const detail = error instanceof Error ? ` (${error.message})` : ""; + throw new Error( + `${OPENCLAW_WRAPPER_ENV_KEY} must point to an executable file: ${resolved}${detail}`, + { cause: error }, + ); + } + return resolved; +} + async function resolveCliProgramArguments(params: { args: string[]; dev?: boolean; runtime?: GatewayRuntimePreference; nodePath?: string; + wrapperPath?: string; }): Promise { + const wrapperPath = await resolveOpenClawWrapperPath(params.wrapperPath); + if (wrapperPath) { + return { programArguments: [wrapperPath, ...params.args] }; + } + const execPath = process.execPath; const runtime = params.runtime ?? "auto"; @@ -255,6 +288,7 @@ export async function resolveGatewayProgramArguments(params: { dev?: boolean; runtime?: GatewayRuntimePreference; nodePath?: string; + wrapperPath?: string; }): Promise { const gatewayArgs = ["gateway", "--port", String(params.port)]; return resolveCliProgramArguments({ @@ -262,6 +296,7 @@ export async function resolveGatewayProgramArguments(params: { dev: params.dev, runtime: params.runtime, nodePath: params.nodePath, + wrapperPath: params.wrapperPath, }); } diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index 983399eb6bd..fbd57862f38 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -398,6 +398,18 @@ describe("buildServiceEnvironment", () => { } }); + it("passes through OPENCLAW_WRAPPER for gateway services", () => { + const env = buildServiceEnvironment({ + env: { + HOME: "/home/user", + OPENCLAW_WRAPPER: " /usr/local/bin/openclaw-doppler ", + }, + port: 18789, + }); + + expect(env.OPENCLAW_WRAPPER).toBe("/usr/local/bin/openclaw-doppler"); + }); + it("forwards TMPDIR from the host environment on Linux", () => { const env = buildServiceEnvironment({ env: { HOME: "/home/user", TMPDIR: "/var/folders/xw/abc123/T/" }, diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index c2fddf395f6..4233bdda3ae 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -295,12 +295,14 @@ export function buildServiceEnvironment(params: { params.execPath, ); const profile = env.OPENCLAW_PROFILE; + const wrapperPath = normalizeOptionalString(env.OPENCLAW_WRAPPER); const resolvedLaunchdLabel = launchdLabel || (platform === "darwin" ? resolveGatewayLaunchAgentLabel(profile) : undefined); const systemdUnit = `${resolveGatewaySystemdServiceName(profile)}.service`; return { ...buildCommonServiceEnvironment(env, sharedEnv), OPENCLAW_PROFILE: profile, + OPENCLAW_WRAPPER: wrapperPath, OPENCLAW_GATEWAY_PORT: String(port), OPENCLAW_LAUNCHD_LABEL: resolvedLaunchdLabel, OPENCLAW_SYSTEMD_UNIT: systemdUnit,