From 0125ce1f44b56f306d3177acb0e14b87050179a5 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:41:56 -0500 Subject: [PATCH] Gateway: fail closed unresolved local auth SecretRefs (#42672) * Gateway: fail closed unresolved local auth SecretRefs * Docs: align node-host gateway auth precedence * CI: resolve rebase breakages in checks lanes * Tests: isolate LOCAL_REMOTE_FALLBACK_TOKEN env state * Gateway: remove stale remote.enabled auth-surface semantics * Changelog: note gateway SecretRef fail-closed fix --- CHANGELOG.md | 2 +- .../OpenClawProtocol/GatewayModels.swift | 12 +++- .../OpenClawProtocol/GatewayModels.swift | 12 +++- docs/channels/discord.md | 2 +- docs/cli/acp.md | 2 +- docs/cli/index.md | 2 +- docs/cli/node.md | 3 +- docs/gateway/configuration-reference.md | 3 +- docs/gateway/remote.md | 10 +-- docs/gateway/secrets.md | 8 +-- docs/gateway/security/index.md | 4 +- docs/help/faq.md | 3 +- docs/nodes/index.md | 5 +- src/cron/isolated-agent/run.ts | 10 --- src/gateway/call.test.ts | 26 +++++++ src/gateway/connection-auth.test.ts | 70 +++++++++++++++++++ src/gateway/credential-planner.ts | 8 +-- src/gateway/credentials.test.ts | 52 ++++++++++++++ .../runtime-gateway-auth-surfaces.test.ts | 5 -- src/secrets/runtime-gateway-auth-surfaces.ts | 6 -- 20 files changed, 197 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 221120f09d4..83f22a3baaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,7 +84,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. +- 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. (#42672) Thanks @joshavant. - Sandbox/fs bridge: pin staged writes to verified parent directories so temporary write files cannot materialize outside the allowed mount before atomic replace. Thanks @tdjackey. - Commands/config writes: enforce `configWrites` against both the originating account and the targeted account scope for `/config` and config-backed `/allowlist` edits, blocking sibling-account mutations while preserving gateway `operator.admin` flows. Thanks @tdjackey for reporting. diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index cf69609e673..ea85e6c1511 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -1337,6 +1337,8 @@ public struct SessionsPatchParams: Codable, Sendable { public let model: AnyCodable? public let spawnedby: AnyCodable? public let spawndepth: AnyCodable? + public let subagentrole: AnyCodable? + public let subagentcontrolscope: AnyCodable? public let sendpolicy: AnyCodable? public let groupactivation: AnyCodable? @@ -1355,6 +1357,8 @@ public struct SessionsPatchParams: Codable, Sendable { model: AnyCodable?, spawnedby: AnyCodable?, spawndepth: AnyCodable?, + subagentrole: AnyCodable?, + subagentcontrolscope: AnyCodable?, sendpolicy: AnyCodable?, groupactivation: AnyCodable?) { @@ -1372,6 +1376,8 @@ public struct SessionsPatchParams: Codable, Sendable { self.model = model self.spawnedby = spawnedby self.spawndepth = spawndepth + self.subagentrole = subagentrole + self.subagentcontrolscope = subagentcontrolscope self.sendpolicy = sendpolicy self.groupactivation = groupactivation } @@ -1391,6 +1397,8 @@ public struct SessionsPatchParams: Codable, Sendable { case model case spawnedby = "spawnedBy" case spawndepth = "spawnDepth" + case subagentrole = "subagentRole" + case subagentcontrolscope = "subagentControlScope" case sendpolicy = "sendPolicy" case groupactivation = "groupActivation" } @@ -3046,7 +3054,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable { public struct ExecApprovalRequestParams: Codable, Sendable { public let id: String? - public let command: String + public let command: String? public let commandargv: [String]? public let systemrunplan: [String: AnyCodable]? public let env: [String: AnyCodable]? @@ -3067,7 +3075,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public init( id: String?, - command: String, + command: String?, commandargv: [String]?, systemrunplan: [String: AnyCodable]?, env: [String: AnyCodable]?, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index cf69609e673..ea85e6c1511 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1337,6 +1337,8 @@ public struct SessionsPatchParams: Codable, Sendable { public let model: AnyCodable? public let spawnedby: AnyCodable? public let spawndepth: AnyCodable? + public let subagentrole: AnyCodable? + public let subagentcontrolscope: AnyCodable? public let sendpolicy: AnyCodable? public let groupactivation: AnyCodable? @@ -1355,6 +1357,8 @@ public struct SessionsPatchParams: Codable, Sendable { model: AnyCodable?, spawnedby: AnyCodable?, spawndepth: AnyCodable?, + subagentrole: AnyCodable?, + subagentcontrolscope: AnyCodable?, sendpolicy: AnyCodable?, groupactivation: AnyCodable?) { @@ -1372,6 +1376,8 @@ public struct SessionsPatchParams: Codable, Sendable { self.model = model self.spawnedby = spawnedby self.spawndepth = spawndepth + self.subagentrole = subagentrole + self.subagentcontrolscope = subagentcontrolscope self.sendpolicy = sendpolicy self.groupactivation = groupactivation } @@ -1391,6 +1397,8 @@ public struct SessionsPatchParams: Codable, Sendable { case model case spawnedby = "spawnedBy" case spawndepth = "spawnDepth" + case subagentrole = "subagentRole" + case subagentcontrolscope = "subagentControlScope" case sendpolicy = "sendPolicy" case groupactivation = "groupActivation" } @@ -3046,7 +3054,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable { public struct ExecApprovalRequestParams: Codable, Sendable { public let id: String? - public let command: String + public let command: String? public let commandargv: [String]? public let systemrunplan: [String: AnyCodable]? public let env: [String: AnyCodable]? @@ -3067,7 +3075,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable { public init( id: String?, - command: String, + command: String?, commandargv: [String]?, systemrunplan: [String: AnyCodable]?, env: [String: AnyCodable]?, diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 48a8a03f59e..e179417e9b8 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -946,7 +946,7 @@ Default slash command settings: Gateway auth for this handler uses the same shared credential resolution contract as other Gateway clients: - env-first local auth (`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` then `gateway.auth.*`) - - in local mode, `gateway.remote.*` can be used as fallback when `gateway.auth.*` is unset + - in local mode, `gateway.remote.*` can be used as fallback only when `gateway.auth.*` is unset; configured-but-unresolved local SecretRefs fail closed - remote-mode support via `gateway.remote.*` when applicable - URL overrides are override-safe: CLI overrides do not reuse implicit credentials, and env overrides use env credentials only diff --git a/docs/cli/acp.md b/docs/cli/acp.md index 152770e6d86..9e239fc8bdf 100644 --- a/docs/cli/acp.md +++ b/docs/cli/acp.md @@ -273,7 +273,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_*`) -> `gateway.auth.*` -> `gateway.remote.*` fallback when `gateway.auth.*` is unset + - local mode: env (`OPENCLAW_GATEWAY_*`) -> `gateway.auth.*` -> `gateway.remote.*` fallback only when `gateway.auth.*` is unset (configured-but-unresolved local SecretRefs fail closed) - 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) - ACP runtime backend child processes receive `OPENCLAW_SHELL=acp`, which can be used for context-specific shell/profile rules. diff --git a/docs/cli/index.md b/docs/cli/index.md index fdee80038c0..fb68727e44b 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -1018,7 +1018,7 @@ Subcommands: Auth notes: -- `node` resolves gateway auth from env/config (no `--token`/`--password` flags): `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`, then `gateway.auth.*`, with remote-mode support via `gateway.remote.*`. +- `node` resolves gateway auth from env/config (no `--token`/`--password` flags): `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`, then `gateway.auth.*`. In local mode, node host intentionally ignores `gateway.remote.*`; in `gateway.mode=remote`, `gateway.remote.*` participates per remote precedence rules. - Legacy `CLAWDBOT_GATEWAY_*` env vars are intentionally ignored for node-host auth resolution. ## Nodes diff --git a/docs/cli/node.md b/docs/cli/node.md index 95f0936065e..baf8c3cd45e 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -64,7 +64,8 @@ Options: - `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` are checked first. - Then local config fallback: `gateway.auth.token` / `gateway.auth.password`. -- In local mode, `gateway.remote.token` / `gateway.remote.password` are also eligible as fallback when `gateway.auth.*` is unset. +- In local mode, node host intentionally does not inherit `gateway.remote.token` / `gateway.remote.password`. +- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, node auth resolution fails closed (no remote fallback masking). - In `gateway.mode=remote`, remote client fields (`gateway.remote.token` / `gateway.remote.password`) are also eligible per remote precedence rules. - Legacy `CLAWDBOT_GATEWAY_*` env vars are ignored for node host auth resolution. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index ae958788e2f..6922234fd2a 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2470,7 +2470,8 @@ See [Plugins](/tools/plugin). - `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`. - `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`: client-side break-glass override that allows plaintext `ws://` to trusted private-network IPs; default remains loopback-only for plaintext. - `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves. -- Local gateway call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset. +- Local gateway call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset. +- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking). - `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control. - `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior. - `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list). diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index a9aadc49dd1..dcbae985b74 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -103,18 +103,19 @@ When the gateway is loopback-only, keep the URL at `ws://127.0.0.1:18789` and op ## Credential precedence -Gateway credential resolution follows one shared contract across call/probe/status paths, Discord exec-approval monitoring, and node-host connections: +Gateway credential resolution follows one shared contract across call/probe/status paths and Discord exec-approval monitoring. Node-host uses the same base contract with one local-mode exception (it intentionally ignores `gateway.remote.*`): - Explicit credentials (`--token`, `--password`, or tool `gatewayToken`) always win on call paths that accept explicit auth. - URL override safety: - CLI URL overrides (`--url`) never reuse implicit config/env credentials. - Env URL overrides (`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` - - password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password` + - token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` -> `gateway.remote.token` (remote fallback applies only when local auth token input is unset) + - password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password` (remote fallback applies only when local auth password input 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. - Remote probe/status token checks are strict by default: they use `gateway.remote.token` only (no local token fallback) when targeting remote mode. - Legacy `CLAWDBOT_GATEWAY_*` env vars are only used by compatibility call paths; probe/status/auth resolution uses `OPENCLAW_GATEWAY_*` only. @@ -140,7 +141,8 @@ Short version: **keep the Gateway loopback-only** unless you’re sure you need set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass. - **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use auth tokens/passwords. - `gateway.remote.token` / `.password` are client credential sources. They do **not** configure server auth by themselves. -- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset. +- Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset. +- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking). - `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`. - **Tailscale Serve** can authenticate Control UI/WebSocket traffic via identity headers when `gateway.auth.allowTailscale: true`; HTTP API endpoints still diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 76b89a0f28a..93cd508d4f1 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -41,13 +41,13 @@ Examples of inactive surfaces: - Web search provider-specific keys that are not selected by `tools.web.search.provider`. In auto mode (provider unset), keys are consulted by precedence for provider auto-detection until one resolves. After selection, non-selected provider keys are treated as inactive until selected. -- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active (when `gateway.remote.enabled` is not `false`) if one of these is true: +- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active if one of these is true: - `gateway.mode=remote` - `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. + - 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` (or `CLAWDBOT_GATEWAY_TOKEN`) is set, because env token input wins for that runtime. ## Gateway auth surface diagnostics diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index f4cf08b73c6..3084adf82ad 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -754,8 +754,10 @@ Doctor can generate one for you: `openclaw doctor --generate-gateway-token`. Note: `gateway.remote.token` / `.password` are client credential sources. They do **not** protect local WS access by themselves. -Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` +Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset. +If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via +SecretRef and unresolved, resolution fails closed (no remote fallback masking). Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`. Plaintext `ws://` is loopback-only by default. For trusted private-network paths, set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass. diff --git a/docs/help/faq.md b/docs/help/faq.md index a1d8724e125..8b738b60fc2 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1452,7 +1452,8 @@ Non-loopback binds **require auth**. Configure `gateway.auth.mode` + `gateway.au Notes: - `gateway.remote.token` / `.password` do **not** enable local gateway auth by themselves. -- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset. +- Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset. +- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking). - The Control UI authenticates via `connect.params.auth.token` (stored in app/UI settings). Avoid putting tokens in URLs. ### Why do I need a token on localhost now diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 69bdeb2c4c9..7c087162c46 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -92,7 +92,10 @@ Notes: - `openclaw node run` supports token or password auth. - Env vars are preferred: `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`. -- Config fallback is `gateway.auth.token` / `gateway.auth.password`; in remote mode, `gateway.remote.token` / `gateway.remote.password` are also eligible. +- Config fallback is `gateway.auth.token` / `gateway.auth.password`. +- In local mode, node host intentionally ignores `gateway.remote.token` / `gateway.remote.password`. +- In remote mode, `gateway.remote.token` / `gateway.remote.password` are eligible per remote precedence rules. +- If active local `gateway.auth.*` SecretRefs are configured but unresolved, node-host auth fails closed. - Legacy `CLAWDBOT_GATEWAY_*` env vars are intentionally ignored by node-host auth resolution. ### Start a node host (service) diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 4db6b88b57f..4c7a5c87fe2 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -198,16 +198,6 @@ function appendCronDeliveryInstruction(params: { return `${params.commandBody}\n\nReturn your summary as plain text; it will be delivered automatically. If the task explicitly calls for messaging a specific external recipient, note who/where it should go instead of sending it yourself.`.trim(); } -function resolveCronEmbeddedAgentLane(lane?: string) { - const trimmed = lane?.trim(); - // Cron jobs already execute inside the cron command lane. Reusing that same - // lane for the nested embedded-agent run deadlocks: the outer cron task holds - // the lane while the inner run waits to reacquire it. - if (!trimmed || trimmed === "cron") { - return CommandLane.Nested; - } - return trimmed; -} export async function runCronIsolatedAgentTurn(params: { cfg: OpenClawConfig; deps: CliDeps; diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 10fc52441d1..87590e58d49 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -655,6 +655,7 @@ describe("callGateway password resolution", () => { envSnapshot = captureEnv([ "OPENCLAW_GATEWAY_PASSWORD", "OPENCLAW_GATEWAY_TOKEN", + "LOCAL_REMOTE_FALLBACK_TOKEN", "LOCAL_REF_PASSWORD", "REMOTE_REF_TOKEN", "REMOTE_REF_PASSWORD", @@ -662,6 +663,7 @@ describe("callGateway password resolution", () => { resetGatewayCallMocks(); delete process.env.OPENCLAW_GATEWAY_PASSWORD; delete process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.LOCAL_REMOTE_FALLBACK_TOKEN; delete process.env.LOCAL_REF_PASSWORD; delete process.env.REMOTE_REF_TOKEN; delete process.env.REMOTE_REF_PASSWORD; @@ -813,6 +815,30 @@ describe("callGateway password resolution", () => { expect(lastClientOptions?.password).toBe("resolved-local-fallback-password"); // pragma: allowlist secret }); + it("fails closed when unresolved local token SecretRef would otherwise fall back to remote token", async () => { + process.env.LOCAL_REMOTE_FALLBACK_TOKEN = "resolved-local-remote-fallback-token"; + loadConfig.mockReturnValue({ + gateway: { + mode: "local", + bind: "loopback", + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_LOCAL_REF_TOKEN" }, + }, + remote: { + token: { source: "env", provider: "default", id: "LOCAL_REMOTE_FALLBACK_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as unknown as OpenClawConfig); + + await expect(callGateway({ method: "health" })).rejects.toThrow("gateway.auth.token"); + }); + it.each(["none", "trusted-proxy"] as const)( "ignores unresolved local password ref when auth mode is %s", async (mode) => { diff --git a/src/gateway/connection-auth.test.ts b/src/gateway/connection-auth.test.ts index c64485da018..036b9ff82dc 100644 --- a/src/gateway/connection-auth.test.ts +++ b/src/gateway/connection-auth.test.ts @@ -416,4 +416,74 @@ describe("resolveGatewayConnectionAuth", () => { }), ).toThrow("gateway.auth.password"); }); + + it("fails closed when local token SecretRef is unresolved and remote token fallback exists", async () => { + const config = 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" }, + }, + }, + }); + + await expect( + resolveGatewayConnectionAuth({ + config, + env: {} as NodeJS.ProcessEnv, + includeLegacyEnv: false, + }), + ).rejects.toThrow("gateway.auth.token"); + expect(() => + resolveGatewayConnectionAuthFromConfig({ + cfg: config, + env: {} as NodeJS.ProcessEnv, + includeLegacyEnv: false, + }), + ).toThrow("gateway.auth.token"); + }); + + it("fails closed when local password SecretRef is unresolved and remote password fallback exists", async () => { + const config = 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" }, + }, + }, + }); + + await expect( + resolveGatewayConnectionAuth({ + config, + env: {} as NodeJS.ProcessEnv, + includeLegacyEnv: false, + }), + ).rejects.toThrow("gateway.auth.password"); + expect(() => + resolveGatewayConnectionAuthFromConfig({ + cfg: config, + env: {} as NodeJS.ProcessEnv, + includeLegacyEnv: false, + }), + ).toThrow("gateway.auth.password"); + }); }); diff --git a/src/gateway/credential-planner.ts b/src/gateway/credential-planner.ts index ba3a64e1642..f486e352a8f 100644 --- a/src/gateway/credential-planner.ts +++ b/src/gateway/credential-planner.ts @@ -33,7 +33,6 @@ export type GatewayCredentialPlan = { remoteMode: boolean; remoteUrlConfigured: boolean; tailscaleRemoteExposure: boolean; - remoteEnabled: boolean; remoteConfiguredSurface: boolean; remoteTokenFallbackActive: boolean; remoteTokenActive: boolean; @@ -187,7 +186,6 @@ export function createGatewayCredentialPlan(params: { const remoteUrlConfigured = Boolean(trimToUndefined(remote?.url)); const tailscaleRemoteExposure = gateway?.tailscale?.mode === "serve" || gateway?.tailscale?.mode === "funnel"; - const remoteEnabled = remote?.enabled !== false; const remoteConfiguredSurface = remoteMode || remoteUrlConfigured || tailscaleRemoteExposure; const remoteTokenFallbackActive = localTokenCanWin && !envToken && !localToken.configured; const remotePasswordFallbackActive = !envPassword && !localPassword.configured && passwordCanWin; @@ -209,12 +207,10 @@ export function createGatewayCredentialPlan(params: { remoteMode, remoteUrlConfigured, tailscaleRemoteExposure, - remoteEnabled, remoteConfiguredSurface, remoteTokenFallbackActive, - remoteTokenActive: remoteEnabled && (remoteConfiguredSurface || remoteTokenFallbackActive), + remoteTokenActive: remoteConfiguredSurface || remoteTokenFallbackActive, remotePasswordFallbackActive, - remotePasswordActive: - remoteEnabled && (remoteConfiguredSurface || remotePasswordFallbackActive), + remotePasswordActive: remoteConfiguredSurface || remotePasswordFallbackActive, }; } diff --git a/src/gateway/credentials.test.ts b/src/gateway/credentials.test.ts index 2ec45139d09..a3f3a8b9f45 100644 --- a/src/gateway/credentials.test.ts +++ b/src/gateway/credentials.test.ts @@ -158,6 +158,58 @@ describe("resolveGatewayCredentialsFromConfig", () => { }); }); + it("fails closed when local token SecretRef is unresolved and remote token fallback exists", () => { + 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("fails closed when local password SecretRef is unresolved and remote password fallback exists", () => { + 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("throws when local password auth relies on an unresolved SecretRef", () => { expect(() => resolveGatewayCredentialsFromConfig({ diff --git a/src/secrets/runtime-gateway-auth-surfaces.test.ts b/src/secrets/runtime-gateway-auth-surfaces.test.ts index bc461f23813..efedd53b360 100644 --- a/src/secrets/runtime-gateway-auth-surfaces.test.ts +++ b/src/secrets/runtime-gateway-auth-surfaces.test.ts @@ -111,7 +111,6 @@ describe("evaluateGatewayAuthSurfaceStates", () => { gateway: { mode: "local", remote: { - enabled: true, token: envRef("GW_REMOTE_TOKEN"), }, }, @@ -131,7 +130,6 @@ describe("evaluateGatewayAuthSurfaceStates", () => { mode: "password", }, remote: { - enabled: true, token: envRef("GW_REMOTE_TOKEN"), }, }, @@ -153,7 +151,6 @@ describe("evaluateGatewayAuthSurfaceStates", () => { token: envRef("GW_AUTH_TOKEN"), }, remote: { - enabled: true, token: envRef("GW_REMOTE_TOKEN"), }, }, @@ -170,7 +167,6 @@ describe("evaluateGatewayAuthSurfaceStates", () => { const states = evaluate({ gateway: { remote: { - enabled: true, url: "wss://gateway.example.com", password: envRef("GW_REMOTE_PASSWORD"), }, @@ -190,7 +186,6 @@ describe("evaluateGatewayAuthSurfaceStates", () => { mode: "token", }, remote: { - enabled: true, password: envRef("GW_REMOTE_PASSWORD"), }, }, diff --git a/src/secrets/runtime-gateway-auth-surfaces.ts b/src/secrets/runtime-gateway-auth-surfaces.ts index c5abc61b2a8..9a965584849 100644 --- a/src/secrets/runtime-gateway-auth-surfaces.ts +++ b/src/secrets/runtime-gateway-auth-surfaces.ts @@ -166,9 +166,6 @@ export function evaluateGatewayAuthSurfaceStates(params: { if (!remote) { return "gateway.remote is not configured."; } - if (!plan.remoteEnabled) { - return "gateway.remote.enabled is false."; - } if (plan.remoteConfiguredSurface) { return `remote surface is active: ${remoteSurfaceReason}.`; } @@ -191,9 +188,6 @@ export function evaluateGatewayAuthSurfaceStates(params: { if (!remote) { return "gateway.remote is not configured."; } - if (!plan.remoteEnabled) { - return "gateway.remote.enabled is false."; - } if (plan.remoteConfiguredSurface) { return `remote surface is active: ${remoteSurfaceReason}.`; }