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,6 +82,7 @@ Docs: https://docs.openclaw.ai
- Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example `@Bot/model` and `@Bot /reset`) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai.
- Control UI/auth token separation: keep the shared gateway token in browser auth validation while reserving cached device tokens for signed device payloads, preventing false `device token mismatch` disconnects after restart/rotation. Landed from contributor PR #37382 by @FradSer. Thanks @FradSer.
- Gateway/browser auth reconnect hardening: stop counting missing token/password submissions as auth rate-limit failures, and stop auto-reconnecting Control UI clients on non-recoverable auth errors so misconfigured browser tabs no longer lock out healthy sessions. Landed from contributor PR #38725 by @ademczuk. Thanks @ademczuk.
- Gateway/service token drift repair: stop persisting shared auth tokens into installed gateway service units, flag stale embedded service tokens for reinstall, and treat tokenless service env as canonical so token rotation/reboot flows stay aligned with config/env resolution. Landed from contributor PR #28428 by @l0cka. Thanks @l0cka.
- Agents/context pruning: guard assistant thinking/text char estimation against malformed blocks (missing `thinking`/`text` strings or null entries) so pruning no longer crashes with malformed provider content. (openclaw#35146) thanks @Sid-Qin.
- Agents/transcript policy: set `preserveSignatures` to Anthropic-only handling in `resolveTranscriptPolicy` so Anthropic thinking signatures are preserved while non-Anthropic providers remain unchanged. (#32813) thanks @Sid-Qin.
- Agents/schema cleaning: detect Venice + Grok model IDs as xAI-proxied targets so unsupported JSON Schema keywords are stripped before requests, preventing Venice/Grok `Invalid arguments` failures. (openclaw#35355) thanks @Sid-Qin.

View File

@@ -116,7 +116,7 @@ describe("runDaemonInstall integration", () => {
expect(joined).toContain("MISSING_GATEWAY_TOKEN");
});
it("auto-mints token when no source exists and persists the same token used for install env", async () => {
it("auto-mints token when no source exists without embedding it into service env", async () => {
await fs.writeFile(
configPath,
JSON.stringify(
@@ -143,6 +143,6 @@ describe("runDaemonInstall integration", () => {
expect((persistedToken ?? "").length).toBeGreaterThan(0);
const installEnv = serviceMock.install.mock.calls[0]?.[0]?.environment;
expect(installEnv?.OPENCLAW_GATEWAY_TOKEN).toBe(persistedToken);
expect(installEnv?.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
});
});

View File

@@ -197,11 +197,8 @@ describe("runDaemonInstall", () => {
await runDaemonInstall({ json: true });
expect(actionState.failed).toEqual([]);
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
expect.objectContaining({
token: undefined,
}),
);
expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1);
expect("token" in buildGatewayInstallPlanMock.mock.calls[0][0]).toBe(false);
expect(writeConfigFileMock).not.toHaveBeenCalled();
expect(
actionState.warnings.some((warning) =>
@@ -225,11 +222,8 @@ describe("runDaemonInstall", () => {
expect(actionState.failed).toEqual([]);
expect(resolveSecretRefValuesMock).toHaveBeenCalledTimes(1);
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
expect.objectContaining({
token: undefined,
}),
);
expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1);
expect("token" in buildGatewayInstallPlanMock.mock.calls[0][0]).toBe(false);
});
it("auto-mints and persists token when no source exists", async () => {
@@ -249,8 +243,9 @@ describe("runDaemonInstall", () => {
};
expect(writtenConfig.gateway?.auth?.token).toBe("minted-token");
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
expect.objectContaining({ token: "minted-token", port: 18789 }),
expect.objectContaining({ port: 18789 }),
);
expect("token" in buildGatewayInstallPlanMock.mock.calls[0][0]).toBe(false);
expect(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1);
expect(actionState.warnings.some((warning) => warning.includes("Auto-generated"))).toBe(true);
});

View File

@@ -91,7 +91,6 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env,
port,
token: tokenResolution.token,
runtime: runtimeRaw,
warn: (message) => {
if (json) {

View File

@@ -83,7 +83,7 @@ describe("runServiceRestart token drift", () => {
expect(payload.warnings?.[0]).toContain("gateway install --force");
});
it("uses env-first token precedence when checking drift", async () => {
it("uses gateway.auth.token when checking drift", async () => {
loadConfig.mockReturnValue({
gateway: {
auth: {
@@ -106,7 +106,7 @@ describe("runServiceRestart token drift", () => {
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const payload = JSON.parse(jsonLine ?? "{}") as { warnings?: string[] };
expect(payload.warnings).toBeUndefined();
expect(payload.warnings?.[0]).toContain("gateway install --force");
});
it("skips drift warning when disabled", async () => {

View File

@@ -5,10 +5,7 @@ import { checkTokenDrift } from "../../daemon/service-audit.js";
import type { GatewayService } from "../../daemon/service.js";
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
import {
isGatewaySecretRefUnavailableError,
resolveGatewayCredentialsFromConfig,
} from "../../gateway/credentials.js";
import { isGatewaySecretRefUnavailableError } from "../../gateway/credentials.js";
import { isWSL } from "../../infra/wsl.js";
import { defaultRuntime } from "../../runtime.js";
import {
@@ -284,11 +281,7 @@ export async function runServiceRestart(params: {
const command = await params.service.readCommand(process.env);
const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN;
const cfg = loadConfig();
const configToken = resolveGatewayCredentialsFromConfig({
cfg,
env: process.env,
modeOverride: "local",
}).token;
const configToken = cfg.gateway?.auth?.token?.trim() || undefined;
const driftIssue = checkTokenDrift({ serviceToken, configToken });
if (driftIssue) {
const warning = driftIssue.detail

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,

View File

@@ -78,12 +78,15 @@ describe("auditGatewayServiceConfig", () => {
},
},
});
expect(
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenEmbedded),
).toBe(true);
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 () => {
it("flags embedded service token even when it matches config token", async () => {
const audit = await auditGatewayServiceConfig({
env: { HOME: "/tmp" },
platform: "linux",
@@ -96,6 +99,29 @@ describe("auditGatewayServiceConfig", () => {
},
},
});
expect(
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenEmbedded),
).toBe(true);
expect(
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch),
).toBe(false);
});
it("does not flag token issues when service token is not embedded", 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",
},
},
});
expect(
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenEmbedded),
).toBe(false);
expect(
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch),
).toBe(false);
@@ -143,10 +169,9 @@ describe("checkTokenDrift", () => {
expect(result?.message).toContain("differs from service token");
});
it("detects drift when config has token but service has no token", () => {
it("returns null when config has token but service has no token", () => {
const result = checkTokenDrift({ serviceToken: undefined, configToken: "new-token" });
expect(result).not.toBeNull();
expect(result?.code).toBe(SERVICE_AUDIT_CODES.gatewayTokenDrift);
expect(result).toBeNull();
});
it("returns null when service has token but config does not", () => {

View File

@@ -35,6 +35,7 @@ export const SERVICE_AUDIT_CODES = {
gatewayPathMissing: "gateway-path-missing",
gatewayPathMissingDirs: "gateway-path-missing-dirs",
gatewayPathNonMinimal: "gateway-path-nonminimal",
gatewayTokenEmbedded: "gateway-token-embedded",
gatewayTokenMismatch: "gateway-token-mismatch",
gatewayRuntimeBun: "gateway-runtime-bun",
gatewayRuntimeNodeVersionManager: "gateway-runtime-node-version-manager",
@@ -208,19 +209,25 @@ function auditGatewayToken(
issues: ServiceConfigIssue[],
expectedGatewayToken?: string,
) {
const expectedToken = expectedGatewayToken?.trim();
if (!expectedToken) {
const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim();
if (!serviceToken) {
return;
}
const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim();
if (serviceToken === expectedToken) {
issues.push({
code: SERVICE_AUDIT_CODES.gatewayTokenEmbedded,
message: "Gateway service embeds OPENCLAW_GATEWAY_TOKEN and should be reinstalled.",
detail: "Run `openclaw gateway install --force` to remove embedded service token.",
level: "recommended",
});
const expectedToken = expectedGatewayToken?.trim();
if (!expectedToken || 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",
detail: "service token is stale",
level: "recommended",
});
}
@@ -360,21 +367,15 @@ export function checkTokenDrift(params: {
serviceToken: string | undefined;
configToken: string | undefined;
}): ServiceConfigIssue | null {
const { serviceToken, configToken } = params;
const serviceToken = params.serviceToken?.trim() || undefined;
const configToken = params.configToken?.trim() || undefined;
// Normalise both tokens before comparing: service-file parsers (systemd,
// launchd) can return values with trailing newlines or whitespace that
// cause a false-positive mismatch against the config value.
const normService = serviceToken?.trim() || undefined;
const normConfig = configToken?.trim() || undefined;
// No drift if both are undefined/empty
if (!normService && !normConfig) {
// Tokenless service units are canonical; no drift to report.
if (!serviceToken) {
return null;
}
// Drift: config has token, service has different or no token
if (normConfig && normService !== normConfig) {
if (configToken && serviceToken !== configToken) {
return {
code: SERVICE_AUDIT_CODES.gatewayTokenDrift,
message:

View File

@@ -264,7 +264,6 @@ describe("buildServiceEnvironment", () => {
const env = buildServiceEnvironment({
env: { HOME: "/home/user" },
port: 18789,
token: "secret",
});
expect(env.HOME).toBe("/home/user");
if (process.platform === "win32") {
@@ -273,7 +272,7 @@ describe("buildServiceEnvironment", () => {
expect(env.PATH).toContain("/usr/bin");
}
expect(env.OPENCLAW_GATEWAY_PORT).toBe("18789");
expect(env.OPENCLAW_GATEWAY_TOKEN).toBe("secret");
expect(env.OPENCLAW_GATEWAY_TOKEN).toBeUndefined();
expect(env.OPENCLAW_SERVICE_MARKER).toBe("openclaw");
expect(env.OPENCLAW_SERVICE_KIND).toBe("gateway");
expect(typeof env.OPENCLAW_SERVICE_VERSION).toBe("string");

View File

@@ -245,11 +245,10 @@ export function buildMinimalServicePath(options: BuildServicePathOptions = {}):
export function buildServiceEnvironment(params: {
env: Record<string, string | undefined>;
port: number;
token?: string;
launchdLabel?: string;
platform?: NodeJS.Platform;
}): Record<string, string | undefined> {
const { env, port, token, launchdLabel } = params;
const { env, port, launchdLabel } = params;
const platform = params.platform ?? process.platform;
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform);
const profile = env.OPENCLAW_PROFILE;
@@ -260,7 +259,6 @@ export function buildServiceEnvironment(params: {
...buildCommonServiceEnvironment(env, sharedEnv),
OPENCLAW_PROFILE: profile,
OPENCLAW_GATEWAY_PORT: String(port),
OPENCLAW_GATEWAY_TOKEN: token,
OPENCLAW_LAUNCHD_LABEL: resolvedLaunchdLabel,
OPENCLAW_SYSTEMD_UNIT: systemdUnit,
OPENCLAW_WINDOWS_TASK_NAME: resolveGatewayWindowsTaskName(profile),

View File

@@ -41,6 +41,7 @@ function withGatewayAuthEnv<T>(env: NodeJS.ProcessEnv, fn: () => T): T {
const keys = [
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_PASSWORD",
"OPENCLAW_SERVICE_KIND",
"CLAWDBOT_GATEWAY_TOKEN",
"CLAWDBOT_GATEWAY_PASSWORD",
] as const;
@@ -138,6 +139,29 @@ describe("gateway credential precedence parity", () => {
auth: { token: undefined, password: undefined },
},
},
{
name: "local mode in gateway service runtime uses config-first token precedence",
cfg: {
gateway: {
mode: "local",
auth: {
token: "config-token",
password: "config-password",
},
},
} as OpenClawConfig,
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
OPENCLAW_SERVICE_KIND: "gateway",
} as NodeJS.ProcessEnv,
expected: {
call: { token: "config-token", password: "env-password" },
probe: { token: "config-token", password: "env-password" },
status: { token: "config-token", password: "env-password" },
auth: { token: "config-token", password: "config-password" },
},
},
];
it.each(cases)("$name", ({ cfg, env, expected }) => {

View File

@@ -120,6 +120,26 @@ describe("resolveGatewayCredentialsFromConfig", () => {
expectEnvGatewayCredentials(resolved);
});
it("uses config-first local token precedence inside gateway service runtime", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: cfg({
gateway: {
mode: "local",
auth: { token: "config-token", password: "config-password" },
},
}),
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
OPENCLAW_SERVICE_KIND: "gateway",
} as NodeJS.ProcessEnv,
});
expect(resolved).toEqual({
token: "config-token",
password: "env-password",
});
});
it("falls back to remote credentials in local mode when local auth is missing", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: cfg({

View File

@@ -223,7 +223,9 @@ export function resolveGatewayCredentialsFromConfig(params: {
? undefined
: trimToUndefined(params.cfg.gateway?.auth?.password);
const localTokenPrecedence = params.localTokenPrecedence ?? "env-first";
const localTokenPrecedence =
params.localTokenPrecedence ??
(env.OPENCLAW_SERVICE_KIND === "gateway" ? "config-first" : "env-first");
const localPasswordPrecedence = params.localPasswordPrecedence ?? "env-first";
if (mode === "local") {

View File

@@ -158,7 +158,16 @@ describe("resolveGatewayConnection", () => {
expect(result.url).toBe("ws://127.0.0.1:18800");
});
it("uses OPENCLAW_GATEWAY_TOKEN for local mode", async () => {
it("uses config auth token for local mode when both config and env tokens are set", async () => {
loadConfig.mockReturnValue({ gateway: { mode: "local", auth: { token: "config-token" } } });
await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => {
const result = await resolveGatewayConnection({});
expect(result.token).toBe("config-token");
});
});
it("falls back to OPENCLAW_GATEWAY_TOKEN when config token is missing", async () => {
loadConfig.mockReturnValue({ gateway: { mode: "local" } });
await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => {
@@ -167,13 +176,6 @@ describe("resolveGatewayConnection", () => {
});
});
it("falls back to config auth token when env token is missing", async () => {
loadConfig.mockReturnValue({ gateway: { mode: "local", auth: { token: "config-token" } } });
const result = await resolveGatewayConnection({});
expect(result.token).toBe("config-token");
});
it("uses local password auth when gateway.auth.mode is unset and password-only is configured", async () => {
loadConfig.mockReturnValue({
gateway: {

View File

@@ -370,16 +370,15 @@ export async function resolveGatewayConnection(
}
const resolveToken = async () => {
const localToken =
explicitAuth.token || envToken
? { value: explicitAuth.token ?? envToken }
: await resolveConfiguredSecretInputString({
value: config.gateway?.auth?.token,
path: "gateway.auth.token",
env,
config,
});
const token = explicitAuth.token ?? envToken ?? localToken.value;
const localToken = explicitAuth.token
? { value: explicitAuth.token }
: await resolveConfiguredSecretInputString({
value: config.gateway?.auth?.token,
path: "gateway.auth.token",
env,
config,
});
const token = explicitAuth.token ?? localToken.value ?? envToken;
if (!token) {
throwGatewayAuthResolutionError(
localToken.unresolvedRefReason ?? "Missing gateway auth token.",
@@ -410,7 +409,7 @@ export async function resolveGatewayConnection(
env,
config,
});
const password = passwordCandidate ?? localPassword.value;
const password = explicitAuth.password ?? localPassword.value ?? envPassword;
if (!password) {
throwGatewayAuthResolutionError(
localPassword.unresolvedRefReason ?? "Missing gateway auth password.",

View File

@@ -233,11 +233,8 @@ describe("finalizeOnboardingWizard", () => {
});
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(gatewayServiceInstall).toHaveBeenCalledTimes(1);
});
});

View File

@@ -184,7 +184,6 @@ export async function finalizeOnboardingWizard(
{
env: process.env,
port: settings.port,
token: tokenResolution.token,
runtime: daemonRuntime,
warn: (message, title) => prompter.note(message, title),
config: nextConfig,