fix: fail closed for unresolved local gateway auth refs

This commit is contained in:
Peter Steinberger
2026-03-11 01:13:43 +00:00
parent ecdbd8aa52
commit 702f6f3305
6 changed files with 163 additions and 27 deletions

View File

@@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai
- Browser/Browserbase 429 handling: surface stable no-retry rate-limit guidance without buffering discarded HTTP 429 response bodies from remote browser services. (#40491) thanks @mvanhorn.
- Gateway/auth: allow one trusted device-token retry on shared-token mismatch with recovery hints to prevent reconnect churn during token drift. (#42507) Thanks @joshavant.
- Channels/allowlists: remove stale matcher caching so same-array allowlist edits and wildcard replacements take effect immediately, with regression coverage for in-place mutation cases.
- Gateway/auth: fail closed when local `gateway.auth.*` SecretRefs are configured but unavailable, instead of silently falling back to `gateway.remote.*` credentials in local mode. Thanks @tdjackey.
## 2026.3.8

View File

@@ -43,4 +43,29 @@ describe("resolveGatewayTokenForDriftCheck", () => {
}),
).toThrow(/gateway\.auth\.token/i);
});
it("does not fall back to gateway.remote token for unresolved local token refs", () => {
expect(() =>
resolveGatewayTokenForDriftCheck({
cfg: {
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "local",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" },
},
remote: {
token: "remote-token",
},
},
} as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
}),
).toThrow(/gateway\.auth\.token/i);
});
});

View File

@@ -36,16 +36,17 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"])
const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]);
const resolveGatewayPort = vi.fn(() => 18789);
const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []);
const probeGateway = vi.fn<
(opts: {
url: string;
auth?: { token?: string; password?: string };
timeoutMs: number;
}) => Promise<{
ok: boolean;
configSnapshot: unknown;
}>
>();
const probeGateway =
vi.fn<
(opts: {
url: string;
auth?: { token?: string; password?: string };
timeoutMs: number;
}) => Promise<{
ok: boolean;
configSnapshot: unknown;
}>
>();
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
const loadConfig = vi.fn(() => ({}));

View File

