mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-07 07:58:36 +00:00
fix: persist gateway service wrappers
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -19,6 +19,7 @@ export type DaemonInstallOptions = {
|
||||
port?: string | number;
|
||||
runtime?: string;
|
||||
token?: string;
|
||||
wrapper?: string;
|
||||
force?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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/" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user