diff --git a/CHANGELOG.md b/CHANGELOG.md index 0df58a604ef..a2c4442bbe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/cli/daemon-cli/install.integration.test.ts b/src/cli/daemon-cli/install.integration.test.ts index bd1a00d605d..e4b49003286 100644 --- a/src/cli/daemon-cli/install.integration.test.ts +++ b/src/cli/daemon-cli/install.integration.test.ts @@ -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(); }); }); diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index cd03bddbedb..2c6c16dc23d 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -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); }); diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index 864f0a93ff0..a5210b41c1a 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -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) { diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts index cf8ccfe3110..cb1baa73bbf 100644 --- a/src/cli/daemon-cli/lifecycle-core.test.ts +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -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 () => { diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index 6b8c7ee684c..1885657b137 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -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 diff --git a/src/commands/configure.daemon.test.ts b/src/commands/configure.daemon.test.ts index a5254a00cf9..800f5d940f8 100644 --- a/src/commands/configure.daemon.test.ts +++ b/src/commands/configure.daemon.test.ts @@ -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); }); diff --git a/src/commands/configure.daemon.ts b/src/commands/configure.daemon.ts index 2be58f19a64..ddc5b067f69 100644 --- a/src/commands/configure.daemon.ts +++ b/src/commands/configure.daemon.ts @@ -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, diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 0a548acf799..68b78630ffe 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -23,7 +23,6 @@ export async function buildGatewayInstallPlan(params: { env: Record; 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) diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index d3ac55073d5..4fd8df3490b 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -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, diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 2d81eb26f5a..8158613efd9 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -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", () => { diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index f4416b49d6f..85f735baf8b 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -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)}`); diff --git a/src/commands/gateway-status/helpers.ts b/src/commands/gateway-status/helpers.ts index 6ee1ccd6b35..24519e6e8be 100644 --- a/src/commands/gateway-status/helpers.ts +++ b/src/commands/gateway-status/helpers.ts @@ -189,6 +189,8 @@ export async function resolveAuthForTarget( } return passwordResolution.value; }; + const withDiagnostics = (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 }; diff --git a/src/commands/onboard-non-interactive/local/daemon-install.test.ts b/src/commands/onboard-non-interactive/local/daemon-install.test.ts index b8021cf4842..c3e87a1d48d 100644 --- a/src/commands/onboard-non-interactive/local/daemon-install.test.ts +++ b/src/commands/onboard-non-interactive/local/daemon-install.test.ts @@ -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); }); diff --git a/src/commands/onboard-non-interactive/local/daemon-install.ts b/src/commands/onboard-non-interactive/local/daemon-install.ts index c2e488800a6..d3b759227d6 100644 --- a/src/commands/onboard-non-interactive/local/daemon-install.ts +++ b/src/commands/onboard-non-interactive/local/daemon-install.ts @@ -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, diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index 090094ed8c9..ebcdf5d643d 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -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", () => { diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index 6f86230dbc3..6caa320d6ac 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -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: diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index b3ad08a76a4..e5d60fdfc96 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -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"); diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index f9c10ddf1bd..fb6fff41839 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -245,11 +245,10 @@ export function buildMinimalServicePath(options: BuildServicePathOptions = {}): export function buildServiceEnvironment(params: { env: Record; port: number; - token?: string; launchdLabel?: string; platform?: NodeJS.Platform; }): Record { - 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), diff --git a/src/gateway/credential-precedence.parity.test.ts b/src/gateway/credential-precedence.parity.test.ts index ee85de49b8b..e9b494450fe 100644 --- a/src/gateway/credential-precedence.parity.test.ts +++ b/src/gateway/credential-precedence.parity.test.ts @@ -41,6 +41,7 @@ function withGatewayAuthEnv(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 }) => { diff --git a/src/gateway/credentials.test.ts b/src/gateway/credentials.test.ts index 7dc12c84aa2..b5051ef033b 100644 --- a/src/gateway/credentials.test.ts +++ b/src/gateway/credentials.test.ts @@ -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({ diff --git a/src/gateway/credentials.ts b/src/gateway/credentials.ts index 4a88eb80015..285ff95bef1 100644 --- a/src/gateway/credentials.ts +++ b/src/gateway/credentials.ts @@ -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") { diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index 204172e4fb2..8f45d32d1bc 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -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: { diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 4001cba4008..313d87b690d 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -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.", diff --git a/src/wizard/onboarding.finalize.test.ts b/src/wizard/onboarding.finalize.test.ts index 8d720c2f594..e816c3d9589 100644 --- a/src/wizard/onboarding.finalize.test.ts +++ b/src/wizard/onboarding.finalize.test.ts @@ -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); }); }); diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index 56e805cee66..fdb1143933c 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -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,