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. - 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. - 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/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/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/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. - 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"); 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( await fs.writeFile(
configPath, configPath,
JSON.stringify( JSON.stringify(
@ -143,6 +143,6 @@ describe("runDaemonInstall integration", () => {
expect((persistedToken ?? "").length).toBeGreaterThan(0); expect((persistedToken ?? "").length).toBeGreaterThan(0);
const installEnv = serviceMock.install.mock.calls[0]?.[0]?.environment; 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 }); await runDaemonInstall({ json: true });
expect(actionState.failed).toEqual([]); expect(actionState.failed).toEqual([]);
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith( expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1);
expect.objectContaining({ expect("token" in buildGatewayInstallPlanMock.mock.calls[0][0]).toBe(false);
token: undefined,
}),
);
expect(writeConfigFileMock).not.toHaveBeenCalled(); expect(writeConfigFileMock).not.toHaveBeenCalled();
expect( expect(
actionState.warnings.some((warning) => actionState.warnings.some((warning) =>
@ -225,11 +222,8 @@ describe("runDaemonInstall", () => {
expect(actionState.failed).toEqual([]); expect(actionState.failed).toEqual([]);
expect(resolveSecretRefValuesMock).toHaveBeenCalledTimes(1); expect(resolveSecretRefValuesMock).toHaveBeenCalledTimes(1);
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith( expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1);
expect.objectContaining({ expect("token" in buildGatewayInstallPlanMock.mock.calls[0][0]).toBe(false);
token: undefined,
}),
);
}); });
it("auto-mints and persists token when no source exists", async () => { 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(writtenConfig.gateway?.auth?.token).toBe("minted-token");
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith( 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(installDaemonServiceAndEmitMock).toHaveBeenCalledTimes(1);
expect(actionState.warnings.some((warning) => warning.includes("Auto-generated"))).toBe(true); 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({ const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env, env: process.env,
port, port,
token: tokenResolution.token,
runtime: runtimeRaw, runtime: runtimeRaw,
warn: (message) => { warn: (message) => {
if (json) { if (json) {

View File

@ -83,7 +83,7 @@ describe("runServiceRestart token drift", () => {
expect(payload.warnings?.[0]).toContain("gateway install --force"); 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({ loadConfig.mockReturnValue({
gateway: { gateway: {
auth: { auth: {
@ -106,7 +106,7 @@ describe("runServiceRestart token drift", () => {
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{")); const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const payload = JSON.parse(jsonLine ?? "{}") as { warnings?: string[] }; 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 () => { 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 type { GatewayService } from "../../daemon/service.js";
import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js"; import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js";
import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js"; import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js";
import { import { isGatewaySecretRefUnavailableError } from "../../gateway/credentials.js";
isGatewaySecretRefUnavailableError,
resolveGatewayCredentialsFromConfig,
} from "../../gateway/credentials.js";
import { isWSL } from "../../infra/wsl.js"; import { isWSL } from "../../infra/wsl.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { import {
@ -284,11 +281,7 @@ export async function runServiceRestart(params: {
const command = await params.service.readCommand(process.env); const command = await params.service.readCommand(process.env);
const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN; const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN;
const cfg = loadConfig(); const cfg = loadConfig();
const configToken = resolveGatewayCredentialsFromConfig({ const configToken = cfg.gateway?.auth?.token?.trim() || undefined;
cfg,
env: process.env,
modeOverride: "local",
}).token;
const driftIssue = checkTokenDrift({ serviceToken, configToken }); const driftIssue = checkTokenDrift({ serviceToken, configToken });
if (driftIssue) { if (driftIssue) {
const warning = driftIssue.detail const warning = driftIssue.detail

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util"; 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 { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js";
import { resolveSecretInputRef } from "../config/types.secrets.js"; import { resolveSecretInputRef } from "../config/types.secrets.js";
import { import {
@ -25,7 +25,6 @@ import { buildGatewayInstallPlan } from "./daemon-install-helpers.js";
import { DEFAULT_GATEWAY_DAEMON_RUNTIME, type GatewayDaemonRuntime } from "./daemon-runtime.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, type GatewayDaemonRuntime } from "./daemon-runtime.js";
import { resolveGatewayAuthTokenForService } from "./doctor-gateway-auth-token.js"; import { resolveGatewayAuthTokenForService } from "./doctor-gateway-auth-token.js";
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js"; import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
import { resolveGatewayInstallToken } from "./gateway-install-token.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@ -259,24 +258,9 @@ export async function maybeRepairGatewayServiceConfig(
const port = resolveGatewayPort(cfg, process.env); const port = resolveGatewayPort(cfg, process.env);
const runtimeChoice = detectGatewayRuntime(command.programArguments); const runtimeChoice = detectGatewayRuntime(command.programArguments);
const installTokenResolution = await resolveGatewayInstallToken({ const { programArguments } = await buildGatewayInstallPlan({
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({
env: process.env, env: process.env,
port, port,
token: installTokenResolution.token,
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice, runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined, nodePath: systemNodePath ?? undefined,
warn: (message, title) => note(message, title), warn: (message, title) => note(message, title),
@ -332,13 +316,56 @@ export async function maybeRepairGatewayServiceConfig(
if (!repair) { if (!repair) {
return; 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 { try {
await service.install({ await service.install({
env: process.env, env: process.env,
stdout: process.stdout, stdout: process.stdout,
programArguments, programArguments: updatedPlan.programArguments,
workingDirectory, workingDirectory: updatedPlan.workingDirectory,
environment, environment: updatedPlan.environment,
}); });
} catch (err) { } catch (err) {
runtime.error(`Gateway service update failed: ${String(err)}`); runtime.error(`Gateway service update failed: ${String(err)}`);

View File

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

View File

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

View File

@ -55,7 +55,6 @@ export async function installGatewayDaemonNonInteractive(params: {
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env, env: process.env,
port, port,
token: tokenResolution.token,
runtime: daemonRuntimeRaw, runtime: daemonRuntimeRaw,
warn: (message) => runtime.log(message), warn: (message) => runtime.log(message),
config: params.nextConfig, 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( expect(
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch), audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch),
).toBe(true); ).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({ const audit = await auditGatewayServiceConfig({
env: { HOME: "/tmp" }, env: { HOME: "/tmp" },
platform: "linux", 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( expect(
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch), audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch),
).toBe(false); ).toBe(false);
@ -143,10 +169,9 @@ describe("checkTokenDrift", () => {
expect(result?.message).toContain("differs from service token"); 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" }); const result = checkTokenDrift({ serviceToken: undefined, configToken: "new-token" });
expect(result).not.toBeNull(); expect(result).toBeNull();
expect(result?.code).toBe(SERVICE_AUDIT_CODES.gatewayTokenDrift);
}); });
it("returns null when service has token but config does not", () => { 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", gatewayPathMissing: "gateway-path-missing",
gatewayPathMissingDirs: "gateway-path-missing-dirs", gatewayPathMissingDirs: "gateway-path-missing-dirs",
gatewayPathNonMinimal: "gateway-path-nonminimal", gatewayPathNonMinimal: "gateway-path-nonminimal",
gatewayTokenEmbedded: "gateway-token-embedded",
gatewayTokenMismatch: "gateway-token-mismatch", gatewayTokenMismatch: "gateway-token-mismatch",
gatewayRuntimeBun: "gateway-runtime-bun", gatewayRuntimeBun: "gateway-runtime-bun",
gatewayRuntimeNodeVersionManager: "gateway-runtime-node-version-manager", gatewayRuntimeNodeVersionManager: "gateway-runtime-node-version-manager",
@ -208,19 +209,25 @@ function auditGatewayToken(
issues: ServiceConfigIssue[], issues: ServiceConfigIssue[],
expectedGatewayToken?: string, expectedGatewayToken?: string,
) { ) {
const expectedToken = expectedGatewayToken?.trim(); const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim();
if (!expectedToken) { if (!serviceToken) {
return; return;
} }
const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim(); issues.push({
if (serviceToken === expectedToken) { 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; return;
} }
issues.push({ issues.push({
code: SERVICE_AUDIT_CODES.gatewayTokenMismatch, code: SERVICE_AUDIT_CODES.gatewayTokenMismatch,
message: message:
"Gateway service OPENCLAW_GATEWAY_TOKEN does not match gateway.auth.token in openclaw.json", "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", level: "recommended",
}); });
} }
@ -360,21 +367,15 @@ export function checkTokenDrift(params: {
serviceToken: string | undefined; serviceToken: string | undefined;
configToken: string | undefined; configToken: string | undefined;
}): ServiceConfigIssue | null { }): 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, // Tokenless service units are canonical; no drift to report.
// launchd) can return values with trailing newlines or whitespace that if (!serviceToken) {
// 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) {
return null; return null;
} }
// Drift: config has token, service has different or no token if (configToken && serviceToken !== configToken) {
if (normConfig && normService !== normConfig) {
return { return {
code: SERVICE_AUDIT_CODES.gatewayTokenDrift, code: SERVICE_AUDIT_CODES.gatewayTokenDrift,
message: message:

View File

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

View File

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

View File

@ -41,6 +41,7 @@ function withGatewayAuthEnv<T>(env: NodeJS.ProcessEnv, fn: () => T): T {
const keys = [ const keys = [
"OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_GATEWAY_PASSWORD", "OPENCLAW_GATEWAY_PASSWORD",
"OPENCLAW_SERVICE_KIND",
"CLAWDBOT_GATEWAY_TOKEN", "CLAWDBOT_GATEWAY_TOKEN",
"CLAWDBOT_GATEWAY_PASSWORD", "CLAWDBOT_GATEWAY_PASSWORD",
] as const; ] as const;
@ -138,6 +139,29 @@ describe("gateway credential precedence parity", () => {
auth: { token: undefined, password: undefined }, 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 }) => { it.each(cases)("$name", ({ cfg, env, expected }) => {

View File

@ -120,6 +120,26 @@ describe("resolveGatewayCredentialsFromConfig", () => {
expectEnvGatewayCredentials(resolved); 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", () => { it("falls back to remote credentials in local mode when local auth is missing", () => {
const resolved = resolveGatewayCredentialsFromConfig({ const resolved = resolveGatewayCredentialsFromConfig({
cfg: cfg({ cfg: cfg({

View File

@ -223,7 +223,9 @@ export function resolveGatewayCredentialsFromConfig(params: {
? undefined ? undefined
: trimToUndefined(params.cfg.gateway?.auth?.password); : 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"; const localPasswordPrecedence = params.localPasswordPrecedence ?? "env-first";
if (mode === "local") { if (mode === "local") {

View File

@ -158,7 +158,16 @@ describe("resolveGatewayConnection", () => {
expect(result.url).toBe("ws://127.0.0.1:18800"); 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" } }); loadConfig.mockReturnValue({ gateway: { mode: "local" } });
await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => { 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 () => { it("uses local password auth when gateway.auth.mode is unset and password-only is configured", async () => {
loadConfig.mockReturnValue({ loadConfig.mockReturnValue({
gateway: { gateway: {

View File

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

View File

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

View File

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