Skip to content

Commit f4e47b7

Browse files
committed
fix gateway lifecycle in non-systemd environments
1 parent 373ef81 commit f4e47b7

10 files changed

Lines changed: 467 additions & 37 deletions

src/cli/daemon-cli/lifecycle.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ const callGatewayCli = vi.fn();
5353
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
5454
const loadConfig = vi.hoisted(() => vi.fn(() => ({})));
5555
const recoverInstalledLaunchAgent = vi.hoisted(() => vi.fn());
56+
const resolveGatewayProbeAuthSafeWithSecretInputs = vi.hoisted(() =>
57+
vi.fn(async () => ({ auth: undefined })),
58+
);
5659
const repairLoadedGatewayServiceForStart = vi.hoisted(() => vi.fn());
5760
const findInstalledSystemdGatewayScope = vi.hoisted(() =>
5861
vi.fn<() => Promise<{ scope: "user" | "system"; unitName: string; unitPath: string } | null>>(
@@ -133,6 +136,11 @@ vi.mock("./launchd-recovery.js", () => ({
133136
recoverInstalledLaunchAgent(args),
134137
}));
135138

139+
vi.mock("../../gateway/probe-auth.js", () => ({
140+
resolveGatewayProbeAuthSafeWithSecretInputs: (params: unknown) =>
141+
resolveGatewayProbeAuthSafeWithSecretInputs(params),
142+
}));
143+
136144
vi.mock("./start-repair.js", () => ({
137145
repairLoadedGatewayServiceForStart: (args: unknown) => repairLoadedGatewayServiceForStart(args),
138146
}));
@@ -214,6 +222,7 @@ describe("runDaemonRestart health checks", () => {
214222
isRestartEnabled.mockReset();
215223
loadConfig.mockReset();
216224
recoverInstalledLaunchAgent.mockReset();
225+
resolveGatewayProbeAuthSafeWithSecretInputs.mockReset();
217226
repairLoadedGatewayServiceForStart.mockReset();
218227

219228
service.readCommand.mockResolvedValue({
@@ -555,6 +564,36 @@ describe("runDaemonRestart health checks", () => {
555564
expect(service.restart).not.toHaveBeenCalled();
556565
});
557566

567+
it("passes resolved probe auth into unmanaged restart health checks", async () => {
568+
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
569+
resolveGatewayProbeAuthSafeWithSecretInputs.mockResolvedValue({
570+
auth: { token: "resolved-token" },
571+
});
572+
mockUnmanagedRestart({ runPostRestartCheck: true });
573+
574+
await runDaemonRestart({ json: true });
575+
576+
expect(waitForGatewayHealthyListener).toHaveBeenCalledWith(
577+
expect.objectContaining({
578+
port: 18789,
579+
auth: { token: "resolved-token" },
580+
}),
581+
);
582+
});
583+
584+
it("reuses one config/probe-auth lookup across unmanaged restart checks", async () => {
585+
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
586+
resolveGatewayProbeAuthSafeWithSecretInputs.mockResolvedValue({
587+
auth: { token: "resolved-token" },
588+
});
589+
mockUnmanagedRestart({ runPostRestartCheck: true });
590+
591+
await runDaemonRestart({ json: true });
592+
593+
expect(loadConfig).toHaveBeenCalledTimes(1);
594+
expect(resolveGatewayProbeAuthSafeWithSecretInputs).toHaveBeenCalledTimes(1);
595+
});
596+
558597
it("prefers launchd repair over unmanaged restart when an installed LaunchAgent is unloaded", async () => {
559598
vi.spyOn(process, "platform", "get").mockReturnValue("darwin");
560599
recoverInstalledLaunchAgent.mockResolvedValue({

src/cli/daemon-cli/lifecycle.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,17 @@ import {
1818
import type { SafeGatewayRestartRequestResult } from "../../infra/restart-coordinator.js";
1919
import { type GatewayRestartIntent, writeGatewayRestartIntentSync } from "../../infra/restart.js";
2020
import { defaultRuntime } from "../../runtime.js";
21+
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
2122
import { formatCliCommand } from "../command-format.js";
2223
import { parseDurationMs } from "../parse-duration.js";
2324
import { recoverInstalledLaunchAgent } from "./launchd-recovery.js";
24-
import { createNullWriter } from "./response.js";
2525
import {
2626
runServiceRestart,
2727
runServiceStart,
2828
runServiceStop,
2929
runServiceUninstall,
3030
} from "./lifecycle-core.js";
31+
import { createNullWriter } from "./response.js";
3132
import {
3233
DEFAULT_RESTART_HEALTH_ATTEMPTS,
3334
DEFAULT_RESTART_HEALTH_DELAY_MS,
@@ -45,6 +46,18 @@ import type { DaemonLifecycleOptions } from "./types.js";
4546
const POST_RESTART_HEALTH_ATTEMPTS = DEFAULT_RESTART_HEALTH_ATTEMPTS;
4647
const POST_RESTART_HEALTH_DELAY_MS = DEFAULT_RESTART_HEALTH_DELAY_MS;
4748
const WINDOWS_POST_RESTART_HEALTH_TIMEOUT_MS = 180_000;
49+
const gatewayProbeAuthModuleLoader = createLazyImportLoader(
50+
() => import("../../gateway/probe-auth.js"),
51+
);
52+
53+
type GatewayProbeAuthResolution = {
54+
auth: { token?: string; password?: string };
55+
warning?: string;
56+
};
57+
58+
function loadGatewayProbeAuthModule() {
59+
return gatewayProbeAuthModuleLoader.load();
60+
}
4861

4962
function postRestartHealthAttempts(): number {
5063
return process.platform === "win32"
@@ -89,13 +102,38 @@ function resolveGatewayPortFallback(): Promise<number> {
89102
.catch(() => resolveGatewayPort(undefined, process.env));
90103
}
91104

92-
async function assertUnmanagedGatewayRestartEnabled(port: number): Promise<void> {
105+
async function resolveGatewayRestartConfigContext(): Promise<{
106+
cfg: Awaited<ReturnType<typeof readBestEffortConfig>> | undefined;
107+
probeAuth?: GatewayProbeAuthResolution;
108+
}> {
93109
const cfg = await readBestEffortConfig().catch(() => undefined);
110+
return {
111+
cfg,
112+
probeAuth: cfg
113+
? await loadGatewayProbeAuthModule()
114+
.then(({ resolveGatewayProbeAuthSafeWithSecretInputs }) =>
115+
resolveGatewayProbeAuthSafeWithSecretInputs({
116+
cfg,
117+
mode: "local",
118+
env: process.env,
119+
}),
120+
)
121+
.catch(() => undefined)
122+
: undefined,
123+
};
124+
}
125+
126+
async function assertUnmanagedGatewayRestartEnabled(params: {
127+
port: number;
128+
cfg?: Awaited<ReturnType<typeof readBestEffortConfig>>;
129+
probeAuth?: GatewayProbeAuthResolution;
130+
}): Promise<void> {
131+
const cfg = params.cfg;
94132
const tlsEnabled = Boolean(cfg?.gateway?.tls?.enabled);
95133
const scheme = tlsEnabled ? "wss" : "ws";
96134
const probe = await probeGateway({
97-
url: `${scheme}://127.0.0.1:${port}`,
98-
auth: {
135+
url: `${scheme}://127.0.0.1:${params.port}`,
136+
auth: params.probeAuth?.auth ?? {
99137
token: normalizeOptionalString(process.env.OPENCLAW_GATEWAY_TOKEN),
100138
password: normalizeOptionalString(process.env.OPENCLAW_GATEWAY_PASSWORD),
101139
},
@@ -230,12 +268,16 @@ async function requestSafeGatewayRestart(opts: DaemonLifecycleOptions): Promise<
230268
async function restartGatewayWithoutServiceManager(
231269
port: number,
232270
restartIntent?: GatewayRestartIntent,
271+
context?: {
272+
cfg?: Awaited<ReturnType<typeof readBestEffortConfig>>;
273+
probeAuth?: GatewayProbeAuthResolution;
274+
},
233275
) {
234276
const managed = await handleSystemScopeSystemdGateway("restart");
235277
if (managed) {
236278
return managed;
237279
}
238-
await assertUnmanagedGatewayRestartEnabled(port);
280+
await assertUnmanagedGatewayRestartEnabled({ port, ...context });
239281
const pids = resolveVerifiedGatewayListenerPids(port);
240282
if (pids.length === 0) {
241283
return null;
@@ -325,6 +367,10 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
325367
const restartPort = await resolveGatewayLifecyclePort(service).catch(() =>
326368
resolveGatewayPortFallback(),
327369
);
370+
let restartContextPromise:
371+
| Promise<Awaited<ReturnType<typeof resolveGatewayRestartConfigContext>>>
372+
| undefined;
373+
const getRestartContext = () => (restartContextPromise ??= resolveGatewayRestartConfigContext());
328374
const restartHealthAttempts = postRestartHealthAttempts();
329375
const restartWaitMs = restartHealthAttempts * POST_RESTART_HEALTH_DELAY_MS;
330376
const restartWaitSeconds = Math.round(restartWaitMs / 1000);
@@ -345,7 +391,11 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
345391
return recovered;
346392
}
347393
}
348-
const handled = await restartGatewayWithoutServiceManager(restartPort, restartIntent);
394+
const handled = await restartGatewayWithoutServiceManager(
395+
restartPort,
396+
restartIntent,
397+
await getRestartContext(),
398+
);
349399
if (handled) {
350400
restartedWithoutServiceManager = true;
351401
return handled;
@@ -354,10 +404,12 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi
354404
},
355405
postRestartCheck: async ({ warnings, fail, stdout }) => {
356406
if (restartedWithoutServiceManager) {
407+
const restartContext = await getRestartContext();
357408
const health = await waitForGatewayHealthyListener({
358409
port: restartPort,
359410
attempts: restartHealthAttempts,
360411
delayMs: POST_RESTART_HEALTH_DELAY_MS,
412+
auth: restartContext.probeAuth?.auth,
361413
});
362414
if (health.healthy) {
363415
return undefined;

src/cli/daemon-cli/restart-health.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,26 @@ async function waitForStoppedFreeGatewayRestart() {
133133
});
134134
}
135135

136+
async function waitForGatewayHealthyListenerWithAuth(params: {
137+
auth?: { token?: string; password?: string };
138+
probeResult: Awaited<ReturnType<typeof probeGateway>>;
139+
}) {
140+
inspectPortUsage.mockResolvedValue({
141+
port: 18789,
142+
status: "busy",
143+
listeners: [{ pid: 8000, commandLine: "openclaw-gateway" }],
144+
hints: [],
145+
});
146+
probeGateway.mockResolvedValue(params.probeResult);
147+
const { waitForGatewayHealthyListener } = await import("./restart-health.js");
148+
return waitForGatewayHealthyListener({
149+
port: 18789,
150+
attempts: 1,
151+
delayMs: 1,
152+
auth: params.auth,
153+
});
154+
}
155+
136156
describe("inspectGatewayRestart", () => {
137157
beforeEach(() => {
138158
inspectPortUsage.mockReset();
@@ -326,6 +346,24 @@ describe("inspectGatewayRestart", () => {
326346
},
327347
);
328348

349+
it("forwards explicit auth into listener health probes", async () => {
350+
const snapshot = await waitForGatewayHealthyListenerWithAuth({
351+
auth: { token: "resolved-token" },
352+
probeResult: {
353+
ok: false,
354+
close: { code: 1008, reason: "auth required" },
355+
},
356+
});
357+
358+
expect(snapshot.healthy).toBe(true);
359+
expect(probeGateway).toHaveBeenCalledWith(
360+
expect.objectContaining({
361+
url: "ws://127.0.0.1:18789",
362+
auth: { token: "resolved-token", password: undefined },
363+
}),
364+
);
365+
});
366+
329367
it("requires the expected gateway version when provided", async () => {
330368
probeGateway.mockResolvedValue({
331369
ok: true,

src/cli/daemon-cli/restart-health.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ async function resolveGatewayRestartProbeAuth(
289289
async function inspectGatewayPortHealth(params: {
290290
port: number;
291291
auth?: GatewayRestartProbeAuth;
292+
env?: NodeJS.ProcessEnv;
292293
}): Promise<GatewayPortHealthSnapshot> {
293294
let portUsage: PortUsage;
294295
try {
@@ -310,7 +311,7 @@ async function inspectGatewayPortHealth(params: {
310311
await confirmGatewayReachable({
311312
port: params.port,
312313
auth: params.auth,
313-
env: process.env,
314+
env: params.env,
314315
})
315316
).reachable;
316317
} catch {
@@ -583,11 +584,13 @@ export async function waitForGatewayHealthyListener(params: {
583584
port: number;
584585
attempts?: number;
585586
delayMs?: number;
587+
auth?: GatewayRestartProbeAuth;
586588
}): Promise<GatewayPortHealthSnapshot> {
587589
const attempts = params.attempts ?? DEFAULT_RESTART_HEALTH_ATTEMPTS;
588590
const delayMs = params.delayMs ?? DEFAULT_RESTART_HEALTH_DELAY_MS;
589591

590-
const probeAuth = await resolveGatewayRestartProbeAuth(undefined).catch(() => undefined);
592+
const probeAuth =
593+
params.auth ?? (await resolveGatewayRestartProbeAuth(undefined).catch(() => undefined));
591594
let snapshot = await inspectGatewayPortHealth({
592595
port: params.port,
593596
auth: probeAuth,

src/infra/gateway-process-argv.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ describe("isGatewayArgv", () => {
6464
allowGatewayBinary: true,
6565
}),
6666
).toBe(true);
67+
expect(isGatewayArgv(["openclaw-gateway"])).toBe(false);
68+
expect(
69+
isGatewayArgv(["openclaw-gateway"], {
70+
allowGatewayBinary: true,
71+
}),
72+
).toBe(true);
6773
});
6874

6975
it("rejects unknown gateway argv even when the token is present", () => {

src/infra/gateway-process-argv.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ export function parseWindowsCmdline(raw: string): string[] {
3737

3838
export function isGatewayArgv(args: string[], opts?: { allowGatewayBinary?: boolean }): boolean {
3939
const normalized = args.map(normalizeProcArg);
40+
const exe = (normalized[0] ?? "").replace(/\.(bat|cmd|exe)$/i, "");
41+
const isGatewayBinary =
42+
opts?.allowGatewayBinary === true &&
43+
(exe.endsWith("/openclaw-gateway") || exe === "openclaw-gateway");
44+
45+
if (isGatewayBinary) {
46+
return true;
47+
}
48+
4049
if (!normalized.includes("gateway")) {
4150
return false;
4251
}
@@ -53,10 +62,5 @@ export function isGatewayArgv(args: string[], opts?: { allowGatewayBinary?: bool
5362
return true;
5463
}
5564

56-
const exe = (normalized[0] ?? "").replace(/\.(bat|cmd|exe)$/i, "");
57-
return (
58-
exe.endsWith("/openclaw") ||
59-
exe === "openclaw" ||
60-
(opts?.allowGatewayBinary === true && exe.endsWith("/openclaw-gateway"))
61-
);
65+
return exe.endsWith("/openclaw") || exe === "openclaw";
6266
}

src/infra/gateway-processes.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,21 @@ describe("gateway-processes", () => {
147147
);
148148
});
149149

150+
it("accepts bare openclaw-gateway process titles when gateway binary verification is enabled", () => {
151+
setPlatform("linux");
152+
readFileSyncMock.mockReturnValue("openclaw-gateway\0");
153+
parseProcCmdlineMock.mockReturnValue(["openclaw-gateway"]);
154+
isGatewayArgvMock.mockReturnValue(true);
155+
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
156+
157+
signalVerifiedGatewayPidSync(700, "SIGUSR1");
158+
159+
expect(isGatewayArgvMock).toHaveBeenCalledWith(["openclaw-gateway"], {
160+
allowGatewayBinary: true,
161+
});
162+
expect(killSpy).toHaveBeenCalledWith(700, "SIGUSR1");
163+
});
164+
150165
it("dedupes and filters verified gateway listener pids on unix and windows", () => {
151166
setPlatform("linux");
152167
findGatewayPidsOnPortSyncMock.mockReturnValue([process.pid, 200, 200, 300, -1]);

0 commit comments

Comments
 (0)