import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" ;
import type { OpenClawConfig } from "../config/config.js" ;
import { withEnvAsync } from "../test-utils/env.js" ;
import { createDoctorPrompter } from "./doctor-prompter.js" ;
import {
readEmbeddedGatewayTokenForTest,
testServiceAuditCodes,
} from "./doctor-service-audit.test-helpers.js" ;
const fsMocks = vi.hoisted(() => ({
realpath: vi.fn(),
}));
vi.mock("node:fs/promises" , async () => {
const actual = await vi.importActual<typeof import ("node:fs/promises" )>("node:fs/promises" );
return {
...actual,
default : {
...actual,
realpath: fsMocks.realpath,
},
realpath: fsMocks.realpath,
};
});
const mocks = vi.hoisted(() => ({
readCommand: vi.fn(),
stage: vi.fn(),
install: vi.fn(),
writeConfigFile: vi.fn().mockResolvedValue(undefined),
auditGatewayServiceConfig: vi.fn(),
buildGatewayInstallPlan: vi.fn(),
resolveGatewayAuthTokenForService: vi.fn(),
resolveGatewayPort: vi.fn(() => 18789 ),
resolveIsNixMode: vi.fn(() => false ),
findExtraGatewayServices: vi.fn().mockResolvedValue([]),
renderGatewayServiceCleanupHints: vi.fn().mockReturnValue([]),
uninstallLegacySystemdUnits: vi.fn().mockResolvedValue([]),
note: vi.fn(),
}));
vi.mock("../config/paths.js" , () => ({
resolveGatewayPort: mocks.resolveGatewayPort,
resolveIsNixMode: mocks.resolveIsNixMode,
}));
vi.mock("../config/config.js" , async () => {
const actual = await vi.importActual<typeof import ("../config/config.js" )>("../config/config.js" );
return {
...actual,
writeConfigFile: mocks.writeConfigFile,
};
});
vi.mock("../daemon/inspect.js" , () => ({
findExtraGatewayServices: mocks.findExtraGatewayServices,
renderGatewayServiceCleanupHints: mocks.renderGatewayServiceCleanupHints,
}));
vi.mock("../daemon/runtime-paths.js" , () => ({
renderSystemNodeWarning: vi.fn().mockReturnValue(undefined),
resolveSystemNodeInfo: vi.fn().mockResolvedValue(null ),
}));
vi.mock("../daemon/service-audit.js" , () => ({
auditGatewayServiceConfig: mocks.auditGatewayServiceConfig,
needsNodeRuntimeMigration: vi.fn(() => false ),
readEmbeddedGatewayToken: readEmbeddedGatewayTokenForTest,
SERVICE_AUDIT_CODES: {
gatewayEntrypointMismatch: testServiceAuditCodes.gatewayEntrypointMismatch,
},
}));
vi.mock("../daemon/service.js" , () => ({
resolveGatewayService: () => ({
readCommand: mocks.readCommand,
stage: mocks.stage,
install: mocks.install,
}),
}));
vi.mock("../daemon/systemd.js" , () => ({
uninstallLegacySystemdUnits: mocks.uninstallLegacySystemdUnits,
}));
vi.mock("../terminal/note.js" , () => ({
note: mocks.note,
}));
vi.mock("./daemon-install-helpers.js" , () => ({
buildGatewayInstallPlan: mocks.buildGatewayInstallPlan,
}));
vi.mock("./doctor-gateway-auth-token.js" , () => ({
resolveGatewayAuthTokenForService: mocks.resolveGatewayAuthTokenForService,
}));
import {
maybeRepairGatewayServiceConfig,
maybeScanExtraGatewayServices,
} 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() };
}
function makeDoctorPrompts() {
return {
confirm: vi.fn().mockResolvedValue(true ),
confirmAutoFix: vi.fn().mockResolvedValue(true ),
confirmAggressiveAutoFix: vi.fn().mockResolvedValue(true ),
confirmRuntimeRepair: vi.fn().mockResolvedValue(true ),
select: vi.fn().mockResolvedValue("node" ),
shouldRepair: false ,
shouldForce: false ,
repairMode: {
shouldRepair: false ,
shouldForce: false ,
nonInteractive: false ,
canPrompt: true ,
updateInProgress: false ,
},
};
}
async function runRepair(cfg: OpenClawConfig) {
await maybeRepairGatewayServiceConfig(cfg, "local" , makeDoctorIo(), makeDoctorPrompts());
}
async function runNonInteractiveRepair(params: {
cfg?: OpenClawConfig;
updateInProgress?: boolean ;
}) {
Object.defineProperty(process.stdin, "isTTY" , {
value: false ,
configurable: true ,
});
if (params.updateInProgress) {
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1" ;
} else {
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
}
await maybeRepairGatewayServiceConfig(
params.cfg ?? { gateway: {} },
"local" ,
makeDoctorIo(),
createDoctorPrompter({
runtime: makeDoctorIo(),
options: {
repair: true ,
nonInteractive: true ,
},
}),
);
}
const gatewayProgramArguments = [
"/usr/bin/node" ,
"/usr/local/bin/openclaw" ,
"gateway" ,
"--port" ,
"18789" ,
];
function createGatewayCommand(entrypoint: string) {
return {
programArguments: ["/usr/bin/node" , entrypoint, "gateway" , "--port" , "18789" ],
environment: {},
};
}
function setupGatewayEntrypointRepairScenario(params: {
currentEntrypoint: string;
installEntrypoint: string;
installWorkingDirectory?: string;
realpath?: (value: string) => Promise<string>;
realpathError?: Error;
}) {
mocks.readCommand.mockResolvedValue(createGatewayCommand(params.currentEntrypoint));
mocks.auditGatewayServiceConfig.mockResolvedValue({
ok: true ,
issues: [],
});
mocks.buildGatewayInstallPlan.mockResolvedValue({
...createGatewayCommand(params.installEntrypoint),
...(params.installWorkingDirectory ? { workingDirectory: params.installWorkingDirectory } : {}),
});
if (params.realpath) {
fsMocks.realpath.mockImplementation(params.realpath);
} else if (params.realpathError) {
fsMocks.realpath.mockRejectedValue(params.realpathError);
} else {
fsMocks.realpath.mockImplementation(async (value: string) => value);
}
}
function setupGatewayTokenRepairScenario() {
mocks.readCommand.mockResolvedValue({
programArguments: gatewayProgramArguments,
environment: {
OPENCLAW_GATEWAY_TOKEN: "stale-token" ,
},
});
mocks.auditGatewayServiceConfig.mockResolvedValue({
ok: false ,
issues: [
{
code: "gateway-token-mismatch" ,
message: "Gateway service OPENCLAW_GATEWAY_TOKEN does not match gateway.auth.token" ,
level: "recommended" ,
},
],
});
mocks.buildGatewayInstallPlan.mockResolvedValue({
programArguments: gatewayProgramArguments,
workingDirectory: "/tmp" ,
environment: {},
});
mocks.install.mockResolvedValue(undefined);
}
describe("maybeRepairGatewayServiceConfig" , () => {
beforeEach(() => {
vi.clearAllMocks();
fsMocks.realpath.mockImplementation(async (value: string) => value);
mocks.resolveGatewayAuthTokenForService.mockImplementation(async (cfg: OpenClawConfig, env) => {
const configToken =
typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway.auth.token.trim() : undefined;
const envToken = env.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined;
return { token: configToken || envToken };
});
});
afterEach(() => {
Object.defineProperty(process.stdin, "isTTY" , {
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 () => {
setupGatewayTokenRepairScenario();
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token" ,
token: "config-token" ,
},
},
};
await runRepair(cfg);
expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith(
expect.objectContaining({
expectedGatewayToken: "config-token" ,
}),
);
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
gateway: expect.objectContaining({
auth: expect.objectContaining({
token: "config-token" ,
}),
}),
}),
}),
);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.stage).not.toHaveBeenCalled();
expect(mocks.install).toHaveBeenCalledTimes(1 );
});
it("uses OPENCLAW_GATEWAY_TOKEN when config token is missing" , async () => {
await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => {
setupGatewayTokenRepairScenario();
const cfg: OpenClawConfig = {
gateway: {},
};
await runRepair(cfg);
expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith(
expect.objectContaining({
expectedGatewayToken: "env-token" ,
}),
);
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
gateway: expect.objectContaining({
auth: expect.objectContaining({
token: "env-token" ,
}),
}),
}),
}),
);
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
gateway: expect.objectContaining({
auth: expect.objectContaining({
token: "env-token" ,
}),
}),
}),
);
expect(mocks.stage).not.toHaveBeenCalled();
expect(mocks.install).toHaveBeenCalledTimes(1 );
});
});
it("does not flag entrypoint mismatch when symlink and realpath match" , async () => {
setupGatewayEntrypointRepairScenario({
currentEntrypoint: "/Users/test/Library/pnpm/global/5/node_modules/openclaw/dist/index.js" ,
installEntrypoint:
"/Users/test/Library/pnpm/global/5/node_modules/.pnpm/openclaw@2026.3.12/node_modules/openclaw/dist/index.js" ,
realpath: async (value: string) => {
if (value.includes("/global/5/node_modules/openclaw/" )) {
return value.replace(
"/global/5/node_modules/openclaw/" ,
"/global/5/node_modules/.pnpm/openclaw@2026.3.12/node_modules/openclaw/" ,
);
}
return value;
},
});
await runRepair({ gateway: {} });
expect(mocks.note).not.toHaveBeenCalledWith(
expect.stringContaining("Gateway service entrypoint does not match the current install." ),
"Gateway service config" ,
);
expect(mocks.stage).not.toHaveBeenCalled();
expect(mocks.install).not.toHaveBeenCalled();
});
it("does not flag entrypoint mismatch when realpath fails but normalized absolute paths match" , async () => {
setupGatewayEntrypointRepairScenario({
currentEntrypoint: "/opt/openclaw/../openclaw/dist/index.js" ,
installEntrypoint: "/opt/openclaw/dist/index.js" ,
realpathError: new Error("no realpath" ),
});
await runRepair({ gateway: {} });
expect(mocks.note).not.toHaveBeenCalledWith(
expect.stringContaining("Gateway service entrypoint does not match the current install." ),
"Gateway service config" ,
);
expect(mocks.stage).not.toHaveBeenCalled();
expect(mocks.install).not.toHaveBeenCalled();
});
it("still flags entrypoint mismatch when canonicalized paths differ" , async () => {
setupGatewayEntrypointRepairScenario({
currentEntrypoint:
"/Users/test/.nvm/versions/node/v22.0.0/lib/node_modules/openclaw/dist/index.js" ,
installEntrypoint: "/Users/test/Library/pnpm/global/5/node_modules/openclaw/dist/index.js" ,
});
await runRepair({ gateway: {} });
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("Gateway service entrypoint does not match the current install." ),
"Gateway service config" ,
);
expect(mocks.stage).not.toHaveBeenCalled();
expect(mocks.install).toHaveBeenCalledTimes(1 );
});
it("repairs entrypoint mismatch in non-interactive fix mode" , async () => {
setupGatewayEntrypointRepairScenario({
currentEntrypoint: "/Users/test/Library/npm/node_modules/openclaw/dist/entry.js" ,
installEntrypoint: "/Users/test/Library/npm/node_modules/openclaw/dist/index.js" ,
installWorkingDirectory: "/tmp" ,
});
await runNonInteractiveRepair({
cfg: { gateway: {} },
updateInProgress: false ,
});
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("Gateway service entrypoint does not match the current install." ),
"Gateway service config" ,
);
expect(mocks.stage).not.toHaveBeenCalled();
expect(mocks.install).toHaveBeenCalledTimes(1 );
});
it("stages service config repairs during non-interactive update repairs" , async () => {
setupGatewayEntrypointRepairScenario({
currentEntrypoint: "/Users/test/Library/npm/node_modules/openclaw/dist/entry.js" ,
installEntrypoint: "/Users/test/Library/npm/node_modules/openclaw/dist/index.js" ,
installWorkingDirectory: "/tmp" ,
});
await runNonInteractiveRepair({
cfg: { gateway: {} },
updateInProgress: true ,
});
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("Gateway service entrypoint does not match the current install." ),
"Gateway service config" ,
);
expect(mocks.stage).toHaveBeenCalledTimes(1 );
expect(mocks.install).not.toHaveBeenCalled();
});
it("treats SecretRef-managed gateway token as non-persisted service state" , async () => {
mocks.readCommand.mockResolvedValue({
programArguments: gatewayProgramArguments,
environment: {
OPENCLAW_GATEWAY_TOKEN: "stale-token" ,
},
});
mocks.auditGatewayServiceConfig.mockResolvedValue({
ok: false ,
issues: [],
});
mocks.buildGatewayInstallPlan.mockResolvedValue({
programArguments: gatewayProgramArguments,
workingDirectory: "/tmp" ,
environment: {},
});
mocks.install.mockResolvedValue(undefined);
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token" ,
token: {
source: "env" ,
provider: "default" ,
id: "OPENCLAW_GATEWAY_TOKEN" ,
},
},
},
};
await runRepair(cfg);
expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith(
expect.objectContaining({
expectedGatewayToken: undefined,
}),
);
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
config: cfg,
}),
);
expect(mocks.stage).not.toHaveBeenCalled();
expect(mocks.install).toHaveBeenCalledTimes(1 );
});
it("falls back to embedded service token when config and env tokens are missing" , async () => {
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: undefined,
},
async () => {
setupGatewayTokenRepairScenario();
const cfg: OpenClawConfig = {
gateway: {},
};
await runRepair(cfg);
expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith(
expect.objectContaining({
expectedGatewayToken: undefined,
}),
);
expect(mocks.writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
gateway: expect.objectContaining({
auth: expect.objectContaining({
token: "stale-token" ,
}),
}),
}),
);
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
gateway: expect.objectContaining({
auth: expect.objectContaining({
token: "stale-token" ,
}),
}),
}),
}),
);
expect(mocks.stage).not.toHaveBeenCalled();
expect(mocks.install).toHaveBeenCalledTimes(1 );
},
);
});
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.stage).toHaveBeenCalledTimes(1 );
expect(mocks.install).not.toHaveBeenCalled();
},
);
});
it("does not persist EnvironmentFile-backed service tokens into config" , async () => {
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: undefined,
},
async () => {
mocks.readCommand.mockResolvedValue({
programArguments: gatewayProgramArguments,
environment: {
OPENCLAW_GATEWAY_TOKEN: "env-file-token" ,
},
environmentValueSources: {
OPENCLAW_GATEWAY_TOKEN: "file" ,
},
});
mocks.auditGatewayServiceConfig.mockResolvedValue({
ok: false ,
issues: [],
});
mocks.buildGatewayInstallPlan.mockResolvedValue({
programArguments: gatewayProgramArguments,
workingDirectory: "/tmp" ,
environment: {},
});
mocks.install.mockResolvedValue(undefined);
const cfg: OpenClawConfig = {
gateway: {},
};
await runRepair(cfg);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
config: cfg,
}),
);
expect(mocks.stage).not.toHaveBeenCalled();
},
);
});
});
describe("maybeScanExtraGatewayServices" , () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.findExtraGatewayServices.mockResolvedValue([]);
mocks.renderGatewayServiceCleanupHints.mockReturnValue([]);
mocks.uninstallLegacySystemdUnits.mockResolvedValue([]);
});
it("removes legacy Linux user systemd services" , async () => {
mocks.findExtraGatewayServices.mockResolvedValue([
{
platform: "linux" ,
label: "clawdbot-gateway.service" ,
detail: "unit: /home/test/.config/systemd/user/clawdbot-gateway.service" ,
scope: "user" ,
legacy: true ,
},
]);
mocks.uninstallLegacySystemdUnits.mockResolvedValue([
{
name: "clawdbot-gateway" ,
unitPath: "/home/test/.config/systemd/user/clawdbot-gateway.service" ,
enabled: true ,
exists: true ,
},
]);
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const prompter = {
confirm: vi.fn(),
confirmAutoFix: vi.fn(),
confirmAggressiveAutoFix: vi.fn(),
confirmRuntimeRepair: vi.fn().mockResolvedValue(true ),
select: vi.fn(),
shouldRepair: false ,
shouldForce: false ,
repairMode: {
shouldRepair: false ,
shouldForce: false ,
nonInteractive: false ,
canPrompt: true ,
updateInProgress: false ,
},
};
await maybeScanExtraGatewayServices({ deep: false }, runtime, prompter);
expect(mocks.uninstallLegacySystemdUnits).toHaveBeenCalledTimes(1 );
expect(mocks.uninstallLegacySystemdUnits).toHaveBeenCalledWith({
env: process.env,
stdout: process.stdout,
});
expect(mocks.note).toHaveBeenCalledWith(
expect.stringContaining("clawdbot-gateway.service" ),
"Legacy gateway removed" ,
);
expect(runtime.log).toHaveBeenCalledWith(
"Legacy gateway services removed. Installing OpenClaw gateway next." ,
);
});
});
Messung V0.5 in Prozent C=100 H=99 G=99
¤ Dauer der Verarbeitung: 0.11 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland