fix: persist gateway service wrappers

This commit is contained in:
Peter Steinberger
2026-04-27 01:32:30 +01:00
parent 414fd41a1f
commit 9f9bd41f40
14 changed files with 297 additions and 11 deletions

View File

@@ -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.

View File

@@ -425,11 +425,13 @@ openclaw gateway uninstall
<AccordionGroup>
<Accordion title="Command options">
- `gateway status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json`
- `gateway install`: `--port`, `--runtime <node|bun>`, `--token`, `--force`, `--json`
- `gateway install`: `--port`, `--runtime <node|bun>`, `--token`, `--wrapper <path>`, `--force`, `--json`
- `gateway uninstall|start|stop|restart`: `--json`
</Accordion>
<Accordion title="Service install and lifecycle notes">
- `gateway install` supports `--port`, `--runtime`, `--token`, `--force`, `--json`.
- `gateway install` supports `--port`, `--runtime`, `--token`, `--wrapper`, `--force`, `--json`.
- `--wrapper <path>` makes the managed service start through an executable wrapper, writing `ProgramArguments` as `<wrapper> 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.

View File

@@ -33,12 +33,14 @@ const buildGatewayInstallPlan = vi.fn(
port: number;
token?: string;
env?: NodeJS.ProcessEnv;
wrapperPath?: string;
existingEnvironment?: Record<string, string>;
}) => ({
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<string, string>;
}) => 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",
}),
);
});

View File

@@ -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<string, string | undefined>;
config: OpenClawConfig;
}): Promise<string | undefined> {
@@ -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;

View File

@@ -77,6 +77,7 @@ export function addGatewayServiceCommands(parent: Command, opts?: { statusDescri
.option("--port <port>", "Gateway port")
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
.option("--token <token>", "Gateway token (token auth)")
.option("--wrapper <path>", "Executable wrapper for generated service ProgramArguments")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (cmdOpts, command) => {

View File

@@ -19,6 +19,7 @@ export type DaemonInstallOptions = {
port?: string | number;
runtime?: string;
token?: string;
wrapper?: string;
force?: boolean;
json?: boolean;
};

View File

@@ -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: {

View File

@@ -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<string, string | undefined>;
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<string, string | undefined> = 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,

View File

@@ -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:

View File

@@ -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<string> {
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,

View File

@@ -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");
});
});

View File

@@ -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<string> {
const argv1 = process.argv[1];
if (!argv1) {
@@ -177,12 +180,42 @@ async function resolveBinaryPath(binary: string): Promise<string> {
}
}
export async function resolveOpenClawWrapperPath(
inputPath: string | undefined,
): Promise<string | undefined> {
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<GatewayProgramArgs> {
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<GatewayProgramArgs> {
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,
});
}

View File

@@ -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/" },

View File

@@ -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,