Compare commits

...

4 Commits

Author SHA1 Message Date
Peter Steinberger
44cb4dd5d6 fix(gateway): fail closed for config-first secretrefs 2026-03-08 00:58:17 +00:00
Peter Steinberger
d6d7a2bb4f fix(gateway): block cached device token override fallback 2026-03-08 00:58:16 +00:00
Peter Steinberger
b5192d6e6b fix(daemon): preserve envfile auth provenance 2026-03-08 00:58:16 +00:00
Peter Steinberger
031dcdb8e6 docs: note gateway auth follow-up hardening 2026-03-08 00:58:16 +00:00
12 changed files with 237 additions and 14 deletions

View File

@ -83,6 +83,7 @@ Docs: https://docs.openclaw.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. - 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.
- Gateway/auth follow-up hardening: preserve systemd `EnvironmentFile=` precedence/source provenance in daemon audits and doctor repairs, block shared-password override flows from piggybacking cached device tokens, and fail closed when config-first gateway SecretRefs cannot resolve. Follow-up to #39241.
- 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

@ -39,6 +39,15 @@ vi.mock("../daemon/runtime-paths.js", () => ({
vi.mock("../daemon/service-audit.js", () => ({ vi.mock("../daemon/service-audit.js", () => ({
auditGatewayServiceConfig: mocks.auditGatewayServiceConfig, auditGatewayServiceConfig: mocks.auditGatewayServiceConfig,
needsNodeRuntimeMigration: vi.fn(() => false), needsNodeRuntimeMigration: vi.fn(() => false),
readEmbeddedGatewayToken: (
command: {
environment?: Record<string, string>;
environmentValueSources?: Record<string, "inline" | "file">;
} | null,
) =>
command?.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN === "file"
? undefined
: command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined,
SERVICE_AUDIT_CODES: { SERVICE_AUDIT_CODES: {
gatewayEntrypointMismatch: "gateway-entrypoint-mismatch", gatewayEntrypointMismatch: "gateway-entrypoint-mismatch",
}, },
@ -299,6 +308,49 @@ describe("maybeRepairGatewayServiceConfig", () => {
}, },
); );
}); });
it("does not persist EnvironmentFile-backed service tokens into config", async () => {
await withEnvAsync(
{
OPENCLAW_GATEWAY_TOKEN: undefined,
CLAWDBOT_GATEWAY_TOKEN: undefined,
},
async () => {
mocks.readCommand.mockResolvedValue({
programArguments: gatewayProgramArguments,
environment: {
OPENCLAW_GATEWAY_TOKEN: "env-file-token",
},
environmentValueSources: {
OPENCLAW_GATEWAY_TOKEN: "file",
},
});
mocks.auditGatewayServiceConfig.mockResolvedValue({
ok: false,
issues: [],
});
mocks.buildGatewayInstallPlan.mockResolvedValue({
programArguments: gatewayProgramArguments,
workingDirectory: "/tmp",
environment: {},
});
mocks.install.mockResolvedValue(undefined);
const cfg: OpenClawConfig = {
gateway: {},
};
await runRepair(cfg);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith(
expect.objectContaining({
config: cfg,
}),
);
},
);
});
}); });
describe("maybeScanExtraGatewayServices", () => { describe("maybeScanExtraGatewayServices", () => {

View File

@ -15,6 +15,7 @@ import { renderSystemNodeWarning, resolveSystemNodeInfo } from "../daemon/runtim
import { import {
auditGatewayServiceConfig, auditGatewayServiceConfig,
needsNodeRuntimeMigration, needsNodeRuntimeMigration,
readEmbeddedGatewayToken,
SERVICE_AUDIT_CODES, SERVICE_AUDIT_CODES,
} from "../daemon/service-audit.js"; } from "../daemon/service-audit.js";
import { resolveGatewayService } from "../daemon/service.js"; import { resolveGatewayService } from "../daemon/service.js";
@ -230,7 +231,7 @@ export async function maybeRepairGatewayServiceConfig(
command, command,
expectedGatewayToken, expectedGatewayToken,
}); });
const serviceToken = command.environment?.OPENCLAW_GATEWAY_TOKEN?.trim(); const serviceToken = readEmbeddedGatewayToken(command);
if (tokenRefConfigured && serviceToken) { if (tokenRefConfigured && serviceToken) {
audit.issues.push({ audit.issues.push({
code: SERVICE_AUDIT_CODES.gatewayTokenMismatch, code: SERVICE_AUDIT_CODES.gatewayTokenMismatch,
@ -316,7 +317,7 @@ export async function maybeRepairGatewayServiceConfig(
if (!repair) { if (!repair) {
return; return;
} }
const serviceEmbeddedToken = command.environment?.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined; const serviceEmbeddedToken = readEmbeddedGatewayToken(command);
const gatewayTokenForRepair = expectedGatewayToken ?? serviceEmbeddedToken; const gatewayTokenForRepair = expectedGatewayToken ?? serviceEmbeddedToken;
const configuredGatewayToken = const configuredGatewayToken =
typeof cfg.gateway?.auth?.token === "string" typeof cfg.gateway?.auth?.token === "string"

View File

@ -126,6 +126,30 @@ describe("auditGatewayServiceConfig", () => {
audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch), audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayTokenMismatch),
).toBe(false); ).toBe(false);
}); });
it("does not treat EnvironmentFile-backed tokens as 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",
OPENCLAW_GATEWAY_TOKEN: "old-token",
},
environmentValueSources: {
OPENCLAW_GATEWAY_TOKEN: "file",
},
},
});
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);
});
}); });
describe("checkTokenDrift", () => { describe("checkTokenDrift", () => {

View File

@ -14,6 +14,7 @@ export type GatewayServiceCommand = {
programArguments: string[]; programArguments: string[];
workingDirectory?: string; workingDirectory?: string;
environment?: Record<string, string>; environment?: Record<string, string>;
environmentValueSources?: Record<string, "inline" | "file">;
sourcePath?: string; sourcePath?: string;
} | null; } | null;
@ -209,7 +210,7 @@ function auditGatewayToken(
issues: ServiceConfigIssue[], issues: ServiceConfigIssue[],
expectedGatewayToken?: string, expectedGatewayToken?: string,
) { ) {
const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN?.trim(); const serviceToken = readEmbeddedGatewayToken(command);
if (!serviceToken) { if (!serviceToken) {
return; return;
} }
@ -232,6 +233,16 @@ function auditGatewayToken(
}); });
} }
export function readEmbeddedGatewayToken(command: GatewayServiceCommand): string | undefined {
if (!command) {
return undefined;
}
if (command.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN === "file") {
return undefined;
}
return command.environment?.OPENCLAW_GATEWAY_TOKEN?.trim() || undefined;
}
function getPathModule(platform: NodeJS.Platform) { function getPathModule(platform: NodeJS.Platform) {
return platform === "win32" ? path.win32 : path.posix; return platform === "win32" ? path.win32 : path.posix;
} }

