mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix: fail closed for unresolved local gateway auth refs
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(() => ({}));
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user