diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts new file mode 100644 index 0000000000..343ba35798 --- /dev/null +++ b/src/commands/doctor-gateway-services.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; + +const mocks = vi.hoisted(() => ({ + readCommand: vi.fn(), + install: vi.fn(), + auditGatewayServiceConfig: vi.fn(), + buildGatewayInstallPlan: vi.fn(), + resolveGatewayPort: vi.fn(() => 18789), + resolveIsNixMode: vi.fn(() => false), + note: vi.fn(), +})); + +vi.mock("../config/paths.js", () => ({ + resolveGatewayPort: mocks.resolveGatewayPort, + resolveIsNixMode: mocks.resolveIsNixMode, +})); + +vi.mock("../daemon/inspect.js", () => ({ + findExtraGatewayServices: vi.fn().mockResolvedValue([]), + renderGatewayServiceCleanupHints: vi.fn().mockReturnValue([]), +})); + +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), + SERVICE_AUDIT_CODES: { + gatewayEntrypointMismatch: "gateway-entrypoint-mismatch", + }, +})); + +vi.mock("../daemon/service.js", () => ({ + resolveGatewayService: () => ({ + readCommand: mocks.readCommand, + install: mocks.install, + }), +})); + +vi.mock("../terminal/note.js", () => ({ + note: mocks.note, +})); + +vi.mock("./daemon-install-helpers.js", () => ({ + buildGatewayInstallPlan: mocks.buildGatewayInstallPlan, +})); + +import { maybeRepairGatewayServiceConfig } from "./doctor-gateway-services.js"; + +describe("maybeRepairGatewayServiceConfig", () => { + it("treats gateway.auth.token as source of truth for service token repairs", async () => { + mocks.readCommand.mockResolvedValue({ + programArguments: ["/usr/bin/node", "/usr/local/bin/openclaw", "gateway", "--port", "18789"], + 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: ["/usr/bin/node", "/usr/local/bin/openclaw", "gateway", "--port", "18789"], + workingDirectory: "/tmp", + environment: { + OPENCLAW_GATEWAY_TOKEN: "config-token", + }, + }); + mocks.install.mockResolvedValue(undefined); + + const cfg: OpenClawConfig = { + gateway: { + auth: { + mode: "token", + token: "config-token", + }, + }, + }; + + await maybeRepairGatewayServiceConfig( + cfg, + "local", + { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + { + confirm: vi.fn().mockResolvedValue(true), + confirmRepair: vi.fn().mockResolvedValue(true), + confirmAggressive: vi.fn().mockResolvedValue(true), + confirmSkipInNonInteractive: vi.fn().mockResolvedValue(true), + select: vi.fn().mockResolvedValue("node"), + shouldRepair: false, + shouldForce: false, + }, + ); + + expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith( + expect.objectContaining({ + expectedGatewayToken: "config-token", + }), + ); + expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ + token: "config-token", + }), + ); + expect(mocks.install).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index b2861681e9..3280054538 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -118,6 +118,7 @@ export async function maybeRepairGatewayServiceConfig( const audit = await auditGatewayServiceConfig({ env: process.env, command, + expectedGatewayToken: cfg.gateway?.auth?.token, }); const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues); const systemNodeInfo = needsNodeRuntime diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index e8e8d89ff8..10fcd214ae 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -60,4 +60,40 @@ describe("auditGatewayServiceConfig", () => { audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathMissingDirs), ).toBe(false); }); + + it("flags gateway token mismatch when service token is stale", async () => { + const audit = await auditGatewayServiceConfig({ + env: { HOME: "/tmp" }, + platform: "linux", + expectedGatewayToken: "new-token", + command: { + programArguments: ["/usr/bin/node", "gateway"], + environment: { + PATH: "/usr/local/bin:/usr/bin:/bin", + OPENCLAW_GATEWAY_TOKEN: "old-token", + }, + }, + }); + expect( + audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch), + ).toBe(true); + }); + + it("does not flag gateway token mismatch when service token matches config token", async () => { + const audit = await auditGatewayServiceConfig({ + env: { HOME: "/tmp" }, + platform: "linux", + expectedGatewayToken: "new-token", + command: { + programArguments: ["/usr/bin/node", "gateway"], + environment: { + PATH: "/usr/local/bin:/usr/bin:/bin", + OPENCLAW_GATEWAY_TOKEN: "new-token", + }, + }, + }); + expect( + audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch), + ).toBe(false); + }); }); diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index b0dda2a76a..ce12969fd0 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -34,6 +34,7 @@ export const SERVICE_AUDIT_CODES = { gatewayPathMissing: "gateway-path-missing", gatewayPathMissingDirs: "gateway-path-missing-dirs", gatewayPathNonMinimal: "gateway-path-nonminimal", + gatewayTokenMismatch: "gateway-token-mismatch", gatewayRuntimeBun: "gateway-runtime-bun", gatewayRuntimeNodeVersionManager: "gateway-runtime-node-version-manager", gatewayRuntimeNodeSystemMissing: "gateway-runtime-node-system-missing", @@ -200,6 +201,28 @@ function auditGatewayCommand(programArguments: string[] | undefined, issues: Ser } } +function auditGatewayToken( + command: GatewayServiceCommand, + issues: ServiceConfigIssue[], + expectedGatewayToken?: string, +) { + const expectedToken = expectedGatewayToken?.trim(); + if (!expectedToken) { + return; + } + const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim(); + if (serviceToken === expectedToken) { + return; + } + issues.push({ + code: SERVICE_AUDIT_CODES.gatewayTokenMismatch, + message: + "Gateway service OPENCLAW_GATEWAY_TOKEN does not match gateway.auth.token in openclaw.json", + detail: serviceToken ? "service token is stale" : "service token is missing", + level: "recommended", + }); +} + function isNodeRuntime(execPath: string): boolean { const base = path.basename(execPath).toLowerCase(); return base === "node" || base === "node.exe"; @@ -341,11 +364,13 @@ export async function auditGatewayServiceConfig(params: { env: Record; command: GatewayServiceCommand; platform?: NodeJS.Platform; + expectedGatewayToken?: string; }): Promise { const issues: ServiceConfigIssue[] = []; const platform = params.platform ?? process.platform; auditGatewayCommand(params.command?.programArguments, issues); + auditGatewayToken(params.command, issues, params.expectedGatewayToken); auditGatewayServicePath(params.command, issues, params.env, platform); await auditGatewayRuntime(params.env, params.command, issues, platform);