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
This commit is contained in:
Josh Avant
2026-03-10 21:41:56 -05:00
committed by GitHub
parent a52104c235
commit 0125ce1f44
20 changed files with 197 additions and 48 deletions

View File

@@ -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. - 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. - 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. - 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. - 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. - 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.

View File

@@ -1337,6 +1337,8 @@ public struct SessionsPatchParams: Codable, Sendable {
public let model: AnyCodable? public let model: AnyCodable?
public let spawnedby: AnyCodable? public let spawnedby: AnyCodable?
public let spawndepth: AnyCodable? public let spawndepth: AnyCodable?
public let subagentrole: AnyCodable?
public let subagentcontrolscope: AnyCodable?
public let sendpolicy: AnyCodable? public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable? public let groupactivation: AnyCodable?
@@ -1355,6 +1357,8 @@ public struct SessionsPatchParams: Codable, Sendable {
model: AnyCodable?, model: AnyCodable?,
spawnedby: AnyCodable?, spawnedby: AnyCodable?,
spawndepth: AnyCodable?, spawndepth: AnyCodable?,
subagentrole: AnyCodable?,
subagentcontrolscope: AnyCodable?,
sendpolicy: AnyCodable?, sendpolicy: AnyCodable?,
groupactivation: AnyCodable?) groupactivation: AnyCodable?)
{ {
@@ -1372,6 +1376,8 @@ public struct SessionsPatchParams: Codable, Sendable {
self.model = model self.model = model
self.spawnedby = spawnedby self.spawnedby = spawnedby
self.spawndepth = spawndepth self.spawndepth = spawndepth
self.subagentrole = subagentrole
self.subagentcontrolscope = subagentcontrolscope
self.sendpolicy = sendpolicy self.sendpolicy = sendpolicy
self.groupactivation = groupactivation self.groupactivation = groupactivation
} }
@@ -1391,6 +1397,8 @@ public struct SessionsPatchParams: Codable, Sendable {
case model case model
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
case spawndepth = "spawnDepth" case spawndepth = "spawnDepth"
case subagentrole = "subagentRole"
case subagentcontrolscope = "subagentControlScope"
case sendpolicy = "sendPolicy" case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation" case groupactivation = "groupActivation"
} }
@@ -3046,7 +3054,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
public struct ExecApprovalRequestParams: Codable, Sendable { public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String? public let id: String?
public let command: String public let command: String?
public let commandargv: [String]? public let commandargv: [String]?
public let systemrunplan: [String: AnyCodable]? public let systemrunplan: [String: AnyCodable]?
public let env: [String: AnyCodable]? public let env: [String: AnyCodable]?
@@ -3067,7 +3075,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public init( public init(
id: String?, id: String?,
command: String, command: String?,
commandargv: [String]?, commandargv: [String]?,
systemrunplan: [String: AnyCodable]?, systemrunplan: [String: AnyCodable]?,
env: [String: AnyCodable]?, env: [String: AnyCodable]?,

View File

@@ -1337,6 +1337,8 @@ public struct SessionsPatchParams: Codable, Sendable {
public let model: AnyCodable? public let model: AnyCodable?
public let spawnedby: AnyCodable? public let spawnedby: AnyCodable?
public let spawndepth: AnyCodable? public let spawndepth: AnyCodable?
public let subagentrole: AnyCodable?
public let subagentcontrolscope: AnyCodable?
public let sendpolicy: AnyCodable? public let sendpolicy: AnyCodable?
public let groupactivation: AnyCodable? public let groupactivation: AnyCodable?
@@ -1355,6 +1357,8 @@ public struct SessionsPatchParams: Codable, Sendable {
model: AnyCodable?, model: AnyCodable?,
spawnedby: AnyCodable?, spawnedby: AnyCodable?,
spawndepth: AnyCodable?, spawndepth: AnyCodable?,
subagentrole: AnyCodable?,
subagentcontrolscope: AnyCodable?,
sendpolicy: AnyCodable?, sendpolicy: AnyCodable?,
groupactivation: AnyCodable?) groupactivation: AnyCodable?)
{ {
@@ -1372,6 +1376,8 @@ public struct SessionsPatchParams: Codable, Sendable {
self.model = model self.model = model
self.spawnedby = spawnedby self.spawnedby = spawnedby
self.spawndepth = spawndepth self.spawndepth = spawndepth
self.subagentrole = subagentrole
self.subagentcontrolscope = subagentcontrolscope
self.sendpolicy = sendpolicy self.sendpolicy = sendpolicy
self.groupactivation = groupactivation self.groupactivation = groupactivation
} }
@@ -1391,6 +1397,8 @@ public struct SessionsPatchParams: Codable, Sendable {
case model case model
case spawnedby = "spawnedBy" case spawnedby = "spawnedBy"
case spawndepth = "spawnDepth" case spawndepth = "spawnDepth"
case subagentrole = "subagentRole"
case subagentcontrolscope = "subagentControlScope"
case sendpolicy = "sendPolicy" case sendpolicy = "sendPolicy"
case groupactivation = "groupActivation" case groupactivation = "groupActivation"
} }
@@ -3046,7 +3054,7 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
public struct ExecApprovalRequestParams: Codable, Sendable { public struct ExecApprovalRequestParams: Codable, Sendable {
public let id: String? public let id: String?
public let command: String public let command: String?
public let commandargv: [String]? public let commandargv: [String]?
public let systemrunplan: [String: AnyCodable]? public let systemrunplan: [String: AnyCodable]?
public let env: [String: AnyCodable]? public let env: [String: AnyCodable]?
@@ -3067,7 +3075,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public init( public init(
id: String?, id: String?,
command: String, command: String?,
commandargv: [String]?, commandargv: [String]?,
systemrunplan: [String: AnyCodable]?, systemrunplan: [String: AnyCodable]?,
env: [String: AnyCodable]?, env: [String: AnyCodable]?,

View File

@@ -946,7 +946,7 @@ Default slash command settings:
Gateway auth for this handler uses the same shared credential resolution contract as other Gateway clients: 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.*`) - 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 - 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 - URL overrides are override-safe: CLI overrides do not reuse implicit credentials, and env overrides use env credentials only

View File

@@ -273,7 +273,7 @@ Security note:
- `--token` and `--password` can be visible in local process listings on some systems. - `--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`). - 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: - 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 - 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) - `--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. - ACP runtime backend child processes receive `OPENCLAW_SHELL=acp`, which can be used for context-specific shell/profile rules.

View File

@@ -1018,7 +1018,7 @@ Subcommands:
Auth notes: 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. - Legacy `CLAWDBOT_GATEWAY_*` env vars are intentionally ignored for node-host auth resolution.
## Nodes ## Nodes

View File

@@ -64,7 +64,8 @@ Options:
- `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` are checked first. - `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD` are checked first.
- Then local config fallback: `gateway.auth.token` / `gateway.auth.password`. - 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. - 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. - Legacy `CLAWDBOT_GATEWAY_*` env vars are ignored for node host auth resolution.

View File

@@ -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://`. - `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. - `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. - `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. - `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. - `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). - `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list).

View File

@@ -103,18 +103,19 @@ When the gateway is loopback-only, keep the URL at `ws://127.0.0.1:18789` and op
## Credential precedence ## 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. - Explicit credentials (`--token`, `--password`, or tool `gatewayToken`) always win on call paths that accept explicit auth.
- URL override safety: - URL override safety:
- CLI URL overrides (`--url`) never reuse implicit config/env credentials. - 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`). - Env URL overrides (`OPENCLAW_GATEWAY_URL`) may use env credentials only (`OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`).
- Local mode defaults: - Local mode defaults:
- token: `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` -> `gateway.remote.token` - 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` - password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.auth.password` -> `gateway.remote.password` (remote fallback applies only when local auth password input is unset)
- Remote mode defaults: - Remote mode defaults:
- token: `gateway.remote.token` -> `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token` - token: `gateway.remote.token` -> `OPENCLAW_GATEWAY_TOKEN` -> `gateway.auth.token`
- password: `OPENCLAW_GATEWAY_PASSWORD` -> `gateway.remote.password` -> `gateway.auth.password` - 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. - 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. - 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 youre sure you need
set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass. 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. - **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. - `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://`. - `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
- **Tailscale Serve** can authenticate Control UI/WebSocket traffic via identity - **Tailscale Serve** can authenticate Control UI/WebSocket traffic via identity
headers when `gateway.auth.allowTailscale: true`; HTTP API endpoints still headers when `gateway.auth.allowTailscale: true`; HTTP API endpoints still

View File

@@ -41,13 +41,13 @@ Examples of inactive surfaces:
- Web search provider-specific keys that are not selected by `tools.web.search.provider`. - 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. 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. 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.mode=remote`
- `gateway.remote.url` is configured - `gateway.remote.url` is configured
- `gateway.tailscale.mode` is `serve` or `funnel` - `gateway.tailscale.mode` is `serve` or `funnel`
In local mode without those remote surfaces: - 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.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.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.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 ## Gateway auth surface diagnostics

View File

@@ -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 Note: `gateway.remote.token` / `.password` are client credential sources. They
do **not** protect local WS access by themselves. 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. 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://`. Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
Plaintext `ws://` is loopback-only by default. For trusted private-network 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. paths, set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass.

View File

@@ -1452,7 +1452,8 @@ Non-loopback binds **require auth**. Configure `gateway.auth.mode` + `gateway.au
Notes: Notes:
- `gateway.remote.token` / `.password` do **not** enable local gateway auth by themselves. - `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. - 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 ### Why do I need a token on localhost now

View File

@@ -92,7 +92,10 @@ Notes:
- `openclaw node run` supports token or password auth. - `openclaw node run` supports token or password auth.
- Env vars are preferred: `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`. - 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. - Legacy `CLAWDBOT_GATEWAY_*` env vars are intentionally ignored by node-host auth resolution.
### Start a node host (service) ### Start a node host (service)

View File

@@ -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(); 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: { export async function runCronIsolatedAgentTurn(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
deps: CliDeps; deps: CliDeps;

View File

@@ -655,6 +655,7 @@ describe("callGateway password resolution", () => {
envSnapshot = captureEnv([ envSnapshot = captureEnv([
"OPENCLAW_GATEWAY_PASSWORD", "OPENCLAW_GATEWAY_PASSWORD",
"OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_TOKEN",
"LOCAL_REMOTE_FALLBACK_TOKEN",
"LOCAL_REF_PASSWORD", "LOCAL_REF_PASSWORD",
"REMOTE_REF_TOKEN", "REMOTE_REF_TOKEN",
"REMOTE_REF_PASSWORD", "REMOTE_REF_PASSWORD",
@@ -662,6 +663,7 @@ describe("callGateway password resolution", () => {
resetGatewayCallMocks(); resetGatewayCallMocks();
delete process.env.OPENCLAW_GATEWAY_PASSWORD; delete process.env.OPENCLAW_GATEWAY_PASSWORD;
delete process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.LOCAL_REMOTE_FALLBACK_TOKEN;
delete process.env.LOCAL_REF_PASSWORD; delete process.env.LOCAL_REF_PASSWORD;
delete process.env.REMOTE_REF_TOKEN; delete process.env.REMOTE_REF_TOKEN;
delete process.env.REMOTE_REF_PASSWORD; 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 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)( it.each(["none", "trusted-proxy"] as const)(
"ignores unresolved local password ref when auth mode is %s", "ignores unresolved local password ref when auth mode is %s",
async (mode) => { async (mode) => {

View File

@@ -416,4 +416,74 @@ describe("resolveGatewayConnectionAuth", () => {
}), }),
).toThrow("gateway.auth.password"); ).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");
});
}); });

View File

@@ -33,7 +33,6 @@ export type GatewayCredentialPlan = {
remoteMode: boolean; remoteMode: boolean;
remoteUrlConfigured: boolean; remoteUrlConfigured: boolean;
tailscaleRemoteExposure: boolean; tailscaleRemoteExposure: boolean;
remoteEnabled: boolean;
remoteConfiguredSurface: boolean; remoteConfiguredSurface: boolean;
remoteTokenFallbackActive: boolean; remoteTokenFallbackActive: boolean;
remoteTokenActive: boolean; remoteTokenActive: boolean;
@@ -187,7 +186,6 @@ export function createGatewayCredentialPlan(params: {
const remoteUrlConfigured = Boolean(trimToUndefined(remote?.url)); const remoteUrlConfigured = Boolean(trimToUndefined(remote?.url));
const tailscaleRemoteExposure = const tailscaleRemoteExposure =
gateway?.tailscale?.mode === "serve" || gateway?.tailscale?.mode === "funnel"; gateway?.tailscale?.mode === "serve" || gateway?.tailscale?.mode === "funnel";
const remoteEnabled = remote?.enabled !== false;
const remoteConfiguredSurface = remoteMode || remoteUrlConfigured || tailscaleRemoteExposure; const remoteConfiguredSurface = remoteMode || remoteUrlConfigured || tailscaleRemoteExposure;
const remoteTokenFallbackActive = localTokenCanWin && !envToken && !localToken.configured; const remoteTokenFallbackActive = localTokenCanWin && !envToken && !localToken.configured;
const remotePasswordFallbackActive = !envPassword && !localPassword.configured && passwordCanWin; const remotePasswordFallbackActive = !envPassword && !localPassword.configured && passwordCanWin;
@@ -209,12 +207,10 @@ export function createGatewayCredentialPlan(params: {
remoteMode, remoteMode,
remoteUrlConfigured, remoteUrlConfigured,
tailscaleRemoteExposure, tailscaleRemoteExposure,
remoteEnabled,
remoteConfiguredSurface, remoteConfiguredSurface,
remoteTokenFallbackActive, remoteTokenFallbackActive,
remoteTokenActive: remoteEnabled && (remoteConfiguredSurface || remoteTokenFallbackActive), remoteTokenActive: remoteConfiguredSurface || remoteTokenFallbackActive,
remotePasswordFallbackActive, remotePasswordFallbackActive,
remotePasswordActive: remotePasswordActive: remoteConfiguredSurface || remotePasswordFallbackActive,
remoteEnabled && (remoteConfiguredSurface || remotePasswordFallbackActive),
}; };
} }

View File

@@ -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", () => { it("throws when local password auth relies on an unresolved SecretRef", () => {
expect(() => expect(() =>
resolveGatewayCredentialsFromConfig({ resolveGatewayCredentialsFromConfig({

View File

@@ -111,7 +111,6 @@ describe("evaluateGatewayAuthSurfaceStates", () => {
gateway: { gateway: {
mode: "local", mode: "local",
remote: { remote: {
enabled: true,
token: envRef("GW_REMOTE_TOKEN"), token: envRef("GW_REMOTE_TOKEN"),
}, },
}, },
@@ -131,7 +130,6 @@ describe("evaluateGatewayAuthSurfaceStates", () => {
mode: "password", mode: "password",
}, },
remote: { remote: {
enabled: true,
token: envRef("GW_REMOTE_TOKEN"), token: envRef("GW_REMOTE_TOKEN"),
}, },
}, },
@@ -153,7 +151,6 @@ describe("evaluateGatewayAuthSurfaceStates", () => {
token: envRef("GW_AUTH_TOKEN"), token: envRef("GW_AUTH_TOKEN"),
}, },
remote: { remote: {
enabled: true,
token: envRef("GW_REMOTE_TOKEN"), token: envRef("GW_REMOTE_TOKEN"),
}, },
}, },
@@ -170,7 +167,6 @@ describe("evaluateGatewayAuthSurfaceStates", () => {
const states = evaluate({ const states = evaluate({
gateway: { gateway: {
remote: { remote: {
enabled: true,
url: "wss://gateway.example.com", url: "wss://gateway.example.com",
password: envRef("GW_REMOTE_PASSWORD"), password: envRef("GW_REMOTE_PASSWORD"),
}, },
@@ -190,7 +186,6 @@ describe("evaluateGatewayAuthSurfaceStates", () => {
mode: "token", mode: "token",
}, },
remote: { remote: {
enabled: true,
password: envRef("GW_REMOTE_PASSWORD"), password: envRef("GW_REMOTE_PASSWORD"),
}, },
}, },

View File

@@ -166,9 +166,6 @@ export function evaluateGatewayAuthSurfaceStates(params: {
if (!remote) { if (!remote) {
return "gateway.remote is not configured."; return "gateway.remote is not configured.";
} }
if (!plan.remoteEnabled) {
return "gateway.remote.enabled is false.";
}
if (plan.remoteConfiguredSurface) { if (plan.remoteConfiguredSurface) {
return `remote surface is active: ${remoteSurfaceReason}.`; return `remote surface is active: ${remoteSurfaceReason}.`;
} }
@@ -191,9 +188,6 @@ export function evaluateGatewayAuthSurfaceStates(params: {
if (!remote) { if (!remote) {
return "gateway.remote is not configured."; return "gateway.remote is not configured.";
} }
if (!plan.remoteEnabled) {
return "gateway.remote.enabled is false.";
}
if (plan.remoteConfiguredSurface) { if (plan.remoteConfiguredSurface) {
return `remote surface is active: ${remoteSurfaceReason}.`; return `remote surface is active: ${remoteSurfaceReason}.`;
} }