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:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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]?,
|
||||||
|
|||||||
@@ -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]?,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 you’re 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}.`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user