fix(gateway): land #28428 from @l0cka

Landed from contributor PR #28428 by @l0cka.

Co-authored-by: Daniel Alkurdi <danielalkurdi@gmail.com>
This commit is contained in:
Peter Steinberger
2026-03-07 22:49:50 +00:00
parent e83094e63f
commit 265367d99b
26 changed files with 289 additions and 165 deletions

View File

@@ -82,11 +82,8 @@ describe("maybeInstallDaemon", () => {
});
expect(resolveGatewayInstallToken).toHaveBeenCalledTimes(1);
expect(buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
token: undefined,
}),
);
expect(buildGatewayInstallPlan).toHaveBeenCalledTimes(1);
expect("token" in buildGatewayInstallPlan.mock.calls[0][0]).toBe(false);
expect(serviceInstall).toHaveBeenCalledTimes(1);
});

View File

@@ -112,7 +112,6 @@ export async function maybeInstallDaemon(params: {
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env,
port: params.port,
token: tokenResolution.token,
runtime: daemonRuntime,
warn: (message, title) => note(message, title),
config: cfg,

View File

@@ -23,7 +23,6 @@ export async function buildGatewayInstallPlan(params: {
env: Record<string, string | undefined>;
port: number;
runtime: GatewayDaemonRuntime;
token?: string;
devMode?: boolean;
nodePath?: string;
warn?: DaemonInstallWarnFn;
@@ -52,7 +51,6 @@ export async function buildGatewayInstallPlan(params: {
const serviceEnvironment = buildServiceEnvironment({
env: params.env,
port: params.port,
token: params.token,
launchdLabel:
process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(params.env.OPENCLAW_PROFILE)

View File

@@ -194,7 +194,6 @@ export async function maybeRepairGatewayDaemon(params: {
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env,
port,
token: tokenResolution.token,
runtime: daemonRuntime,
warn: (message, title) => note(message, title),
config: params.cfg,

View File

@@ -5,9 +5,10 @@ import { withEnvAsync } from "../test-utils/env.js";
const mocks = vi.hoisted(() => ({
readCommand: vi.fn(),
install: vi.fn(),
writeConfigFile: vi.fn().mockResolvedValue(undefined),
auditGatewayServiceConfig: vi.fn(),
buildGatewayInstallPlan: vi.fn(),
resolveGatewayInstallToken: vi.fn(),
resolveGatewayAuthTokenForService: vi.fn(),
resolveGatewayPort: vi.fn(() => 18789),
resolveIsNixMode: vi.fn(() => false),
findExtraGatewayServices: vi.fn().mockResolvedValue([]),
@@ -21,6 +22,10 @@ vi.mock("../config/paths.js", () => ({
resolveIsNixMode: mocks.resolveIsNixMode,
}));
vi.mock("../config/config.js", () => ({
writeConfigFile: mocks.writeConfigFile,
}));
vi.mock("../daemon/inspect.js", () => ({
findExtraGatewayServices: mocks.findExtraGatewayServices,
renderGatewayServiceCleanupHints: mocks.renderGatewayServiceCleanupHints,
@@ -58,8 +63,8 @@ vi.mock("./daemon-install-helpers.js", () => ({
buildGatewayInstallPlan: mocks.buildGatewayInstallPlan,
}));
vi.mock("./gateway-install-token.js", () => ({
resolveGatewayInstallToken: mocks.resolveGatewayInstallToken,
vi.mock("./doctor-gateway-auth-token.js", () => ({
resolveGatewayAuthTokenForService: mocks.resolveGatewayAuthTokenForService,
}));
import {
@@ -95,7 +100,7 @@ const gatewayProgramArguments = [
"18789",
];
function setupGatewayTokenRepairScenario(expectedToken: string) {
function setupGatewayTokenRepairScenario() {
mocks.readCommand.mockResolvedValue({
programArguments: gatewayProgramArguments,
environment: {
@@ -115,14 +120,7 @@ function setupGatewayTokenRepairScenario(expectedToken: string) {
mocks.buildGatewayInstallPlan.mockResolvedValue({
programArguments: gatewayProgramArguments,
workingDirectory: "/tmp",
environment: {
OPENCLAW_GATEWAY_TOKEN: expectedToken,
},
});
mocks.resolveGatewayInstallToken.mockResolvedValue({
token: expectedToken,
tokenRefConfigured: false,
warnings: [],
environment: {},
});
mocks.install.mockResolvedValue(undefined);
}
@@ -130,10 +128,16 @@ function setupGatewayTokenRepairScenario(expectedToken: string) {
describe("maybeRepairGatewayServiceConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
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 };
});
});
it("treats gateway.auth.token as source of truth for service token repairs", async () => {
setupGatewayTokenRepairScenario("config-token");
setupGatewayTokenRepairScenario();
const cfg: OpenClawConfig = {
gateway: {
@@ -153,15 +157,22 @@ describe("maybeRepairGatewayServiceConfig", () => {
);
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
token: "config-token",
config: expect.objectContaining({
gateway: expect.objectContaining({
auth: expect.objectContaining({
token: "config-token",
}),
}),
}),
}),
);
expect(mocks.writeConfigFile).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("env-token");
setupGatewayTokenRepairScenario();
const cfg: OpenClawConfig = {
gateway: {},
@@ -176,7 +187,22 @@ describe("maybeRepairGatewayServiceConfig", () => {
);
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
token: "env-token",
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.install).toHaveBeenCalledTimes(1);
@@ -190,11 +216,6 @@ describe("maybeRepairGatewayServiceConfig", () => {
OPENCLAW_GATEWAY_TOKEN: "stale-token",
},
});
mocks.resolveGatewayInstallToken.mockResolvedValue({
token: undefined,
tokenRefConfigured: true,
warnings: [],
});
mocks.auditGatewayServiceConfig.mockResolvedValue({
ok: false,
issues: [],
@@ -228,11 +249,56 @@ describe("maybeRepairGatewayServiceConfig", () => {
);
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
token: undefined,
config: cfg,
}),
);
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,
CLAWDBOT_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.install).toHaveBeenCalledTimes(1);
},
);
});
});
describe("maybeScanExtraGatewayServices", () => {

View File

@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import type { OpenClawConfig } from "../config/config.js";
import { writeConfigFile, type OpenClawConfig } from "../config/config.js";
import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import {
@@ -25,7 +25,6 @@ import { buildGatewayInstallPlan } from "./daemon-install-helpers.js";
import { DEFAULT_GATEWAY_DAEMON_RUNTIME, type GatewayDaemonRuntime } from "./daemon-runtime.js";
import { resolveGatewayAuthTokenForService } from "./doctor-gateway-auth-token.js";
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
import { resolveGatewayInstallToken } from "./gateway-install-token.js";
const execFileAsync = promisify(execFile);
@@ -259,24 +258,9 @@ export async function maybeRepairGatewayServiceConfig(
const port = resolveGatewayPort(cfg, process.env);
const runtimeChoice = detectGatewayRuntime(command.programArguments);
const installTokenResolution = await resolveGatewayInstallToken({
config: cfg,
env: process.env,
});
for (const warning of installTokenResolution.warnings) {
note(warning, "Gateway service config");
}
if (installTokenResolution.unavailableReason) {
note(
`Unable to verify gateway service token drift: ${installTokenResolution.unavailableReason}`,
"Gateway service config",
);
return;
}
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
const { programArguments } = await buildGatewayInstallPlan({
env: process.env,
port,
token: installTokenResolution.token,
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined,
warn: (message, title) => note(message, title),
@@ -332,13 +316,56 @@ export async function maybeRepairGatewayServiceConfig(
if (!repair) {
return;
}
const serviceEmbeddedToken = command.environment?.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined;
const gatewayTokenForRepair = expectedGatewayToken ?? serviceEmbeddedToken;
const configuredGatewayToken =
typeof cfg.gateway?.auth?.token === "string"
? cfg.gateway.auth.token.trim() || undefined
: undefined;
let cfgForServiceInstall = cfg;
if (!tokenRefConfigured && !configuredGatewayToken && gatewayTokenForRepair) {
const nextCfg: OpenClawConfig = {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
mode: cfg.gateway?.auth?.mode ?? "token",
token: gatewayTokenForRepair,
},
},
};
try {
await writeConfigFile(nextCfg);
cfgForServiceInstall = nextCfg;
note(
expectedGatewayToken
? "Persisted gateway.auth.token from environment before reinstalling service."
: "Persisted gateway.auth.token from existing service definition before reinstalling service.",
"Gateway",
);
} catch (err) {
runtime.error(`Failed to persist gateway.auth.token before service repair: ${String(err)}`);
return;
}
}
const updatedPort = resolveGatewayPort(cfgForServiceInstall, process.env);
const updatedPlan = await buildGatewayInstallPlan({
env: process.env,
port: updatedPort,
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined,
warn: (message, title) => note(message, title),
config: cfgForServiceInstall,
});
try {
await service.install({
env: process.env,
stdout: process.stdout,
programArguments,
workingDirectory,
environment,
programArguments: updatedPlan.programArguments,
workingDirectory: updatedPlan.workingDirectory,
environment: updatedPlan.environment,
});
} catch (err) {
runtime.error(`Gateway service update failed: ${String(err)}`);

View File

@@ -189,6 +189,8 @@ export async function resolveAuthForTarget(
}
return passwordResolution.value;
};
const withDiagnostics = <T extends { token?: string; password?: string }>(result: T) =>
diagnostics.length > 0 ? { ...result, diagnostics } : result;
if (target.kind === "configRemote" || target.kind === "sshTunnel") {
const remoteTokenValue = cfg.gateway?.remote?.token;
@@ -198,11 +200,7 @@ export async function resolveAuthForTarget(
const password = token
? undefined
: await resolvePassword(remotePasswordValue, "gateway.remote.password");
return {
token,
password,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
return withDiagnostics({ token, password });
}
const authDisabled = authMode === "none" || authMode === "trusted-proxy";
@@ -213,49 +211,39 @@ export async function resolveAuthForTarget(
const envToken = readGatewayTokenEnv();
const envPassword = readGatewayPasswordEnv();
if (tokenOnly) {
const token = await resolveToken(cfg.gateway?.auth?.token, "gateway.auth.token");
if (token) {
return withDiagnostics({ token });
}
if (envToken) {
return { token: envToken };
}
const token = await resolveToken(cfg.gateway?.auth?.token, "gateway.auth.token");
return {
token,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
return withDiagnostics({});
}
if (passwordOnly) {
const password = await resolvePassword(cfg.gateway?.auth?.password, "gateway.auth.password");
if (password) {
return withDiagnostics({ password });
}
if (envPassword) {
return { password: envPassword };
}
const password = await resolvePassword(cfg.gateway?.auth?.password, "gateway.auth.password");
return {
password,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
return withDiagnostics({});
}
const token = await resolveToken(cfg.gateway?.auth?.token, "gateway.auth.token");
if (token) {
return withDiagnostics({ token });
}
if (envToken) {
return { token: envToken };
}
const token = await resolveToken(cfg.gateway?.auth?.token, "gateway.auth.token");
if (token) {
return {
token,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
}
if (envPassword) {
return {
password: envPassword,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
return withDiagnostics({ password: envPassword });
}
const password = await resolvePassword(cfg.gateway?.auth?.password, "gateway.auth.password");
return {
token,
password,
...(diagnostics.length > 0 ? { diagnostics } : {}),
};
return withDiagnostics({ token, password });
}
export { pickGatewaySelfPresence };

View File

@@ -74,11 +74,8 @@ describe("installGatewayDaemonNonInteractive", () => {
});
expect(resolveGatewayInstallToken).toHaveBeenCalledTimes(1);
expect(buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
token: undefined,
}),
);
expect(buildGatewayInstallPlan).toHaveBeenCalledTimes(1);
expect("token" in buildGatewayInstallPlan.mock.calls[0][0]).toBe(false);
expect(serviceInstall).toHaveBeenCalledTimes(1);
});

View File

@@ -55,7 +55,6 @@ export async function installGatewayDaemonNonInteractive(params: {
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env,
port,
token: tokenResolution.token,
runtime: daemonRuntimeRaw,
warn: (message) => runtime.log(message),
config: params.nextConfig,