View File

@ -27,6 +27,7 @@ export type GatewayServiceCommandConfig = {
programArguments: string[]; programArguments: string[];
workingDirectory?: string; workingDirectory?: string;
environment?: Record<string, string>; environment?: Record<string, string>;
environmentValueSources?: Record<string, "inline" | "file">;
sourcePath?: string; sourcePath?: string;
}; };

View File

@ -460,7 +460,7 @@ describe("readSystemdServiceExecStart", () => {
expect(readFileSpy).toHaveBeenCalledTimes(2); expect(readFileSpy).toHaveBeenCalledTimes(2);
}); });
it("lets inline Environment override EnvironmentFile values", async () => { it("lets EnvironmentFile override inline Environment values", async () => {
vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => { vi.spyOn(fs, "readFile").mockImplementation(async (pathname) => {
const pathValue = pathLikeToString(pathname); const pathValue = pathLikeToString(pathname);
if (pathValue.endsWith("/openclaw-gateway.service")) { if (pathValue.endsWith("/openclaw-gateway.service")) {
@ -478,7 +478,8 @@ describe("readSystemdServiceExecStart", () => {
}); });
const command = await readSystemdServiceExecStart({ HOME: "/home/test" }); const command = await readSystemdServiceExecStart({ HOME: "/home/test" });
expect(command?.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("inline-token"); expect(command?.environment?.OPENCLAW_GATEWAY_TOKEN).toBe("env-file-token");
expect(command?.environmentValueSources?.OPENCLAW_GATEWAY_TOKEN).toBe("file");
}); });
it("ignores missing optional EnvironmentFile entries", async () => { it("ignores missing optional EnvironmentFile entries", async () => {
@ -598,6 +599,10 @@ describe("readSystemdServiceExecStart", () => {
OPENCLAW_GATEWAY_TOKEN: "quoted token", OPENCLAW_GATEWAY_TOKEN: "quoted token",
OPENCLAW_GATEWAY_PASSWORD: "quoted-password", // pragma: allowlist secret OPENCLAW_GATEWAY_PASSWORD: "quoted-password", // pragma: allowlist secret
}); });
expect(command?.environmentValueSources).toEqual({
OPENCLAW_GATEWAY_TOKEN: "file",
OPENCLAW_GATEWAY_PASSWORD: "file",
});
}); });
}); });

