fix(gateway): keep configured local auth authoritative (#114462)

* fix(gateway): align configured local credentials

* test(gateway): align local credential coverage

* chore(gateway): leave release note to release process
This commit is contained in:
Peter Steinberger
2026-07-27 05:43:31 -04:00
committed by GitHub
parent c56689416e
commit fd6e042d87
31 changed files with 265 additions and 215 deletions

View File

@@ -297,7 +297,7 @@ Security note:
- `--token` and `--password` can be visible in local process listings on some systems. Prefer `--token-file`/`--password-file` or environment variables (`OPENCLAW_GATEWAY_TOKEN`, `OPENCLAW_GATEWAY_PASSWORD`).
- Gateway auth resolution follows the shared contract used by other Gateway clients:
- local mode: env (`OPENCLAW_GATEWAY_*`) then `gateway.auth.*`, falling back to `gateway.remote.*` only when `gateway.auth.*` is unset (a configured-but-unresolved local SecretRef fails closed instead of silently falling back)
- local mode: `gateway.auth.*` then env (`OPENCLAW_GATEWAY_*`), falling back to `gateway.remote.*` only when `gateway.auth.*` is unset (a configured-but-unresolved local SecretRef fails closed instead of silently falling back)
- remote mode: `gateway.remote.*` with env/config fallback per remote precedence rules
- `--url` is override-safe and does not reuse implicit config/env credentials; pass explicit `--token`/`--password` (or file variants)

View File

@@ -95,12 +95,12 @@ Gateway credential resolution follows one shared contract across call/probe/stat
- CLI `--url` never reuses implicit config/env credentials.
- Env `OPENCLAW_GATEWAY_URL` may use env credentials only (`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`).
- Local mode defaults:
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` -> `gateway.remote.token` (remote fallback only when the local token is unset)
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password` (remote fallback only when the local password is unset)
- token: `gateway.auth.token` -> `OPENCLAW_GATEWAY_TOKEN` -> `gateway.remote.token` (remote fallback only when the local token is unset)
- password: `gateway.auth.password` -> `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.remote.password` (remote fallback only when the local password is unset)
- Remote mode defaults:
- token: `gateway.remote.token` -> `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token`
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.remote.password` -> `gateway.auth.password`
- Node-host local-mode exception: `gateway.remote.token` / `gateway.remote.password` are ignored.
- Node-host local-mode exception: environment credentials stay first and `gateway.remote.token` / `gateway.remote.password` are ignored because node commands target an explicit host and port.
- Remote probe/status token checks are strict by default: they use `gateway.remote.token` only (no local token fallback) when targeting remote mode.
- Gateway env overrides use `OPENCLAW_GATEWAY_*` only.

View File

@@ -80,7 +80,7 @@ SecretRefs are validated only on effectively active surfaces:
- `gateway.remote.url` is configured
- `gateway.tailscale.mode` is `serve` or `funnel`
- In local mode without those remote surfaces: `gateway.remote.token` is active when token auth can win and no env/auth token is configured; `gateway.remote.password` is active only when password auth can win and no env/auth password is configured.
- `gateway.auth.token` SecretRef is inactive for startup auth resolution when `OPENCLAW_GATEWAY_TOKEN` is set, because env token input wins for that runtime.
- Active `gateway.auth.token` / `gateway.auth.password` SecretRefs stay authoritative over `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`; environment credentials are fallbacks when the corresponding local config input is absent.
</Accordion>

View File

@@ -371,7 +371,7 @@ describe("registerQrCli", () => {
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
});
it("uses OPENCLAW_GATEWAY_PASSWORD without resolving local password SecretRef", async () => {
it("does not let OPENCLAW_GATEWAY_PASSWORD mask a local password SecretRef", async () => {
vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", "password-from-env");
loadConfig.mockReturnValue(
createLocalGatewayConfigWithAuth(
@@ -379,9 +379,9 @@ describe("registerQrCli", () => {
),
);
await runQr(["--setup-code-only"]);
expectLoggedLocalSetupCode();
await expectQrExit(["--setup-code-only"]);
const output = runtimeError.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
expect(output).toContain("MISSING_LOCAL_GATEWAY_PASSWORD");
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
});

View File

@@ -166,10 +166,10 @@ async function collectMacLaunchctlGatewayEnvOverrideWarning(
"- Host-wide launchctl gateway auth overrides detected.",
"- Current managed Gateway installs do not need these values unless config intentionally references the env var.",
envToken && envTokenKey
? `- \`${envTokenKey}\` is set; it can make local clients use a different token than gateway.auth.token.`
? `- \`${envTokenKey}\` is set; explicit environment URL or node-host targets can use a different token than gateway.auth.token.`
: undefined,
envPassword
? `- \`${envPasswordKey ?? "OPENCLAW_GATEWAY_PASSWORD"}\` is set; it can make local clients use a different password than gateway.auth.password.`
? `- \`${envPasswordKey ?? "OPENCLAW_GATEWAY_PASSWORD"}\` is set; explicit environment URL or node-host targets can use a different password than gateway.auth.password.`
: undefined,
"- Clear overrides and restart the app/gateway:",
envTokenKey ? ` launchctl unsetenv ${envTokenKey}` : undefined,

View File

@@ -198,7 +198,7 @@ describe("noteSecurityWarnings gateway exposure", () => {
await noteSecurityWarnings(cfg);
const message = lastMessage();
expect(message).toContain("OPENCLAW_GATEWAY_TOKEN conflicts with gateway.auth.token");
expect(message).toContain("Direct local Gateway clients commonly prefer the env token");
expect(message).toContain("Configured local Gateway clients");
expect(message).toContain("~/.openclaw/.env");
});

View File

@@ -36,8 +36,10 @@ async function materializeDoctorGatewayAuthRefs(
cfg,
env,
mode: cfg.gateway?.auth?.mode,
hasTokenCandidate: Boolean(normalizeOptionalString(env.OPENCLAW_GATEWAY_TOKEN)),
hasPasswordCandidate: Boolean(normalizeOptionalString(env.OPENCLAW_GATEWAY_PASSWORD)),
hasTokenOverride: false,
hasPasswordOverride: false,
hasTokenFallback: Boolean(normalizeOptionalString(env.OPENCLAW_GATEWAY_TOKEN)),
hasPasswordFallback: Boolean(normalizeOptionalString(env.OPENCLAW_GATEWAY_PASSWORD)),
};
if (!canMaterializeGatewayAuthSecretRefsWithoutExec(materializeParams)) {
return cfg;

View File

@@ -108,8 +108,6 @@ async function resolveBrowserHatchTarget(
config,
env,
modeOverride: "local",
localTokenPrecedence: "config-first",
localPasswordPrecedence: "config-first",
});
const auth = resolveGatewayAuth({
authConfig: {

View File

@@ -19,8 +19,10 @@ type GatewayAuthSecretRefResolutionParams = {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
mode?: GatewayAuthConfig["mode"];
hasPasswordCandidate: boolean;
hasTokenCandidate: boolean;
hasPasswordOverride: boolean;
hasTokenOverride: boolean;
hasPasswordFallback: boolean;
hasTokenFallback: boolean;
};
/** Check whether a local Gateway auth input is configured directly or through defaults. */
@@ -35,12 +37,14 @@ export function hasConfiguredGatewayAuthSecretInput(
function shouldResolveGatewayAuthSecretRef(params: {
mode?: GatewayAuthConfig["mode"];
path: GatewayAuthSecretInputPath;
hasPasswordCandidate: boolean;
hasTokenCandidate: boolean;
hasPasswordOverride: boolean;
hasTokenOverride: boolean;
hasPasswordFallback: boolean;
hasTokenFallback: boolean;
}): boolean {
const isTokenPath = params.path === "gateway.auth.token";
const hasPathCandidate = isTokenPath ? params.hasTokenCandidate : params.hasPasswordCandidate;
if (hasPathCandidate) {
const hasPathOverride = isTokenPath ? params.hasTokenOverride : params.hasPasswordOverride;
if (hasPathOverride) {
return false;
}
if (params.mode === (isTokenPath ? "token" : "password")) {
@@ -56,8 +60,10 @@ function shouldResolveGatewayAuthSecretRef(params: {
return !isTokenPath;
}
// With implicit mode, resolve the side that does not already have a concrete
// candidate so token and password defaults do not both get materialized.
return isTokenPath ? !params.hasPasswordCandidate : !params.hasTokenCandidate;
// competing credential so token and password defaults do not both get materialized.
return isTokenPath
? !(params.hasPasswordOverride || params.hasPasswordFallback)
: !(params.hasTokenOverride || params.hasTokenFallback);
}
function shouldResolveGatewayTokenSecretRef(
@@ -66,8 +72,10 @@ function shouldResolveGatewayTokenSecretRef(
return shouldResolveGatewayAuthSecretRef({
mode: params.mode,
path: "gateway.auth.token",
hasPasswordCandidate: params.hasPasswordCandidate,
hasTokenCandidate: params.hasTokenCandidate,
hasPasswordOverride: params.hasPasswordOverride,
hasTokenOverride: params.hasTokenOverride,
hasPasswordFallback: params.hasPasswordFallback,
hasTokenFallback: params.hasTokenFallback,
});
}
@@ -77,8 +85,10 @@ function shouldResolveGatewayPasswordSecretRef(
return shouldResolveGatewayAuthSecretRef({
mode: params.mode,
path: "gateway.auth.password",
hasPasswordCandidate: params.hasPasswordCandidate,
hasTokenCandidate: params.hasTokenCandidate,
hasPasswordOverride: params.hasPasswordOverride,
hasTokenOverride: params.hasTokenOverride,
hasPasswordFallback: params.hasPasswordFallback,
hasTokenFallback: params.hasTokenFallback,
});
}
@@ -187,8 +197,10 @@ async function resolveGatewayPasswordSecretRef(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
mode?: GatewayAuthConfig["mode"];
hasPasswordCandidate: boolean;
hasTokenCandidate: boolean;
hasPasswordOverride: boolean;
hasTokenOverride: boolean;
hasPasswordFallback: boolean;
hasTokenFallback: boolean;
}): Promise<OpenClawConfig> {
return resolveGatewayAuthSecretRef({
cfg: params.cfg,
@@ -212,9 +224,11 @@ export async function materializeGatewayAuthSecretRefs(
cfg: cfgWithToken,
env: params.env,
mode: params.mode,
hasPasswordCandidate: params.hasPasswordCandidate,
hasTokenCandidate:
params.hasTokenCandidate ||
hasPasswordOverride: params.hasPasswordOverride,
hasTokenOverride: params.hasTokenOverride,
hasPasswordFallback: params.hasPasswordFallback,
hasTokenFallback:
params.hasTokenFallback ||
hasConfiguredGatewayAuthSecretInput(cfgWithToken, "gateway.auth.token"),
});
}

View File

@@ -18,6 +18,39 @@ function remoteGatewayConfig(remote?: GatewayRemoteConfig): OpenClawConfig {
}
describe("resolveGatewayInteractiveSurfaceAuth", () => {
it("keeps configured local password ahead of OPENCLAW_GATEWAY_PASSWORD", async () => {
await expect(
resolveGatewayInteractiveSurfaceAuth({
config: {
gateway: {
mode: "local",
auth: { mode: "password", password: "config-password" }, // pragma: allowlist secret
},
},
env: { OPENCLAW_GATEWAY_PASSWORD: "env-password" }, // pragma: allowlist secret
surface: "local",
}),
).resolves.toEqual({
token: undefined,
password: "config-password", // pragma: allowlist secret
failureReason: undefined,
});
});
it("falls back to OPENCLAW_GATEWAY_PASSWORD without configured local password", async () => {
await expect(
resolveGatewayInteractiveSurfaceAuth({
config: { gateway: { mode: "local", auth: { mode: "password" } } },
env: { OPENCLAW_GATEWAY_PASSWORD: "env-password" }, // pragma: allowlist secret
surface: "local",
}),
).resolves.toEqual({
token: undefined,
password: "env-password", // pragma: allowlist secret
failureReason: undefined,
});
});
it("uses OPENCLAW_GATEWAY_TOKEN as remote interactive fallback", async () => {
await expect(
resolveGatewayInteractiveSurfaceAuth({

View File

@@ -275,17 +275,16 @@ export async function resolveGatewayInteractiveSurfaceAuth(params: {
};
const resolvePassword = async () => {
const localPassword =
explicitPassword || envPassword
? { value: explicitPassword ?? envPassword }
: await resolveGatewayCredential({
config: params.config,
env,
diagnostics,
path: "gateway.auth.password",
value: params.config.gateway?.auth?.password,
});
const password = explicitPassword ?? envPassword ?? localPassword.value;
const localPassword = explicitPassword
? { value: explicitPassword }
: await resolveGatewayCredential({
config: params.config,
env,
diagnostics,
path: "gateway.auth.password",
value: params.config.gateway?.auth?.password,
});
const password = explicitPassword ?? localPassword.value ?? envPassword;
return {
password,
failureReason: password

View File

@@ -63,9 +63,9 @@ export function resolveGatewayAuthTokenSourceConflict(params: {
const title = `${GATEWAY_ENV_TOKEN} conflicts with gateway.auth.token`;
const detail =
`${GATEWAY_ENV_TOKEN} is set while gateway.auth.token uses a different configured source. ` +
"Direct local Gateway clients commonly prefer the env token, while the managed gateway service " +
"prefers gateway.auth.token. If the values differ, CLI/RPC calls can fail to authenticate " +
"with the running gateway.";
"Configured local Gateway clients and the managed gateway service prefer gateway.auth.token. " +
"Environment credentials remain active for explicit environment URL and node-host targets, " +
"so a stale value can still authenticate against the wrong target.";
const remediation =
`Remove ${GATEWAY_ENV_TOKEN} from the shell, ~/.openclaw/.env, or launchctl env if gateway.auth.token is intended, ` +
`or point gateway.auth.token at \${${GATEWAY_ENV_TOKEN}} if the env var should be canonical.`;

View File

@@ -2335,7 +2335,7 @@ describe("callGateway password resolution", () => {
expectedPassword: "secret",
},
{
label: "prefers env password over local config password",
label: "prefers local config password over env password",
envPassword: "from-env",
config: {
gateway: {
@@ -2344,7 +2344,7 @@ describe("callGateway password resolution", () => {
auth: { password: "from-config" },
},
},
expectedPassword: "from-env",
expectedPassword: "from-config",
},
{
label: "uses remote password in remote mode when env is unset",
@@ -2392,7 +2392,7 @@ describe("callGateway password resolution", () => {
expect(lastClientOptions?.password).toBe("resolved-local-ref-password");
});
it("does not resolve local password ref when env password takes precedence", async () => {
it("does not let env password mask an unresolved local password ref", async () => {
process.env.OPENCLAW_GATEWAY_PASSWORD = "from-env";
getRuntimeConfig.mockReturnValue({
gateway: {
@@ -2410,9 +2410,7 @@ describe("callGateway password resolution", () => {
},
} as unknown as OpenClawConfig);
await callGateway({ method: "health" });
expect(lastClientOptions?.password).toBe("from-env");
await expect(callGateway({ method: "health" })).rejects.toThrow("gateway.auth.password");
});
it("does not resolve local password ref when token auth can win", async () => {

View File

@@ -653,8 +653,7 @@ type ResolvedGatewayCallContext = {
remoteUrl?: string;
explicitAuth: ExplicitGatewayAuth;
modeOverride?: GatewayCredentialMode;
localTokenPrecedence?: GatewayCredentialPrecedence;
localPasswordPrecedence?: GatewayCredentialPrecedence;
localPrecedence?: GatewayCredentialPrecedence;
remoteTokenPrecedence?: GatewayRemoteCredentialPrecedence;
remotePasswordPrecedence?: GatewayRemoteCredentialPrecedence;
remoteTokenFallback?: GatewayRemoteCredentialFallback;
@@ -760,8 +759,7 @@ async function resolveGatewayCredentialsWithEnv(
urlOverrideSource: context.urlOverrideSource,
env,
modeOverride: context.modeOverride,
localTokenPrecedence: context.localTokenPrecedence,
localPasswordPrecedence: context.localPasswordPrecedence,
localPrecedence: context.localPrecedence,
remoteTokenPrecedence: context.remoteTokenPrecedence,
remotePasswordPrecedence: context.remotePasswordPrecedence,
remoteTokenFallback: context.remoteTokenFallback,

View File

@@ -44,7 +44,7 @@ const DEFAULT_ENV = {
describe("resolveGatewayConnectionAuth", () => {
const cases: ConnectionAuthCase[] = [
{
name: "local mode defaults to env-first token/password",
name: "local mode defaults to config-first token/password",
cfgLocal: cfg({
gateway: {
mode: "local",
@@ -59,52 +59,29 @@ describe("resolveGatewayConnectionAuth", () => {
},
}),
env: DEFAULT_ENV,
expected: {
token: "env-token",
password: "env-password", // pragma: allowlist secret
},
},
{
name: "local mode supports config-first token/password",
cfgLocal: cfg({
gateway: {
mode: "local",
auth: {
token: "config-token",
password: "config-password", // pragma: allowlist secret
},
},
}),
env: DEFAULT_ENV,
options: {
localTokenPrecedence: "config-first",
localPasswordPrecedence: "config-first", // pragma: allowlist secret
},
expected: {
token: "config-token",
password: "config-password", // pragma: allowlist secret
},
},
{
name: "local mode precedence can mix env-first token with config-first password",
name: "local mode supports explicit env-first token/password",
cfgLocal: cfg({
gateway: {
mode: "local",
auth: {},
remote: {
token: "remote-token",
password: "remote-password", // pragma: allowlist secret
auth: {
token: "config-token",
password: "config-password", // pragma: allowlist secret
},
},
}),
env: DEFAULT_ENV,
options: {
localTokenPrecedence: "env-first",
localPasswordPrecedence: "config-first", // pragma: allowlist secret
localPrecedence: "env-first",
},
expected: {
token: "env-token",
password: "remote-password", // pragma: allowlist secret
password: "env-password", // pragma: allowlist secret
},
},
{
@@ -220,6 +197,20 @@ describe("resolveGatewayConnectionAuth", () => {
});
});
it("resolves an env-template local token through the configured auth path", async () => {
await expect(
resolveGatewayConnectionAuth({
config: cfg({
gateway: {
mode: "local",
auth: { mode: "token", token: "${OPENCLAW_GATEWAY_TOKEN}" },
},
}),
env: { OPENCLAW_GATEWAY_TOKEN: "env-token" },
}),
).resolves.toEqual({ token: "env-token", password: undefined });
});
it("resolves config-first token SecretRef even when OPENCLAW env token exists", async () => {
const config = cfg({
gateway: {
@@ -242,7 +233,6 @@ describe("resolveGatewayConnectionAuth", () => {
const resolved = await resolveGatewayConnectionAuth({
config,
env,
localTokenPrecedence: "config-first",
});
expect(resolved).toEqual({
token: "config-first-token",
@@ -273,7 +263,6 @@ describe("resolveGatewayConnectionAuth", () => {
const resolved = await resolveGatewayConnectionAuth({
config,
env,
localPasswordPrecedence: "config-first", // pragma: allowlist secret
});
expect(resolved).toEqual({
token: undefined,
@@ -303,7 +292,6 @@ describe("resolveGatewayConnectionAuth", () => {
resolveGatewayConnectionAuth({
config,
env,
localTokenPrecedence: "config-first",
}),
).rejects.toThrow("gateway.auth.token");
});
@@ -331,7 +319,6 @@ describe("resolveGatewayConnectionAuth", () => {
resolveGatewayConnectionAuth({
config,
env,
localPasswordPrecedence: "config-first", // pragma: allowlist secret
}),
).rejects.toThrow("gateway.auth.password");
});

View File

@@ -64,16 +64,6 @@ export function trimCredentialToUndefined(value: unknown): string | undefined {
return trimmed;
}
/** True when the process env supplies a nonempty Gateway token candidate. */
export function hasGatewayTokenEnvCandidate(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN));
}
/** True when the process env supplies a nonempty Gateway password candidate. */
export function hasGatewayPasswordEnvCandidate(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD));
}
/** Classify one configured credential input without resolving secret refs. */
function resolveConfiguredGatewayCredentialInput(params: {
value: unknown;
@@ -139,7 +129,6 @@ export function createGatewayCredentialPlan(params: {
(authMode !== "token" && authMode !== "none" && !tokenCanWin);
const localTokenSurfaceActive =
localTokenCanWin &&
!envToken &&
(authMode === "token" ||
(authMode === undefined && !(envPassword || localPassword.configured)));

View File

@@ -54,7 +54,7 @@ function withGatewayAuthEnv<T>(env: NodeJS.ProcessEnv, fn: () => T): T {
describe("gateway credential precedence coverage", () => {
const cases: TestCase[] = [
{
name: "local mode: env overrides config for call/probe/status, auth remains config-first",
name: "local mode keeps configured auth aligned across client and server surfaces",
cfg: {
gateway: {
mode: "local",
@@ -69,8 +69,8 @@ describe("gateway credential precedence coverage", () => {
OPENCLAW_GATEWAY_PASSWORD: "env-password", // pragma: allowlist secret
} as NodeJS.ProcessEnv,
expected: {
call: { token: "env-token", password: "env-password" }, // pragma: allowlist secret
probe: { token: "env-token", password: "env-password" }, // pragma: allowlist secret
call: { token: "config-token", password: "config-password" }, // pragma: allowlist secret
probe: { token: "config-token", password: "config-password" }, // pragma: allowlist secret
status: { token: "config-token", password: "config-password" }, // pragma: allowlist secret
auth: { token: "config-token", password: "config-password" }, // pragma: allowlist secret
},
@@ -103,7 +103,7 @@ describe("gateway credential precedence coverage", () => {
},
},
{
name: "local mode in gateway service runtime uses config-first token precedence",
name: "gateway service runtime uses the same local credential policy",
cfg: {
gateway: {
mode: "local",
@@ -119,8 +119,8 @@ describe("gateway credential precedence coverage", () => {
OPENCLAW_SERVICE_KIND: "gateway",
} as NodeJS.ProcessEnv,
expected: {
call: { token: "config-token", password: "env-password" }, // pragma: allowlist secret
probe: { token: "config-token", password: "env-password" }, // pragma: allowlist secret
call: { token: "config-token", password: "config-password" }, // pragma: allowlist secret
probe: { token: "config-token", password: "config-password" }, // pragma: allowlist secret
status: { token: "config-token", password: "config-password" }, // pragma: allowlist secret
auth: { token: "config-token", password: "config-password" }, // pragma: allowlist secret
},

View File

@@ -29,8 +29,7 @@ type GatewayCredentialSecretInputOptions = {
urlOverrideSource?: "cli" | "env";
env?: NodeJS.ProcessEnv;
modeOverride?: GatewayCredentialMode;
localTokenPrecedence?: GatewayCredentialPrecedence;
localPasswordPrecedence?: GatewayCredentialPrecedence;
localPrecedence?: GatewayCredentialPrecedence;
remoteTokenPrecedence?: GatewayRemoteCredentialPrecedence;
remotePasswordPrecedence?: GatewayRemoteCredentialPrecedence;
remoteTokenFallback?: GatewayRemoteCredentialFallback;
@@ -101,8 +100,7 @@ function resolveGatewayCredentialsFromConfigOptions(params: {
urlOverride: options.urlOverride,
urlOverrideSource: options.urlOverrideSource,
modeOverride: options.modeOverride,
localTokenPrecedence: options.localTokenPrecedence,
localPasswordPrecedence: options.localPasswordPrecedence,
localPrecedence: options.localPrecedence,
remoteTokenPrecedence: options.remoteTokenPrecedence,
remotePasswordPrecedence: options.remotePasswordPrecedence ?? "env-first", // pragma: allowlist secret
remoteTokenFallback: options.remoteTokenFallback,

View File

@@ -188,15 +188,15 @@ describe("resolveGatewayCredentialsFromConfig", () => {
expectEnvGatewayCredentials(resolved);
});
it("uses local-mode environment values before local config", () => {
it("uses local config before local-mode environment values", () => {
const resolved = resolveGatewayCredentialsFor({
mode: "local",
auth: DEFAULT_GATEWAY_AUTH,
});
expectEnvGatewayCredentials(resolved);
expect(resolved).toEqual(DEFAULT_GATEWAY_AUTH);
});
it("uses config-first local token precedence inside gateway service runtime", () => {
it("does not let the gateway service marker change local credential precedence", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: cfg({
gateway: {
@@ -212,10 +212,19 @@ describe("resolveGatewayCredentialsFromConfig", () => {
});
expect(resolved).toEqual({
token: "config-token",
password: "env-password", // pragma: allowlist secret
password: "config-password", // pragma: allowlist secret
});
});
it("keeps env ahead of remote fallback when local auth is missing", () => {
const resolved = resolveGatewayCredentialsFor({
mode: "local",
auth: {},
remote: DEFAULT_REMOTE_AUTH,
});
expectEnvGatewayCredentials(resolved);
});
it("falls back to remote credentials in local mode when local auth is missing", () => {
const resolved = resolveLocalGatewayCredentials({
remote: DEFAULT_REMOTE_AUTH,
@@ -250,26 +259,23 @@ describe("resolveGatewayCredentialsFromConfig", () => {
});
});
it("treats env-template local tokens as SecretRefs instead of plaintext", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: cfg({
gateway: {
mode: "local",
auth: {
mode: "token",
token: "${OPENCLAW_GATEWAY_TOKEN}",
it("fails closed on env-template local tokens in the synchronous resolver", () => {
expect(() =>
resolveGatewayCredentialsFromConfig({
cfg: cfg({
gateway: {
mode: "local",
auth: {
mode: "token",
token: "${OPENCLAW_GATEWAY_TOKEN}",
},
},
},
}),
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
} as NodeJS.ProcessEnv,
}),
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
} as NodeJS.ProcessEnv,
});
expect(resolved).toEqual({
token: "env-token",
password: undefined,
});
).toThrow("gateway.auth.token");
});
it("throws when env-template local token SecretRef is unresolved in token mode", () => {

View File

@@ -7,11 +7,7 @@ import {
trimCredentialToUndefined,
trimToUndefined,
} from "./credential-planner.js";
export {
hasGatewayPasswordEnvCandidate,
hasGatewayTokenEnvCandidate,
trimToUndefined,
} from "./credential-planner.js";
export { trimToUndefined } from "./credential-planner.js";
export type ExplicitGatewayAuth = {
token?: string;
@@ -112,25 +108,33 @@ export function resolveGatewayCredentialsFromValues(params: {
function resolveLocalGatewayCredentials(params: {
plan: GatewayCredentialPlan;
env: NodeJS.ProcessEnv;
localTokenPrecedence: GatewayCredentialPrecedence;
localPasswordPrecedence: GatewayCredentialPrecedence;
localPrecedence: GatewayCredentialPrecedence;
}): ResolvedGatewayCredentials {
const fallbackToken = params.plan.localToken.configured
const tokenConfigFallback = params.plan.localToken.configured
? params.plan.localToken.value
: params.plan.remoteToken.value;
const fallbackPassword = params.plan.localPassword.configured
const passwordConfigFallback = params.plan.localPassword.configured
? params.plan.localPassword.value
: params.plan.authMode === "trusted-proxy"
? undefined
: params.plan.remotePassword.value;
const localResolved = resolveGatewayCredentialsFromValues({
configToken: fallbackToken,
configPassword: fallbackPassword,
env: params.env,
tokenPrecedence: params.localTokenPrecedence,
passwordPrecedence: params.localPasswordPrecedence,
});
const token =
params.localPrecedence === "config-first"
? firstDefined([
params.plan.localToken.value,
params.plan.envToken,
params.plan.localToken.configured ? undefined : params.plan.remoteToken.value,
])
: firstDefined([params.plan.envToken, tokenConfigFallback]);
const password =
params.localPrecedence === "config-first"
? firstDefined([
params.plan.localPassword.value,
params.plan.envPassword,
params.plan.localPassword.configured ? undefined : passwordConfigFallback,
])
: firstDefined([params.plan.envPassword, passwordConfigFallback]);
const localResolved = { token, password };
const localPasswordCanWin =
params.plan.authMode === "password" ||
params.plan.authMode === "trusted-proxy" ||
@@ -146,7 +150,7 @@ function resolveLocalGatewayCredentials(params: {
// unresolved secret ref that would otherwise be the active local credential.
if (
params.plan.localToken.refPath &&
params.localTokenPrecedence === "config-first" &&
params.localPrecedence === "config-first" &&
!params.plan.localToken.value &&
Boolean(params.plan.envToken) &&
localTokenCanWin
@@ -155,7 +159,7 @@ function resolveLocalGatewayCredentials(params: {
}
if (
params.plan.localPassword.refPath &&
params.localPasswordPrecedence === "config-first" && // pragma: allowlist secret
params.localPrecedence === "config-first" && // pragma: allowlist secret
!params.plan.localPassword.value &&
Boolean(params.plan.envPassword) &&
localPasswordCanWin
@@ -265,8 +269,7 @@ export function resolveGatewayCredentialsFromConfig(params: {
urlOverride?: string;
urlOverrideSource?: "cli" | "env";
modeOverride?: GatewayCredentialMode;
localTokenPrecedence?: GatewayCredentialPrecedence;
localPasswordPrecedence?: GatewayCredentialPrecedence;
localPrecedence?: GatewayCredentialPrecedence;
remoteTokenPrecedence?: GatewayRemoteCredentialPrecedence;
remotePasswordPrecedence?: GatewayRemoteCredentialPrecedence;
remoteTokenFallback?: GatewayRemoteCredentialFallback;
@@ -300,17 +303,10 @@ export function resolveGatewayCredentialsFromConfig(params: {
});
const mode: GatewayCredentialMode = params.modeOverride ?? plan.configuredMode;
const localTokenPrecedence =
params.localTokenPrecedence ??
(env.OPENCLAW_SERVICE_KIND === "gateway" ? "config-first" : "env-first");
const localPasswordPrecedence = params.localPasswordPrecedence ?? "env-first";
if (mode === "local") {
return resolveLocalGatewayCredentials({
plan,
env,
localTokenPrecedence,
localPasswordPrecedence,
localPrecedence: params.localPrecedence ?? "config-first",
});
}

View File

@@ -227,16 +227,32 @@ describe("ensureGatewayStartupAuth", () => {
});
});
it("uses OPENCLAW_GATEWAY_TOKEN without resolving configured token SecretRef", async () => {
it("keeps configured token SecretRef ahead of OPENCLAW_GATEWAY_TOKEN", async () => {
const configuredToken = gatewayEnvSecretRef("GW_TOKEN");
await expectResolvedToken({
cfg: createMissingGatewayTokenSecretRefConfig(),
cfg: gatewayAuthConfigWithDefaultEnvProvider({
mode: "token",
token: configuredToken,
}),
env: {
GW_TOKEN: "token-from-config-ref",
OPENCLAW_GATEWAY_TOKEN: "token-from-env",
} as NodeJS.ProcessEnv,
expectedToken: "token-from-env",
expectedToken: "token-from-config-ref",
expectedConfiguredToken: configuredToken,
});
});
it("does not let OPENCLAW_GATEWAY_TOKEN mask an unresolved configured token ref", async () => {
await expect(
runStartupAuth({
cfg: createMissingGatewayTokenSecretRefConfig(),
env: { OPENCLAW_GATEWAY_TOKEN: "token-from-env" } as NodeJS.ProcessEnv,
persist: true,
}),
).rejects.toThrow(/MISSING_GW_TOKEN/i);
});
it("fails when gateway.auth.token SecretRef is active and unresolved", async () => {
await expect(
runStartupAuth({
@@ -260,19 +276,35 @@ describe("ensureGatewayStartupAuth", () => {
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
});
it("uses OPENCLAW_GATEWAY_PASSWORD without resolving configured password SecretRef", async () => {
it("keeps configured password SecretRef ahead of OPENCLAW_GATEWAY_PASSWORD", async () => {
const configuredPassword = gatewayEnvSecretRef("GW_PASSWORD");
const result = await runStartupAuth({
cfg: gatewayAuthConfigWithDefaultEnvProvider({
mode: "password",
password: gatewayEnvSecretRef("MISSING_GW_PASSWORD"),
password: configuredPassword,
}),
env: {
GW_PASSWORD: "password-from-config-ref", // pragma: allowlist secret
OPENCLAW_GATEWAY_PASSWORD: "password-from-env", // pragma: allowlist secret
} as NodeJS.ProcessEnv,
persist: true,
});
expectResolvedPassword(result, "password-from-env");
expectResolvedPassword(result, "password-from-config-ref");
expect(result.cfg.gateway?.auth?.password).toEqual(configuredPassword);
});
it("does not let OPENCLAW_GATEWAY_PASSWORD mask an unresolved configured password ref", async () => {
await expect(
runStartupAuth({
cfg: gatewayAuthConfigWithDefaultEnvProvider({
mode: "password",
password: gatewayEnvSecretRef("MISSING_GW_PASSWORD"),
}),
env: { OPENCLAW_GATEWAY_PASSWORD: "password-from-env" } as NodeJS.ProcessEnv,
persist: true,
}),
).rejects.toThrow(/MISSING_GW_PASSWORD/i);
});
it("does not resolve gateway.auth.password SecretRef when token mode is explicit", async () => {

View File

@@ -11,11 +11,7 @@ import {
} from "./auth-config-utils.js";
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "./auth-mode-policy.js";
import { resolveGatewayAuth, type ResolvedGatewayAuth } from "./auth.js";
import {
hasGatewayPasswordEnvCandidate,
hasGatewayTokenEnvCandidate,
trimToUndefined,
} from "./credentials.js";
import { trimToUndefined } from "./credentials.js";
import { assertGatewayAuthNotKnownWeak } from "./known-weak-gateway-secrets.js";
const HOOKS_GATEWAY_AUTH_REUSE_WARNING =
@@ -144,12 +140,8 @@ function hasGatewayTokenOverrideCandidate(params: { authOverride?: GatewayAuthCo
}
function hasGatewayPasswordOverrideCandidate(params: {
env: NodeJS.ProcessEnv;
authOverride?: GatewayAuthConfig;
}): boolean {
if (hasGatewayPasswordEnvCandidate(params.env)) {
return true;
}
return (
typeof params.authOverride?.password === "string" &&
params.authOverride.password.trim().length > 0
@@ -185,22 +177,27 @@ export async function ensureGatewayStartupAuth(params: {
cfg: params.cfg,
env,
mode: explicitMode,
hasTokenCandidate:
hasGatewayTokenOverrideCandidate({ authOverride: params.authOverride }) ||
hasGatewayTokenEnvCandidate(env),
hasPasswordCandidate:
hasGatewayPasswordOverrideCandidate({ env, authOverride: params.authOverride }) ||
hasTokenOverride: hasGatewayTokenOverrideCandidate({ authOverride: params.authOverride }),
hasPasswordOverride: hasGatewayPasswordOverrideCandidate({
authOverride: params.authOverride,
}),
hasTokenFallback: Boolean(trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN)),
hasPasswordFallback:
Boolean(trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD)) ||
hasConfiguredGatewayAuthSecretInput(params.cfg, "gateway.auth.password"),
}),
resolveGatewayPasswordSecretRefValue({
cfg: params.cfg,
env,
mode: explicitMode,
hasPasswordCandidate: hasGatewayPasswordOverrideCandidate({
env,
hasPasswordOverride: hasGatewayPasswordOverrideCandidate({
authOverride: params.authOverride,
}),
hasTokenCandidate: hasGatewayTokenCandidate({
hasTokenOverride: hasGatewayTokenOverrideCandidate({
authOverride: params.authOverride,
}),
hasPasswordFallback: Boolean(trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD)),
hasTokenFallback: hasGatewayTokenCandidate({
cfg: params.cfg,
env,
authOverride: params.authOverride,

View File

@@ -285,8 +285,7 @@ describe("runNodeHost", () => {
},
},
env: process.env,
localTokenPrecedence: "env-first",
localPasswordPrecedence: "env-first",
localPrecedence: "env-first",
remoteTokenPrecedence: "env-first",
remotePasswordPrecedence: "env-first",
});

View File

@@ -158,8 +158,7 @@ async function resolveNodeHostGatewayCredentials(params: {
return await resolveGatewayConnectionAuth({
config: configForResolution,
env: params.env,
localTokenPrecedence: "env-first",
localPasswordPrecedence: "env-first", // pragma: allowlist secret
localPrecedence: "env-first",
remoteTokenPrecedence: "env-first",
remotePasswordPrecedence: "env-first", // pragma: allowlist secret
});

View File

@@ -365,17 +365,6 @@ describe("pairing setup code", () => {
},
expectedAuthLabel: "password",
},
{
name: "uses OPENCLAW_GATEWAY_PASSWORD without resolving configured password SecretRef",
auth: {
mode: "password",
password: { source: "env", provider: "default", id: "MISSING_GW_PASSWORD" },
} as const,
env: {
OPENCLAW_GATEWAY_PASSWORD: "password-from-env", // pragma: allowlist secret
},
expectedAuthLabel: "password",
},
{
name: "does not resolve gateway.auth.password SecretRef in token mode",
auth: {
@@ -419,6 +408,20 @@ describe("pairing setup code", () => {
options: { env: {} },
expectedError: "MISSING_GW_TOKEN",
},
{
name: "does not let OPENCLAW_GATEWAY_PASSWORD mask a configured password SecretRef",
config: createCustomGatewayConfig(
{
mode: "password",
password: { source: "env", provider: "default", id: "MISSING_GW_PASSWORD" },
},
defaultEnvSecretProviderConfig,
),
options: {
env: { OPENCLAW_GATEWAY_PASSWORD: "password-from-env" },
},
expectedError: "MISSING_GW_PASSWORD",
},
] as const)("$name", async ({ config, options, expectedError }) => {
await expectResolvedSetupFailureCase({ config, options, expectedError });
});

View File

@@ -419,8 +419,10 @@ export async function resolvePairingSetupFromConfig(
cfg,
env,
mode: cfg.gateway?.auth?.mode,
hasTokenCandidate: Boolean(normalizeOptionalString(env.OPENCLAW_GATEWAY_TOKEN)),
hasPasswordCandidate: Boolean(normalizeOptionalString(env.OPENCLAW_GATEWAY_PASSWORD)),
hasTokenOverride: false,
hasPasswordOverride: false,
hasTokenFallback: Boolean(normalizeOptionalString(env.OPENCLAW_GATEWAY_TOKEN)),
hasPasswordFallback: Boolean(normalizeOptionalString(env.OPENCLAW_GATEWAY_PASSWORD)),
});
const authLabel = resolvePairingSetupAuthLabel(cfgForAuth, env);
if (authLabel.error) {

View File

@@ -43,7 +43,7 @@ describe("evaluateGatewayAuthSurfaceStates", () => {
});
});
it("marks gateway.auth.token inactive when env token is configured", () => {
it("keeps gateway.auth.token active when env token is configured", () => {
const states = evaluate(
{
gateway: {
@@ -58,8 +58,8 @@ describe("evaluateGatewayAuthSurfaceStates", () => {
expectGatewayState(states["gateway.auth.token"], {
hasSecretRef: true,
active: false,
reason: "gateway token env var is configured.",
active: true,
reason: 'gateway.auth.mode is "token".',
});
});

View File

@@ -137,9 +137,7 @@ export function evaluateGatewayAuthSurfaceStates(params: {
return "gateway.auth is not configured.";
}
if (plan.authMode === "token") {
return plan.envToken
? "gateway token env var is configured."
: 'gateway.auth.mode is "token".';
return 'gateway.auth.mode is "token".';
}
if (
plan.authMode === "password" ||

View File

@@ -39,10 +39,10 @@ describe("security audit gateway auth selection", () => {
expectedAuth: { token: "local-token-abc123" },
},
{
name: "prefers env token over local config token",
name: "prefers local config token over env token",
cfg: { gateway: { mode: "local", auth: { token: "local-token" } } },
env: { token: "env-token" },
expectedAuth: { token: "env-token" },
expectedAuth: { token: "local-token" },
},
{
name: "uses local auth when gateway.mode is undefined (default)",

View File

@@ -248,8 +248,10 @@ async function materializeAuditGatewayAuthRefs(params: {
cfg: params.cfg,
env: params.env,
mode: params.cfg.gateway?.auth?.mode,
hasTokenCandidate: Boolean(normalizeOptionalString(params.env.OPENCLAW_GATEWAY_TOKEN)),
hasPasswordCandidate: Boolean(normalizeOptionalString(params.env.OPENCLAW_GATEWAY_PASSWORD)),
hasTokenOverride: false,
hasPasswordOverride: false,
hasTokenFallback: Boolean(normalizeOptionalString(params.env.OPENCLAW_GATEWAY_TOKEN)),
hasPasswordFallback: Boolean(normalizeOptionalString(params.env.OPENCLAW_GATEWAY_PASSWORD)),
};
if (!canMaterializeGatewayAuthSecretRefsWithoutExec(materializeParams)) {
return params.cfg;

View File

@@ -314,7 +314,7 @@ describe("resolveGatewayConnection", () => {
expect(result.token).toBeUndefined();
});
it("keeps normal TUI local password mode env precedence by default", async () => {
it("keeps configured local password ahead of the ambient env password", async () => {
loadConfig.mockReturnValue({
gateway: {
mode: "local",
@@ -327,7 +327,7 @@ describe("resolveGatewayConnection", () => {
await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "env-password" }, async () => {
const result = await resolveGatewayConnection({});
expect(result.password).toBe("env-password");
expect(result.password).toBe("config-password");
});
});