fix(doctor): skip service config repairs during updates

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
giulio-leone
2026-03-24 04:54:32 +01:00
committed by Peter Steinberger
parent d8aada9d45
commit 67c7f98c32
4 changed files with 190 additions and 2 deletions

View File

@@ -99,6 +99,7 @@ import {
} from "./doctor-gateway-services.js";
const originalStdinIsTTY = process.stdin.isTTY;
const originalUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
function makeDoctorIo() {
return { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
@@ -170,6 +171,11 @@ describe("maybeRepairGatewayServiceConfig", () => {
value: originalStdinIsTTY,
configurable: true,
});
if (originalUpdateInProgress === undefined) {
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
} else {
process.env.OPENCLAW_UPDATE_IN_PROGRESS = originalUpdateInProgress;
}
});
it("treats gateway.auth.token as source of truth for service token repairs", async () => {
@@ -411,6 +417,58 @@ describe("maybeRepairGatewayServiceConfig", () => {
expect(mocks.install).toHaveBeenCalledTimes(1);
});
it("skips service config reinstalls during non-interactive update repairs", async () => {
Object.defineProperty(process.stdin, "isTTY", {
value: false,
configurable: true,
});
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
mocks.readCommand.mockResolvedValue({
programArguments: [
"/usr/bin/node",
"/Users/test/Library/npm/node_modules/openclaw/dist/entry.js",
"gateway",
"--port",
"18789",
],
environment: {},
});
mocks.auditGatewayServiceConfig.mockResolvedValue({
ok: true,
issues: [],
});
mocks.buildGatewayInstallPlan.mockResolvedValue({
programArguments: [
"/usr/bin/node",
"/Users/test/Library/npm/node_modules/openclaw/dist/index.js",
"gateway",
"--port",
"18789",
],
workingDirectory: "/tmp",
environment: {},
});
await maybeRepairGatewayServiceConfig(
{ gateway: {} },
"local",
makeDoctorIo(),
createDoctorPrompter({
runtime: makeDoctorIo(),
options: {
repair: true,
nonInteractive: true,
},
}),
);
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("Gateway service entrypoint does not match the current install."),
"Gateway service config",
);
expect(mocks.install).not.toHaveBeenCalled();
});
it("treats SecretRef-managed gateway token as non-persisted service state", async () => {
mocks.readCommand.mockResolvedValue({
programArguments: gatewayProgramArguments,
@@ -501,6 +559,43 @@ describe("maybeRepairGatewayServiceConfig", () => {
);
});
it("does not persist embedded service tokens during non-interactive update repairs", async () => {
Object.defineProperty(process.stdin, "isTTY", {
value: false,
configurable: true,
});
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: undefined,
},
async () => {
setupGatewayTokenRepairScenario();
const cfg: OpenClawConfig = {
gateway: {},
};
await maybeRepairGatewayServiceConfig(
cfg,
"local",
makeDoctorIo(),
createDoctorPrompter({
runtime: makeDoctorIo(),
options: {
repair: true,
nonInteractive: true,
},
}),
);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.install).not.toHaveBeenCalled();
},
);
});
it("does not persist EnvironmentFile-backed service tokens into config", async () => {
await withEnvAsync(
{

View File

@@ -321,7 +321,7 @@ export async function maybeRepairGatewayServiceConfig(
message: "Overwrite gateway service config with current defaults now?",
initialValue: Boolean(prompter.shouldForce),
})
: await prompter.confirmRepair({
: await prompter.confirmSkipInNonInteractive({
message: "Update gateway service config to the recommended defaults now?",
initialValue: true,
});

View File

@@ -93,6 +93,19 @@ export const findExtraGatewayServices = vi.fn().mockResolvedValue([]) as unknown
export const renderGatewayServiceCleanupHints = vi
.fn()
.mockReturnValue(["cleanup"]) as unknown as MockFn;
export const auditGatewayServiceConfig = vi
.fn()
.mockResolvedValue({ ok: true, issues: [] }) as unknown as MockFn;
export const buildGatewayInstallPlan = vi.mocked(
vi.fn().mockResolvedValue({
programArguments: ["node", "cli", "gateway", "--port", "18789"],
workingDirectory: "/tmp",
environment: {},
}),
) as unknown as MockFn;
export const resolveGatewayAuthTokenForService = vi
.fn()
.mockResolvedValue({ token: undefined }) as unknown as MockFn;
export const resolveGatewayProgramArguments = vi.fn().mockResolvedValue({
programArguments: ["node", "cli", "gateway", "--port", "18789"],
}) as unknown as MockFn;
@@ -101,6 +114,7 @@ export const serviceIsLoaded = vi.fn().mockResolvedValue(false) as unknown as Mo
export const serviceStop = vi.fn().mockResolvedValue(undefined) as unknown as MockFn;
export const serviceRestart = vi.fn().mockResolvedValue(undefined) as unknown as MockFn;
export const serviceUninstall = vi.fn().mockResolvedValue(undefined) as unknown as MockFn;
export const serviceReadCommand = vi.fn().mockResolvedValue(null) as unknown as MockFn;
export const callGateway = vi
.fn()
.mockRejectedValue(new Error("gateway closed")) as unknown as MockFn;
@@ -207,10 +221,37 @@ vi.mock("../daemon/inspect.js", () => ({
renderGatewayServiceCleanupHints,
}));
vi.mock("../daemon/service-audit.js", () => ({
auditGatewayServiceConfig,
needsNodeRuntimeMigration: vi.fn(() => false),
readEmbeddedGatewayToken: (
command: {
environment?: Record<string, string>;
environmentValueSources?: Record<string, "inline" | "file">;
} | null,
) =>
command?.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN === "file"
? undefined
: command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined,
SERVICE_AUDIT_CODES: {
gatewayEntrypointMismatch: "gateway-entrypoint-mismatch",
gatewayTokenMismatch: "gateway-token-mismatch",
},
}));
vi.mock("../daemon/program-args.js", () => ({
resolveGatewayProgramArguments,
}));
vi.mock("./daemon-install-helpers.js", () => ({
buildGatewayInstallPlan,
gatewayInstallErrorHint: vi.fn(() => "hint"),
}));
vi.mock("./doctor-gateway-auth-token.js", () => ({
resolveGatewayAuthTokenForService,
}));
vi.mock("../gateway/call.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../gateway/call.js")>();
return {
@@ -250,7 +291,7 @@ vi.mock("../daemon/service.js", () => ({
stop: serviceStop,
restart: serviceRestart,
isLoaded: serviceIsLoaded,
readCommand: vi.fn(),
readCommand: serviceReadCommand,
readRuntime: vi.fn().mockResolvedValue({ status: "running" }),
}),
}));
@@ -390,6 +431,13 @@ beforeEach(() => {
uninstallLegacyGatewayServices.mockReset().mockResolvedValue([]);
findExtraGatewayServices.mockReset().mockResolvedValue([]);
renderGatewayServiceCleanupHints.mockReset().mockReturnValue(["cleanup"]);
auditGatewayServiceConfig.mockReset().mockResolvedValue({ ok: true, issues: [] });
buildGatewayInstallPlan.mockReset().mockResolvedValue({
programArguments: ["node", "cli", "gateway", "--port", "18789"],
workingDirectory: "/tmp",
environment: {},
});
resolveGatewayAuthTokenForService.mockReset().mockResolvedValue({ token: undefined });
resolveGatewayProgramArguments.mockReset().mockResolvedValue({
programArguments: ["node", "cli", "gateway", "--port", "18789"],
});
@@ -398,6 +446,7 @@ beforeEach(() => {
serviceStop.mockReset().mockResolvedValue(undefined);
serviceRestart.mockReset().mockResolvedValue(undefined);
serviceUninstall.mockReset().mockResolvedValue(undefined);
serviceReadCommand.mockReset().mockResolvedValue(null);
callGateway.mockReset().mockRejectedValue(new Error("gateway closed"));
runStartupMatrixMigration.mockReset().mockResolvedValue(undefined);

View File

@@ -1,11 +1,15 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
auditGatewayServiceConfig,
buildGatewayInstallPlan,
confirm,
createDoctorRuntime,
mockDoctorConfigSnapshot,
serviceReadCommand,
serviceInstall,
serviceIsLoaded,
serviceRestart,
writeConfigFile,
} from "./doctor.e2e-harness.js";
let doctorCommand: typeof import("./doctor.js").doctorCommand;
@@ -49,4 +53,44 @@ describe("doctor command update-mode repairs", () => {
expect(serviceRestart).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
});
it("skips gateway service-config reinstalls and token persistence during non-interactive update repairs", async () => {
mockDoctorConfigSnapshot({ config: { gateway: {} }, parsed: { gateway: {} } });
vi.mocked(healthCommand).mockRejectedValueOnce(new Error("gateway closed"));
serviceIsLoaded.mockResolvedValueOnce(false);
serviceReadCommand.mockResolvedValueOnce({
programArguments: ["node", "cli", "gateway", "--port", "18789"],
environment: {
OPENCLAW_GATEWAY_TOKEN: "stale-token",
},
});
auditGatewayServiceConfig.mockResolvedValueOnce({
ok: false,
issues: [
{
code: "gateway-token-mismatch",
message: "Gateway service OPENCLAW_GATEWAY_TOKEN does not match gateway.auth.token",
level: "recommended",
},
],
});
buildGatewayInstallPlan.mockResolvedValue({
programArguments: ["node", "cli", "gateway", "--port", "18789"],
workingDirectory: "/tmp",
environment: {},
});
serviceInstall.mockClear();
serviceRestart.mockClear();
writeConfigFile.mockClear();
confirm.mockClear();
await doctorCommand(createDoctorRuntime(), { repair: true, nonInteractive: true });
expect(writeConfigFile).not.toHaveBeenCalled();
expect(serviceInstall).not.toHaveBeenCalled();
expect(serviceRestart).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
});
});