Files
openclaw/src/gateway/server-methods/update.test.ts
B.K. 22a0ca9e3b fix: hand off managed update run self-updates
Route managed LaunchAgent package self-updates through the post-exit CLI handoff path and persist handoff helper failures through the update restart sentinel so agent-invoked updates cannot stay pending indefinitely.

Add handoff ownership guards for stale helpers, atomic helper sentinel writes, and regression coverage for unrelated and newer pending sentinels.

Fixes #81894.

Co-authored-by: B.K. <bandark@mac.com>
2026-05-15 06:12:57 +01:00

542 lines
18 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
type RestartSentinelPayload,
} from "../../infra/restart-sentinel.js";
import type { RespawnSupervisor } from "../../infra/supervisor-markers.js";
import type { UpdateInstallSurface, UpdateRunResult } from "../../infra/update-runner.js";
// Capture the sentinel payload written during update.run
let capturedPayload: RestartSentinelPayload | undefined;
const runGatewayUpdateMock = vi.fn<() => Promise<UpdateRunResult>>();
const resolveUpdateInstallSurfaceMock = vi.fn<() => Promise<UpdateInstallSurface>>(async () => ({
kind: "git",
mode: "git",
root: "/tmp/openclaw",
packageRoot: "/tmp/openclaw",
}));
const getLatestUpdateRestartSentinelMock = vi.fn<() => RestartSentinelPayload | null>(() => null);
const recordLatestUpdateRestartSentinelMock = vi.fn();
const isRestartEnabledMock = vi.fn(() => true);
const readPackageVersionMock = vi.fn(async () => "1.0.0");
const detectRespawnSupervisorMock = vi.fn<() => RespawnSupervisor | null>(() => null);
const startManagedServiceUpdateHandoffMock = vi.fn(async () => ({
status: "started" as const,
pid: 12345,
command: "openclaw update --yes --timeout 1800",
logPath: "/tmp/openclaw-update-run-handoff/handoff.log",
}));
const scheduleGatewaySigusr1RestartMock = vi.fn(() => ({ scheduled: true }));
vi.mock("../../config/config.js", () => ({
getRuntimeConfig: () => ({ update: {} }),
}));
vi.mock("../../config/commands.flags.js", () => ({
isRestartEnabled: isRestartEnabledMock,
}));
vi.mock("../../config/sessions.js", () => ({
extractDeliveryInfo: (sessionKey: string | undefined) => {
if (!sessionKey) {
return { deliveryContext: undefined, threadId: undefined };
}
// Simulate a threaded Slack session
if (sessionKey.includes(":thread:")) {
return {
deliveryContext: { channel: "slack", to: "slack:C0123ABC", accountId: "workspace-1" },
threadId: "1234567890.123456",
};
}
return {
deliveryContext: { channel: "webchat", to: "webchat:user-123", accountId: "default" },
threadId: undefined,
};
},
}));
vi.mock("../../infra/openclaw-root.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/openclaw-root.js")>(
"../../infra/openclaw-root.js",
);
return {
...actual,
resolveOpenClawPackageRoot: async () => "/tmp/openclaw",
};
});
vi.mock("../../infra/restart-sentinel.js", async () => {
const actual = await vi.importActual("../../infra/restart-sentinel.js");
return {
...(actual as Record<string, unknown>),
writeRestartSentinel: async (payload: RestartSentinelPayload) => {
capturedPayload = payload;
return "/tmp/sentinel.json";
},
};
});
vi.mock("../../infra/restart.js", () => ({
scheduleGatewaySigusr1Restart: scheduleGatewaySigusr1RestartMock,
}));
vi.mock("../../infra/package-json.js", () => ({
readPackageVersion: readPackageVersionMock,
}));
vi.mock("../../infra/supervisor-markers.js", () => ({
detectRespawnSupervisor: detectRespawnSupervisorMock,
}));
vi.mock("../../infra/update-channels.js", () => ({
normalizeUpdateChannel: () => undefined,
}));
vi.mock("../../infra/update-runner.js", () => ({
resolveUpdateInstallSurface: resolveUpdateInstallSurfaceMock,
runGatewayUpdate: runGatewayUpdateMock,
}));
vi.mock("../protocol/index.js", () => ({
validateUpdateStatusParams: () => true,
validateUpdateRunParams: () => true,
}));
vi.mock("../server-restart-sentinel.js", () => ({
getLatestUpdateRestartSentinel: getLatestUpdateRestartSentinelMock,
recordLatestUpdateRestartSentinel: recordLatestUpdateRestartSentinelMock,
}));
vi.mock("./restart-request.js", () => ({
parseRestartRequestParams: (params: Record<string, unknown>) => ({
sessionKey: params.sessionKey,
note: params.note,
continuationMessage: params.continuationMessage,
restartDelayMs: undefined,
}),
}));
vi.mock("./update-managed-service-handoff.js", () => ({
startManagedServiceUpdateHandoff: startManagedServiceUpdateHandoffMock,
formatManagedServiceUpdateCommand: (timeoutMs?: number) =>
timeoutMs
? `openclaw update --yes --timeout ${Math.ceil(timeoutMs / 1000)}`
: "openclaw update --yes",
buildManagedServiceHandoffUnavailableMessage: (command: string) =>
`Run \`${command}\` from a shell outside the gateway service.`,
}));
vi.mock("./validation.js", () => ({
assertValidParams: () => true,
}));
beforeEach(() => {
capturedPayload = undefined;
isRestartEnabledMock.mockReset();
isRestartEnabledMock.mockReturnValue(true);
readPackageVersionMock.mockClear();
readPackageVersionMock.mockResolvedValue("1.0.0");
detectRespawnSupervisorMock.mockReset();
detectRespawnSupervisorMock.mockReturnValue(null);
runGatewayUpdateMock.mockClear();
runGatewayUpdateMock.mockResolvedValue({
status: "ok",
mode: "npm",
after: { version: "2.0.0" },
steps: [],
durationMs: 100,
});
resolveUpdateInstallSurfaceMock.mockClear();
resolveUpdateInstallSurfaceMock.mockResolvedValue({
kind: "git",
mode: "git",
root: "/tmp/openclaw",
packageRoot: "/tmp/openclaw",
});
getLatestUpdateRestartSentinelMock.mockClear();
recordLatestUpdateRestartSentinelMock.mockClear();
startManagedServiceUpdateHandoffMock.mockClear();
scheduleGatewaySigusr1RestartMock.mockClear();
scheduleGatewaySigusr1RestartMock.mockReturnValue({ scheduled: true });
});
async function invokeUpdateRun(
params: Record<string, unknown>,
respond?: (ok: boolean, response?: unknown) => void,
) {
const { updateHandlers } = await import("./update.js");
const onRespond = respond ?? (() => {});
await updateHandlers["update.run"]({
params,
respond: onRespond as never,
context: { getRuntimeConfig: () => ({ update: {} }) },
} as never);
}
function readCapturedPayload(): RestartSentinelPayload {
if (!capturedPayload) {
throw new Error("expected restart sentinel payload");
}
return capturedPayload;
}
function firstMockCall(
mock: { mock: { calls: Array<readonly unknown[]> } },
label: string,
): readonly unknown[] {
const call = mock.mock.calls[0];
if (!call) {
throw new Error(`expected ${label} call`);
}
return call;
}
describe("update.run sentinel deliveryContext", () => {
it("includes deliveryContext in sentinel payload when sessionKey is provided", async () => {
capturedPayload = undefined;
let responded = false;
await invokeUpdateRun({ sessionKey: "agent:main:webchat:dm:user-123" }, () => {
responded = true;
});
expect(responded).toBe(true);
const payload = readCapturedPayload();
expect(payload.deliveryContext).toEqual({
channel: "webchat",
to: "webchat:user-123",
accountId: "default",
});
expect(payload.continuation).toEqual({
kind: "agentTurn",
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
});
});
it("omits deliveryContext when no sessionKey is provided", async () => {
capturedPayload = undefined;
await invokeUpdateRun({});
const payload = readCapturedPayload();
expect(payload.deliveryContext).toBeUndefined();
expect(payload.threadId).toBeUndefined();
expect(payload.continuation).toBeUndefined();
});
it("includes threadId in sentinel payload for threaded sessions", async () => {
capturedPayload = undefined;
await invokeUpdateRun({ sessionKey: "agent:main:slack:dm:C0123ABC:thread:1234567890.123456" });
const payload = readCapturedPayload();
expect(payload.deliveryContext).toEqual({
channel: "slack",
to: "slack:C0123ABC",
accountId: "workspace-1",
});
expect(payload.threadId).toBe("1234567890.123456");
expect(payload.continuation).toEqual({
kind: "agentTurn",
message: DEFAULT_RESTART_SUCCESS_CONTINUATION_MESSAGE,
});
});
it("uses an explicit continuationMessage in successful update sentinels", async () => {
capturedPayload = undefined;
await invokeUpdateRun({
sessionKey: "agent:main:webchat:dm:user-123",
continuationMessage: "Check the running version and finish the update report.",
});
expect(readCapturedPayload().continuation).toEqual({
kind: "agentTurn",
message: "Check the running version and finish the update report.",
});
});
});
describe("update.run timeout normalization", () => {
it("enforces a 1000ms minimum timeout for tiny values", async () => {
await invokeUpdateRun({ timeoutMs: 1 });
expect(runGatewayUpdateMock).toHaveBeenCalledTimes(1);
const [updateParams] = firstMockCall(runGatewayUpdateMock, "gateway update") as [
{ timeoutMs?: number },
];
expect(updateParams?.timeoutMs).toBe(1000);
});
});
describe("update.run restart scheduling", () => {
it("schedules restart when update succeeds", async () => {
let payload: { ok: boolean; restart: unknown } | undefined;
await invokeUpdateRun({}, (_ok: boolean, response: unknown) => {
const typed = response as { ok: boolean; restart: unknown };
payload = typed;
});
expect(scheduleGatewaySigusr1RestartMock).toHaveBeenCalledTimes(1);
expect(payload?.ok).toBe(true);
expect(payload?.restart).toEqual({ scheduled: true });
});
it("skips restart when update fails", async () => {
runGatewayUpdateMock.mockResolvedValueOnce({
status: "error",
mode: "git",
reason: "build-failed",
steps: [],
durationMs: 100,
});
let payload: { ok: boolean; restart: unknown } | undefined;
await invokeUpdateRun(
{
sessionKey: "agent:main:webchat:dm:user-123",
continuationMessage: "This should not run after a failed update.",
},
(_ok: boolean, response: unknown) => {
const typed = response as { ok: boolean; restart: unknown };
payload = typed;
},
);
expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled();
expect(payload?.ok).toBe(false);
expect(payload?.restart).toBeNull();
expect(capturedPayload?.continuation).toBeUndefined();
});
it.each([
{ status: "skipped" as const, reason: "dirty" },
{ status: "skipped" as const, reason: "not-git-install" },
{ status: "skipped" as const, reason: "restart-disabled" },
{ status: "error" as const, reason: "deps-install-failed" },
{ status: "error" as const, reason: "build-failed" },
{ status: "error" as const, reason: "global-install-failed" },
])("returns ok=false for $status:$reason", async ({ status, reason }) => {
runGatewayUpdateMock.mockResolvedValueOnce({
status,
mode: "git",
reason,
steps: [],
durationMs: 100,
});
let payload: { ok: boolean; result?: { status?: string; reason?: string } } | undefined;
await invokeUpdateRun({}, (_ok: boolean, response: unknown) => {
payload = response as typeof payload;
});
expect(payload?.ok).toBe(false);
expect(payload?.result?.status).toBe(status);
expect(payload?.result?.reason).toBe(reason);
});
it("hands managed package updates to the CLI path instead of running them in-process", async () => {
detectRespawnSupervisorMock.mockReturnValueOnce("launchd");
resolveUpdateInstallSurfaceMock.mockResolvedValueOnce({
kind: "global",
mode: "npm",
root: "/tmp/openclaw-global",
packageRoot: "/tmp/openclaw-global",
});
let payload:
| {
ok: boolean;
result?: { status?: string; reason?: string; mode?: string };
sentinel?: { path?: string | null };
}
| undefined;
await invokeUpdateRun({}, (_ok: boolean, response: unknown) => {
payload = response as typeof payload;
});
expect(runGatewayUpdateMock).not.toHaveBeenCalled();
expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledTimes(1);
expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledWith(
expect.objectContaining({
root: "/tmp/openclaw",
handoffId: expect.any(String),
meta: expect.objectContaining({
handoffId: expect.any(String),
}),
}),
);
const [handoffParams] = firstMockCall(
startManagedServiceUpdateHandoffMock,
"managed handoff",
) as [{ handoffId?: string; meta?: { handoffId?: string } }];
expect(handoffParams.meta?.handoffId).toBe(handoffParams.handoffId);
expect(scheduleGatewaySigusr1RestartMock).toHaveBeenCalledTimes(1);
const [restartParams] = firstMockCall(
scheduleGatewaySigusr1RestartMock,
"gateway restart schedule",
) as [{ delayMs?: number; reason?: string; skipCooldown?: boolean; skipDeferral?: boolean }];
expect(restartParams?.reason).toBe("update.run");
expect(restartParams?.skipCooldown).toBe(true);
expect(restartParams?.skipDeferral).toBe(true);
expect(payload?.ok).toBe(true);
expect(payload?.result?.status).toBe("skipped");
expect(payload?.result?.reason).toBe("managed-service-handoff-started");
expect(
(payload as { handoff?: { status?: string; command?: string } } | undefined)?.handoff,
).toEqual({
status: "started",
pid: 12345,
command: "openclaw update --yes --timeout 1800",
});
expect(payload?.sentinel?.path).toBe("/tmp/sentinel.json");
const sentinel = readCapturedPayload();
expect(sentinel.kind).toBe("update");
expect(sentinel.status).toBe("skipped");
expect(sentinel.stats).toEqual(
expect.objectContaining({
handoffId: handoffParams.handoffId,
reason: "managed-service-handoff-started",
}),
);
expect(recordLatestUpdateRestartSentinelMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: "update",
status: "skipped",
stats: expect.objectContaining({
reason: "managed-service-handoff-started",
}),
}),
);
});
it("keeps git/dev updates on the in-process gateway update path", async () => {
runGatewayUpdateMock.mockResolvedValueOnce({
status: "ok",
mode: "git",
after: { version: "2.0.0" },
steps: [],
durationMs: 100,
});
resolveUpdateInstallSurfaceMock.mockResolvedValueOnce({
kind: "git",
mode: "git",
root: "/tmp/openclaw-git",
packageRoot: "/tmp/openclaw-git",
});
let payload:
| { ok: boolean; result?: { status?: string; mode?: string }; handoff?: unknown }
| undefined;
await invokeUpdateRun({}, (_ok: boolean, response: unknown) => {
payload = response as typeof payload;
});
expect(runGatewayUpdateMock).toHaveBeenCalledTimes(1);
expect(startManagedServiceUpdateHandoffMock).not.toHaveBeenCalled();
expect(payload?.ok).toBe(true);
expect(payload?.result?.status).toBe("ok");
expect(payload?.result?.mode).toBe("git");
expect(payload?.handoff).toBeUndefined();
expect(readCapturedPayload().status).toBe("ok");
});
it("returns a safe command when package updates cannot be handed off", async () => {
resolveUpdateInstallSurfaceMock.mockResolvedValueOnce({
kind: "global",
mode: "npm",
root: "/tmp/openclaw-global",
packageRoot: "/tmp/openclaw-global",
});
let payload:
| {
ok: boolean;
result?: { status?: string; reason?: string; mode?: string };
handoff?: { status?: string; command?: string; message?: string };
restart?: unknown;
}
| undefined;
await invokeUpdateRun({ timeoutMs: 1_800_000 }, (_ok: boolean, response: unknown) => {
payload = response as typeof payload;
});
expect(runGatewayUpdateMock).not.toHaveBeenCalled();
expect(startManagedServiceUpdateHandoffMock).not.toHaveBeenCalled();
expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled();
expect(payload?.ok).toBe(false);
expect(payload?.restart).toBeNull();
expect(payload?.result?.status).toBe("skipped");
expect(payload?.result?.reason).toBe("managed-service-handoff-unavailable");
expect(payload?.handoff).toEqual({
status: "unavailable",
command: "openclaw update --yes --timeout 1800",
message:
"Run `openclaw update --yes --timeout 1800` from a shell outside the gateway service.",
});
});
it("blocks global package installs when the gateway cannot restart afterward", async () => {
isRestartEnabledMock.mockReturnValue(false);
detectRespawnSupervisorMock.mockReturnValue(null);
resolveUpdateInstallSurfaceMock.mockResolvedValueOnce({
kind: "global",
mode: "npm",
root: "/tmp/openclaw-global",
packageRoot: "/tmp/openclaw-global",
});
let payload:
| { ok: boolean; result?: { status?: string; reason?: string; mode?: string } }
| undefined;
await invokeUpdateRun({}, (_ok: boolean, response: unknown) => {
payload = response as typeof payload;
});
expect(runGatewayUpdateMock).not.toHaveBeenCalled();
expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled();
expect(payload?.ok).toBe(false);
expect(payload?.result?.status).toBe("skipped");
expect(payload?.result?.reason).toBe("restart-unavailable");
expect(payload?.result?.mode).toBe("npm");
});
});
describe("update.status", () => {
it("returns the latest cached update sentinel", async () => {
getLatestUpdateRestartSentinelMock.mockReturnValueOnce({
kind: "update",
status: "ok",
ts: 1,
stats: {
after: { version: "2.0.0" },
},
});
const { updateHandlers } = await import("./update.js");
const respond = vi.fn();
await updateHandlers["update.status"]({
params: {},
respond,
} as never);
expect(respond).toHaveBeenCalledTimes(1);
const [ok, response] = firstMockCall(respond, "update status response") as [
boolean,
{ sentinel?: { kind?: string; status?: string } } | undefined,
];
expect(ok).toBe(true);
expect(response?.sentinel?.kind).toBe("update");
expect(response?.sentinel?.status).toBe("ok");
});
});