View File

@ -65,7 +65,7 @@ export async function readSystemdServiceExecStart(
const content = await fs.readFile(unitPath, "utf8"); const content = await fs.readFile(unitPath, "utf8");
let execStart = ""; let execStart = "";
let workingDirectory = ""; let workingDirectory = "";
const environment: Record<string, string> = {}; const inlineEnvironment: Record<string, string> = {};
const environmentFileSpecs: string[] = []; const environmentFileSpecs: string[] = [];
for (const rawLine of content.split("\n")) { for (const rawLine of content.split("\n")) {
const line = rawLine.trim(); const line = rawLine.trim();
@ -80,7 +80,7 @@ export async function readSystemdServiceExecStart(
const raw = line.slice("Environment=".length).trim(); const raw = line.slice("Environment=".length).trim();
const parsed = parseSystemdEnvAssignment(raw); const parsed = parseSystemdEnvAssignment(raw);
if (parsed) { if (parsed) {
environment[parsed.key] = parsed.value; inlineEnvironment[parsed.key] = parsed.value;
} }
} else if (line.startsWith("EnvironmentFile=")) { } else if (line.startsWith("EnvironmentFile=")) {
const raw = line.slice("EnvironmentFile=".length).trim(); const raw = line.slice("EnvironmentFile=".length).trim();
@ -98,14 +98,21 @@ export async function readSystemdServiceExecStart(
unitPath, unitPath,
}); });
const mergedEnvironment = { const mergedEnvironment = {
...environmentFromFiles, ...inlineEnvironment,
...environment, ...environmentFromFiles.environment,
};
const mergedEnvironmentSources = {
...buildEnvironmentValueSources(inlineEnvironment, "inline"),
...buildEnvironmentValueSources(environmentFromFiles.environment, "file"),
}; };
const programArguments = parseSystemdExecStart(execStart); const programArguments = parseSystemdExecStart(execStart);
return { return {
programArguments, programArguments,
...(workingDirectory ? { workingDirectory } : {}), ...(workingDirectory ? { workingDirectory } : {}),
...(Object.keys(mergedEnvironment).length > 0 ? { environment: mergedEnvironment } : {}), ...(Object.keys(mergedEnvironment).length > 0 ? { environment: mergedEnvironment } : {}),
...(Object.keys(mergedEnvironmentSources).length > 0
? { environmentValueSources: mergedEnvironmentSources }
: {}),
sourcePath: unitPath, sourcePath: unitPath,
}; };
} catch { } catch {
@ -113,6 +120,13 @@ export async function readSystemdServiceExecStart(
} }
} }
function buildEnvironmentValueSources(
environment: Record<string, string>,
source: "inline" | "file",
): Record<string, "inline" | "file"> {
return Object.fromEntries(Object.keys(environment).map((key) => [key, source]));
}
function expandSystemdSpecifier(input: string, env: GatewayServiceEnv): string { function expandSystemdSpecifier(input: string, env: GatewayServiceEnv): string {
// Support the common unit-specifier used in user services. // Support the common unit-specifier used in user services.
return input.replaceAll("%h", toPosixPath(resolveHomeDir(env))); return input.replaceAll("%h", toPosixPath(resolveHomeDir(env)));
@ -165,10 +179,10 @@ async function resolveSystemdEnvironmentFiles(params: {
environmentFileSpecs: string[]; environmentFileSpecs: string[];
env: GatewayServiceEnv; env: GatewayServiceEnv;
unitPath: string; unitPath: string;
}): Promise<Record<string, string>> { }): Promise<{ environment: Record<string, string> }> {
const resolved: Record<string, string> = {}; const resolved: Record<string, string> = {};
if (params.environmentFileSpecs.length === 0) { if (params.environmentFileSpecs.length === 0) {
return resolved; return { environment: resolved };
} }
const unitDir = path.posix.dirname(params.unitPath); const unitDir = path.posix.dirname(params.unitPath);
for (const specRaw of params.environmentFileSpecs) { for (const specRaw of params.environmentFileSpecs) {
@ -193,7 +207,7 @@ async function resolveSystemdEnvironmentFiles(params: {
} }
} }
} }
return resolved; return { environment: resolved };
} }
export type SystemdServiceInfo = { export type SystemdServiceInfo = {

View File

@ -402,6 +402,26 @@ describe("GatewayClient connect auth payload", () => {
client.stop(); client.stop();
}); });
it("uses explicit shared password and does not inject stored device token", () => {
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
const client = new GatewayClient({
url: "ws://127.0.0.1:18789",
password: "shared-password", // pragma: allowlist secret
});
client.start();
const ws = getLatestWs();
ws.emitOpen();
emitConnectChallenge(ws);
expect(connectFrameFrom(ws)).toMatchObject({
password: "shared-password", // pragma: allowlist secret
});
expect(connectFrameFrom(ws).token).toBeUndefined();
expect(connectFrameFrom(ws).deviceToken).toBeUndefined();
client.stop();
});
it("uses stored device token when shared token is not provided", () => { it("uses stored device token when shared token is not provided", () => {
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" }); loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
const client = new GatewayClient({ const client = new GatewayClient({

View File

@ -254,9 +254,12 @@ export class GatewayClient {
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token ? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
: null; : null;
// Keep shared gateway credentials explicit. Persisted per-device tokens only // Keep shared gateway credentials explicit. Persisted per-device tokens only
// participate when no explicit shared token is provided. // participate when no explicit shared token/password is provided.
const resolvedDeviceToken = const resolvedDeviceToken =
explicitDeviceToken ?? (!explicitGatewayToken ? (storedToken ?? undefined) : undefined); explicitDeviceToken ??
(!(explicitGatewayToken || this.opts.password?.trim())
? (storedToken ?? undefined)
: undefined);
// Legacy compatibility: keep `auth.token` populated for device-token auth when // Legacy compatibility: keep `auth.token` populated for device-token auth when
// no explicit shared token is present. // no explicit shared token is present.
const authToken = explicitGatewayToken ?? resolvedDeviceToken; const authToken = explicitGatewayToken ?? resolvedDeviceToken;

View File

@ -343,4 +343,77 @@ describe("resolveGatewayConnectionAuth", () => {
password: "config-first-password", // pragma: allowlist secret password: "config-first-password", // pragma: allowlist secret
}); });
}); });
it("throws when config-first token SecretRef cannot resolve even if env token exists", async () => {
const config = cfg({
gateway: {
mode: "local",
auth: {
token: { source: "env", provider: "default", id: "MISSING_CONFIG_FIRST_TOKEN" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
});
const env = {
OPENCLAW_GATEWAY_TOKEN: "env-token",
} as NodeJS.ProcessEnv;
await expect(
resolveGatewayConnectionAuth({
config,
env,
includeLegacyEnv: false,
localTokenPrecedence: "config-first",
}),
).rejects.toThrow("gateway.auth.token");
expect(() =>
resolveGatewayConnectionAuthFromConfig({
cfg: config,
env,
includeLegacyEnv: false,
localTokenPrecedence: "config-first",
}),
).toThrow("gateway.auth.token");
});
it("throws when config-first password SecretRef cannot resolve even if env password exists", async () => {
const config = cfg({
gateway: {
mode: "local",
auth: {
mode: "password",
password: { source: "env", provider: "default", id: "MISSING_CONFIG_FIRST_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
});
const env = {
OPENCLAW_GATEWAY_PASSWORD: "env-password", // pragma: allowlist secret
} as NodeJS.ProcessEnv;
await expect(
resolveGatewayConnectionAuth({
config,
env,
includeLegacyEnv: false,
localPasswordPrecedence: "config-first",
}),
).rejects.toThrow("gateway.auth.password");
expect(() =>
resolveGatewayConnectionAuthFromConfig({
cfg: config,
env,
includeLegacyEnv: false,
localPasswordPrecedence: "config-first",
}),
).toThrow("gateway.auth.password");
});
}); });

View File

@ -254,6 +254,24 @@ export function resolveGatewayCredentialsFromConfig(params: {
authMode !== "none" && authMode !== "none" &&
authMode !== "trusted-proxy" && authMode !== "trusted-proxy" &&
!localResolved.password); !localResolved.password);
if (
localTokenRef &&
localTokenPrecedence === "config-first" &&
!localToken &&
Boolean(envToken) &&
localTokenCanWin
) {
throwUnresolvedGatewaySecretInput("gateway.auth.token");
}
if (
localPasswordRef &&
localPasswordPrecedence === "config-first" &&
!localPassword &&
Boolean(envPassword) &&
localPasswordCanWin
) {
throwUnresolvedGatewaySecretInput("gateway.auth.password");
}
if (localTokenRef && !localResolved.token && !envToken && localTokenCanWin) { if (localTokenRef && !localResolved.token && !envToken && localTokenCanWin) {
throwUnresolvedGatewaySecretInput("gateway.auth.token"); throwUnresolvedGatewaySecretInput("gateway.auth.token");
} }