@@ -222,6 +222,58 @@ describe("resolveGatewayCredentialsFromConfig", () => {
).toThrow("gateway.auth.token");
});
it("throws when unresolved local token SecretRef would otherwise fall back to remote token", () => {
expect(() =>
resolveGatewayCredentialsFromConfig({
cfg: {
gateway: {
mode: "local",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_LOCAL_TOKEN" },
},
remote: {
token: "remote-token",
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as unknown as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
includeLegacyEnv: false,
}),
).toThrow("gateway.auth.token");
});
it("throws when unresolved local password SecretRef would otherwise fall back to remote password", () => {
expect(() =>
resolveGatewayCredentialsFromConfig({
cfg: {
gateway: {
mode: "local",
auth: {
mode: "password",
password: { source: "env", provider: "default", id: "MISSING_LOCAL_PASSWORD" },
},
remote: {
password: "remote-password", // pragma: allowlist secret
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as unknown as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
includeLegacyEnv: false,
}),
).toThrow("gateway.auth.password");
});
it("ignores unresolved local password ref when local auth mode is none", () => {
const resolved = resolveLocalModeWithUnresolvedPassword("none");
expect(resolved).toEqual({

View File

@@ -1,6 +1,6 @@
import type { OpenClawConfig } from "../config/config.js";
import { containsEnvVarReference } from "../config/env-substitution.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js";
export type ExplicitGatewayAuth = {
token?: string;
@@ -16,6 +16,13 @@ export type GatewayCredentialMode = "local" | "remote";
export type GatewayCredentialPrecedence = "env-first" | "config-first";
export type GatewayRemoteCredentialPrecedence = "remote-first" | "env-first";
export type GatewayRemoteCredentialFallback = "remote-env-local" | "remote-only";
type GatewaySecretDefaults = NonNullable<OpenClawConfig["secrets"]>["defaults"];
type GatewayConfiguredCredentialInput = {
configured: boolean;
value?: string;
refPath?: string;
};
const GATEWAY_SECRET_REF_UNAVAILABLE_ERROR_CODE = "GATEWAY_SECRET_REF_UNAVAILABLE"; // pragma: allowlist secret
@@ -85,6 +92,22 @@ function throwUnresolvedGatewaySecretInput(path: string): never {
throw new GatewaySecretRefUnavailableError(path);
}
function resolveConfiguredGatewayCredentialInput(params: {
value: unknown;
defaults?: GatewaySecretDefaults;
path: string;
}): GatewayConfiguredCredentialInput {
const ref = resolveSecretInputRef({
value: params.value,
defaults: params.defaults,
}).ref;
return {
configured: hasConfiguredSecretInput(params.value, params.defaults),
value: ref ? undefined : trimToUndefined(params.value),
refPath: ref ? params.path : undefined,
};
}
export function readGatewayTokenEnv(
env: NodeJS.ProcessEnv = process.env,
includeLegacyEnv = true,
@@ -200,28 +223,34 @@ export function resolveGatewayCredentialsFromConfig(params: {
const envToken = readGatewayTokenEnv(env, includeLegacyEnv);
const envPassword = readGatewayPasswordEnv(env, includeLegacyEnv);
const localTokenRef = resolveSecretInputRef({
const localTokenInput = resolveConfiguredGatewayCredentialInput({
value: params.cfg.gateway?.auth?.token,
defaults,
}).ref;
const localPasswordRef = resolveSecretInputRef({
path: "gateway.auth.token",
});
const localPasswordInput = resolveConfiguredGatewayCredentialInput({
value: params.cfg.gateway?.auth?.password,
defaults,
}).ref;
const remoteTokenRef = resolveSecretInputRef({
path: "gateway.auth.password",
});
const remoteTokenInput = resolveConfiguredGatewayCredentialInput({
value: remote?.token,
defaults,
}).ref;
const remotePasswordRef = resolveSecretInputRef({
path: "gateway.remote.token",
});
const remotePasswordInput = resolveConfiguredGatewayCredentialInput({
value: remote?.password,
defaults,
}).ref;
const remoteToken = remoteTokenRef ? undefined : trimToUndefined(remote?.token);
const remotePassword = remotePasswordRef ? undefined : trimToUndefined(remote?.password);
const localToken = localTokenRef ? undefined : trimToUndefined(params.cfg.gateway?.auth?.token);
const localPassword = localPasswordRef
? undefined
: trimToUndefined(params.cfg.gateway?.auth?.password);
path: "gateway.remote.password",
});
const localTokenRef = localTokenInput.refPath;
const localPasswordRef = localPasswordInput.refPath;
const remoteTokenRef = remoteTokenInput.refPath;
const remotePasswordRef = remotePasswordInput.refPath;
const remoteToken = remoteTokenInput.value;
const remotePassword = remotePasswordInput.value;
const localToken = localTokenInput.value;
const localPassword = localPasswordInput.value;
const localTokenPrecedence =
params.localTokenPrecedence ??
@@ -232,8 +261,8 @@ export function resolveGatewayCredentialsFromConfig(params: {
// In local mode, prefer gateway.auth.token, but also accept gateway.remote.token
// as a fallback for cron commands and other local gateway clients.
// This allows users in remote mode to use a single token for all operations.
const fallbackToken = localToken ?? remoteToken;
const fallbackPassword = localPassword ?? remotePassword;
const fallbackToken = localTokenInput.configured ? localToken : remoteToken;
const fallbackPassword = localPasswordInput.configured ? localPassword : remotePassword;
const localResolved = resolveGatewayCredentialsFromValues({
configToken: fallbackToken,
configPassword: fallbackPassword,

View File

@@ -51,6 +51,34 @@ describe("resolveGatewayProbeAuthSafe", () => {
expect(result.warning).toContain("unresolved");
});
it("does not fall through to remote token when local token SecretRef is unresolved", () => {
const result = resolveGatewayProbeAuthSafe({
cfg: {
gateway: {
mode: "local",
auth: {
mode: "token",
token: { source: "env", provider: "default", id: "MISSING_GATEWAY_TOKEN" },
},
remote: {
token: "remote-token",
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as OpenClawConfig,
mode: "local",
env: {} as NodeJS.ProcessEnv,
});
expect(result.auth).toEqual({});
expect(result.warning).toContain("gateway.auth.token");
expect(result.warning).toContain("unresolved");
});
it("ignores unresolved local token SecretRef in remote mode when remote-only auth is requested", () => {
const result = resolveGatewayProbeAuthSafe({
cfg: {