diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 3843a961b0b1..743711239d4d 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -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) diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index d07f009073b4..75e3881c9fb1 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -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. diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 7290ae078bff..ec56a868d9a6 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -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. diff --git a/src/cli/qr-cli.test.ts b/src/cli/qr-cli.test.ts index 825da49336aa..cf7bc60eb52a 100644 --- a/src/cli/qr-cli.test.ts +++ b/src/cli/qr-cli.test.ts @@ -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(); }); diff --git a/src/commands/doctor-platform-notes.ts b/src/commands/doctor-platform-notes.ts index 252243dc1b17..2c422e33388e 100644 --- a/src/commands/doctor-platform-notes.ts +++ b/src/commands/doctor-platform-notes.ts @@ -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, diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index 3294a8acc707..f7cea167129c 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -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"); }); diff --git a/src/commands/doctor/shared/hooks-token-reuse-repair.ts b/src/commands/doctor/shared/hooks-token-reuse-repair.ts index b6b61fde019c..1b4ad888d3ee 100644 --- a/src/commands/doctor/shared/hooks-token-reuse-repair.ts +++ b/src/commands/doctor/shared/hooks-token-reuse-repair.ts @@ -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; diff --git a/src/commands/onboard-browser-handoff.ts b/src/commands/onboard-browser-handoff.ts index 64a65283faf4..963f3b3b1d3b 100644 --- a/src/commands/onboard-browser-handoff.ts +++ b/src/commands/onboard-browser-handoff.ts @@ -108,8 +108,6 @@ async function resolveBrowserHatchTarget( config, env, modeOverride: "local", - localTokenPrecedence: "config-first", - localPasswordPrecedence: "config-first", }); const auth = resolveGatewayAuth({ authConfig: { diff --git a/src/gateway/auth-config-utils.ts b/src/gateway/auth-config-utils.ts index 0c5d295694a0..d54d7fff20d6 100644 --- a/src/gateway/auth-config-utils.ts +++ b/src/gateway/auth-config-utils.ts @@ -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 { 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"), }); } diff --git a/src/gateway/auth-surface-resolution.test.ts b/src/gateway/auth-surface-resolution.test.ts index 50a4565daef7..e83443bcf2a9 100644 --- a/src/gateway/auth-surface-resolution.test.ts +++ b/src/gateway/auth-surface-resolution.test.ts @@ -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({ diff --git a/src/gateway/auth-surface-resolution.ts b/src/gateway/auth-surface-resolution.ts index e23c26ba2c48..9d7c563de5cb 100644 --- a/src/gateway/auth-surface-resolution.ts +++ b/src/gateway/auth-surface-resolution.ts @@ -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 diff --git a/src/gateway/auth-token-source-conflict.ts b/src/gateway/auth-token-source-conflict.ts index b9d53700fdf5..1f5a702ef62c 100644 --- a/src/gateway/auth-token-source-conflict.ts +++ b/src/gateway/auth-token-source-conflict.ts @@ -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.`; diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 22d062e48210..360e402c4743 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -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 () => { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index b706450464b1..8bc9db5662c8 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -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, diff --git a/src/gateway/connection-auth.test.ts b/src/gateway/connection-auth.test.ts index 0b327343045e..97163817a30e 100644 --- a/src/gateway/connection-auth.test.ts +++ b/src/gateway/connection-auth.test.ts @@ -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"); }); diff --git a/src/gateway/credential-planner.ts b/src/gateway/credential-planner.ts index 078e166570c1..75d51ceefd14 100644 --- a/src/gateway/credential-planner.ts +++ b/src/gateway/credential-planner.ts @@ -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))); diff --git a/src/gateway/credential-precedence.parity.test.ts b/src/gateway/credential-precedence.parity.test.ts index eec6d525493a..31c26b3ecad2 100644 --- a/src/gateway/credential-precedence.parity.test.ts +++ b/src/gateway/credential-precedence.parity.test.ts @@ -54,7 +54,7 @@ function withGatewayAuthEnv(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 }, diff --git a/src/gateway/credentials-secret-inputs.ts b/src/gateway/credentials-secret-inputs.ts index 50632ac8214f..5c294fa5c2b8 100644 --- a/src/gateway/credentials-secret-inputs.ts +++ b/src/gateway/credentials-secret-inputs.ts @@ -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, diff --git a/src/gateway/credentials.test.ts b/src/gateway/credentials.test.ts index f7918d8eacfe..4da412f1cc4d 100644 --- a/src/gateway/credentials.test.ts +++ b/src/gateway/credentials.test.ts @@ -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", () => { diff --git a/src/gateway/credentials.ts b/src/gateway/credentials.ts index a40627a617e3..7e8121a28faf 100644 --- a/src/gateway/credentials.ts +++ b/src/gateway/credentials.ts @@ -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", }); } diff --git a/src/gateway/startup-auth.test.ts b/src/gateway/startup-auth.test.ts index badbb9e6d975..72cf187928bd 100644 --- a/src/gateway/startup-auth.test.ts +++ b/src/gateway/startup-auth.test.ts @@ -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 () => { diff --git a/src/gateway/startup-auth.ts b/src/gateway/startup-auth.ts index b4fe78ed971a..2f08cfcda7d2 100644 --- a/src/gateway/startup-auth.ts +++ b/src/gateway/startup-auth.ts @@ -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, diff --git a/src/node-host/runner.test.ts b/src/node-host/runner.test.ts index e169eb5a9c2e..742e0a154c2b 100644 --- a/src/node-host/runner.test.ts +++ b/src/node-host/runner.test.ts @@ -285,8 +285,7 @@ describe("runNodeHost", () => { }, }, env: process.env, - localTokenPrecedence: "env-first", - localPasswordPrecedence: "env-first", + localPrecedence: "env-first", remoteTokenPrecedence: "env-first", remotePasswordPrecedence: "env-first", }); diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 66b6201cfc6a..b32366a49693 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -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 }); diff --git a/src/pairing/setup-code.test.ts b/src/pairing/setup-code.test.ts index ec07ba56ca6f..4f93326f4426 100644 --- a/src/pairing/setup-code.test.ts +++ b/src/pairing/setup-code.test.ts @@ -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 }); }); diff --git a/src/pairing/setup-code.ts b/src/pairing/setup-code.ts index 1905293c2b8f..65d22876235b 100644 --- a/src/pairing/setup-code.ts +++ b/src/pairing/setup-code.ts @@ -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) { diff --git a/src/secrets/runtime-gateway-auth-surfaces.test.ts b/src/secrets/runtime-gateway-auth-surfaces.test.ts index 3ddcb1aa8587..7617fc3b3de8 100644 --- a/src/secrets/runtime-gateway-auth-surfaces.test.ts +++ b/src/secrets/runtime-gateway-auth-surfaces.test.ts @@ -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".', }); }); diff --git a/src/secrets/runtime-gateway-auth-surfaces.ts b/src/secrets/runtime-gateway-auth-surfaces.ts index 58ce2f95d1fa..07ffa71423bf 100644 --- a/src/secrets/runtime-gateway-auth-surfaces.ts +++ b/src/secrets/runtime-gateway-auth-surfaces.ts @@ -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" || diff --git a/src/security/audit-gateway-auth-selection.test.ts b/src/security/audit-gateway-auth-selection.test.ts index 3d622445184d..5dea9c3c8a11 100644 --- a/src/security/audit-gateway-auth-selection.test.ts +++ b/src/security/audit-gateway-auth-selection.test.ts @@ -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)", diff --git a/src/security/audit.ts b/src/security/audit.ts index 4312a168ff3c..d108db5eddd6 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -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; diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts index 6ebdf4d4de3e..cd43fec7aec4 100644 --- a/src/tui/gateway-chat.test.ts +++ b/src/tui/gateway-chat.test.ts @@ -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"); }); });