mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 23:40:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user