Skip to content
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ All notable changes to this project are tracked in this file.

### Security

- Required the `operator` role wherever a request runs the agent or mutates state, matching `POST /api/integration/messages`. Previously any authenticated identity, including viewer account tokens, viewer browser sessions, and OIDC users without an operator role claim, could run the agent with tools through these surfaces. New operator accounts default to `viewer`, so grant `operator` to accounts used for Companion, CLI/TUI chat, and API clients.
- `/ws`: closed with code 1008, which web chat reports as an authorization failure.
- `POST /v1/chat/completions` and `POST /v1/responses`: 403 with an OpenAI-style `permission_error` body.
- A2A execution paths: 403. Discovery stays public.
- `POST /apps/chat`: 403.
- `tools/call` through the `/apps/mcp/{appId}` MCP App proxy: tool error result. Listing and reading App tools and resources stay available to any authenticated role.
- MCP `openclaw.send_message`, `openclaw.run_workflow`, and `openclaw.respond_workflow`: tool error result. Read-only MCP tools stay available to viewers.
- Each denial is logged under `OpenClaw.Gateway.Authorization` with the surface, account, and role, so admins can find accounts to promote.
- Migration aid: `OpenClaw:Security:AllowViewerAgentExecution=true` restores the previous behavior for authenticated identities below `operator`, logs each such request, and adds the `viewer_agent_execution_allowed` risk flag to `admin posture`. It is temporary and will be removed in the next release.
- Stopped trusting a loopback client IP on `/apps/health`, `/apps/chat`, and `/apps/mcp/{appId}`. Behind a same-host reverse proxy without `TrustForwardedHeaders`, every caller has a loopback IP, so these routes answered unauthenticated requests, including agent runs and MCP App tool calls, and ignored `AlwaysRequireAuth`. They now follow the gateway's bind-based rule: open only on a loopback-bound gateway without `AlwaysRequireAuth`.
- Added `OpenClawWebSocketClient.OnClosed`, raised with the gateway's close status and reason. Companion now marks itself disconnected and shows the reason instead of appearing connected after the gateway closes the socket.
- Bound tool-approval decisions to the original requester (`channelId` + `senderId`) for non-loopback/public binds.
- Kept `POST /tools/approve` as an explicit admin override path.
- Added WhatsApp official webhook signature validation support (`ValidateSignature`, `WebhookAppSecret`/`WebhookAppSecretRef`).
Expand Down
41 changes: 38 additions & 3 deletions docs/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Authentication configuration lives under the `OpenClaw.Security` node in `appset
|-------|------|---------|-------------|
| `AuthToken` | `string?` | `null` | Static bootstrap token. When `null`, bootstrap auth is disabled |
| `AlwaysRequireAuth` | `bool` | `false` | When `true`, even loopback-bound requests must carry valid credentials |
| `AllowViewerAgentExecution` | `bool` | `false` | **Temporary, to be removed in the next release.** When `true`, identities below `operator` can still run the agent (see [3.3](#33-role-required-for-agent-execution)). Each such request is logged and `admin posture` reports the risk |
| `AuthMode` | `string` | `"token"` | Authentication mode: `"token"` or `"oidc"` |
| `AllowQueryStringToken` | `bool` | `false` | Whether to accept tokens from the `?token=` query string parameter |
| `BrowserSessionIdleMinutes` | `int` | `60` | Idle timeout for browser admin sessions (minutes) |
Expand Down Expand Up @@ -130,7 +131,7 @@ Request enters

### 3.2 WebSocket Authentication Flow

WebSocket endpoints (`/ws`, `/ws/live`) use a two-phase authentication flow:
WebSocket endpoints (`/ws`, `/ws/live`) authenticate in Phase 1. `/ws` then applies the chat role check (Phase 2) and resolves the user ID (Phase 3):

**Phase 1: `TryValidateWebSocketRequest` → `IsAuthorizedRequest`**

Expand All @@ -148,7 +149,24 @@ WebSocket request (/ws)
└─ Passed ──→ Accept WebSocket connection
```

**Phase 2: `TryResolveAuthorizedUserIdForWebSocket`**
**Phase 2 (`/ws` only): `EndpointHelpers.CanExecuteAgent`**

Every `/ws` frame becomes agent input, so the connection needs the same `operator` role as `POST /api/integration/messages`. The role is resolved through `AuthorizeOperatorRequest`, the same chain the HTTP API uses:

```
WebSocket accepted (/ws)
│
├─ Role below operator, or identity not allowed by organization policy?
│ ── yes ──→ close 1008 (PolicyViolation) "Chat requires the operator role."
│
└─ Passed ──→ Phase 3
```

The connection is accepted and then closed, rather than rejected with 403 during the handshake. Browsers cannot read a failed handshake's status code, but web chat treats close code 1008 as an authorization failure and stops reconnecting.

This applies to OIDC identities too. A JWT without the configured `RoleClaim` resolves to `viewer` and cannot chat, so grant `operator` through the claim to users who should use web chat.

**Phase 3: `TryResolveAuthorizedUserIdForWebSocket`**

```
WebSocket connected
Expand All @@ -160,7 +178,24 @@ WebSocket connected
└─ Call AuthorizeOperatorRequest() ──→ extract AccountId as userId
```

### 3.3 `IsAuthorizedRequest` — Detailed Logic
### 3.3 Role Required for Agent Execution

`IsAuthorizedRequest` only establishes that a caller is authenticated. Surfaces that turn a request into agent input or another mutation also require the `operator` role, the same role as `POST /api/integration/messages`, through `EndpointHelpers.CanExecuteAgent`:

| Surface | Below operator |
|---------|----------------|
| `/ws` | Accepted, then closed with 1008 (PolicyViolation) |
| `POST /v1/chat/completions`, `POST /v1/responses` | 403 with an OpenAI-style `permission_error` body |
| A2A execution paths (discovery stays public) | 403 |
| `POST /apps/chat` | 403 |
| `/apps/mcp/{appId}` `tools/call` | Tool error result; listing and reading App tools and resources stay available |
| MCP `openclaw.send_message`, `openclaw.run_workflow`, `openclaw.respond_workflow` | Tool error result; read-only MCP tools stay available to viewers |

Bootstrap tokens and open loopback resolve to `admin` and are unaffected. New operator accounts default to `viewer`, so accounts used for Companion, CLI/TUI chat, or API clients need the `operator` role.

Each denial is logged as a warning under the `OpenClaw.Gateway.Authorization` category, naming the surface, auth mode, account, and role (never the credential), so admins can find the accounts to promote. To migrate without an outage, set `OpenClaw:Security:AllowViewerAgentExecution=true`, watch the log for the admitted accounts, grant them `operator`, then turn the setting off. The setting is temporary and will be removed in the next release.

### 3.4 `IsAuthorizedRequest` — Detailed Logic

```csharp
// Step 1: Loopback exemption
Expand Down
6 changes: 4 additions & 2 deletions docs/MCPAPP.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ OpenClaw.NET exposes a small gateway-facing host surface for browser-side MCP Ap
| Route | Purpose |
|-------|---------|
| `/apps/health` | Returns the selected MCP App id plus the gateway MCP endpoint the browser should connect to |
| `/apps/chat` | Streams chat-host SSE events (`session`, `text`, `tool`, `result`, `done`) into the existing `GatewayAppRuntime` |
| `/apps/mcp/{appId}` | Proxies MCP requests to the already connected `McpClient` for that App |
| `/apps/chat` | Streams chat-host SSE events (`session`, `text`, `tool`, `result`, `done`) into the existing `GatewayAppRuntime`. Requires the `operator` role |
| `/apps/mcp/{appId}` | Proxies MCP requests to the already connected `McpClient` for that App. `tools/call` requires the `operator` role; listing and reading stay available to any authenticated role |

The important detail is that browser UIs should connect to `/apps/mcp/{appId}`, not directly to the App's raw upstream MCP URL. That keeps browser-driven MCP calls and model-driven MCP calls on the same OpenClaw-managed session.

The `/apps/*` routes use the gateway's normal authentication. They are open without credentials only when the gateway is bound to loopback and `AlwaysRequireAuth` is off. Otherwise the browser host must send a token or browser session. A loopback client IP is not trusted on its own: behind a same-host reverse proxy every caller has one.

### Session Reuse Behavior

- `/apps/health` returns an `mcp` URL that points back to the gateway's own `/apps/mcp/{appId}` route.
Expand Down
4 changes: 2 additions & 2 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,14 @@ For the full A2A behavior and operator notes, see [a2a.md](a2a.md).
OpenClaw.NET now has three fixed operator roles:

- `viewer`: read-only dashboard, audit, setup status, observability, and export access
- `operator`: viewer permissions plus approvals, memory/profile/learning changes, automation execution, session promotion, and webhook replay
- `operator`: viewer permissions plus approvals, chat and other agent execution, memory/profile/learning changes, automation execution, session promotion, and webhook replay
- `admin`: operator permissions plus settings, plugins, provider policies, accounts, and organization policy

Recommended auth flow:

1. Use `OPENCLAW_AUTH_TOKEN` once on a non-loopback deployment to bootstrap the first operator account.
2. Sign into `/admin` with the operator account username and password.
3. Exchange credentials for an operator account token when setting up Companion, API clients, CLI automation, or websocket integrations.
3. Exchange credentials for an operator account token when setting up Companion, API clients, CLI automation, or websocket integrations. Clients that chat or run the agent need an account with the `operator` role; new accounts default to `viewer`.

Operator token exchange is available at `POST /auth/operator-token`.

Expand Down
2 changes: 1 addition & 1 deletion docs/a2a.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ With that configuration, the Agent Card advertises endpoints such as `https://ag

## Authentication

Discovery is public by default so standard A2A card resolvers can fetch the Agent Card. Execution endpoints continue to use the gateway authentication and IP rate limiting policy.
Discovery is public by default so standard A2A card resolvers can fetch the Agent Card. Execution endpoints continue to use the gateway authentication and IP rate limiting policy, and require the `operator` role.

For public deployments, configure gateway authentication before exposing the A2A execution paths.

Expand Down
4 changes: 2 additions & 2 deletions docs/workflow-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ MCP tools expose the same surface:
| Tool | Purpose |
| --- | --- |
| `openclaw.list_workflows` | List configured workflow backends. |
| `openclaw.run_workflow` | Start a workflow run. |
| `openclaw.run_workflow` | Start a workflow run. Requires the `operator` role. |
| `openclaw.get_workflow_run` | Read current status, events, pending inputs, and output. |
| `openclaw.respond_workflow` | Send a human or system response to a pending input port. |
| `openclaw.respond_workflow` | Send a human or system response to a pending input port. Requires the `operator` role. |

## Status Model

Expand Down
41 changes: 38 additions & 3 deletions docs/zh-CN/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ OpenClaw.NET Gateway 支持多层认证体系,涵盖静态令牌、OIDC/JWT Be
|------|------|--------|------|
| `AuthToken` | `string?` | `null` | 静态 Bootstrap 令牌。`null` 时禁用 Bootstrap 认证 |
| `AlwaysRequireAuth` | `bool` | `false` | `true` 时,即使是 loopback 绑定也需要认证 |
| `AllowViewerAgentExecution` | `bool` | `false` | **临时设置,将在下一个版本移除。** `true` 时,低于 `operator` 的身份仍可执行智能体(见 3.3 节)。每个此类请求都会记录日志,`admin posture` 也会报告该风险 |
| `AuthMode` | `string` | `"token"` | 认证模式:`"token"` 或 `"oidc"` |
| `AllowQueryStringToken` | `bool` | `false` | 是否允许从查询字符串 `?token=` 读取令牌 |
| `BrowserSessionIdleMinutes` | `int` | `60` | 浏览器会话空闲超时(分钟) |
Expand Down Expand Up @@ -130,7 +131,7 @@ HTTP API 端点使用 `AuthorizeOperatorRequest` 方法([EndpointHelpers.cs](.

### 3.2 WebSocket 认证流程

WebSocket 端点 (`/ws`, `/ws/live`) 使用两步认证流程:
WebSocket 端点 (`/ws`, `/ws/live`) 在第一步完成认证;`/ws` 随后执行聊天角色检查(第二步)并解析用户 ID(第三步):

**第一步:`TryValidateWebSocketRequest` → `IsAuthorizedRequest`**

Expand All @@ -148,7 +149,24 @@ WebSocket 请求 (/ws)
└─ 通过 ──→ 接受 WebSocket 连接
```

**第二步:`TryResolveAuthorizedUserIdForWebSocket`**
**第二步(仅 `/ws`):`EndpointHelpers.CanExecuteAgent`**

每个 `/ws` 帧都会成为智能体输入,因此连接需要与 `POST /api/integration/messages` 相同的 `operator` 角色。角色通过 `AuthorizeOperatorRequest` 解析,与 HTTP API 使用同一认证链:

```
WebSocket 已接受 (/ws)
│
├─ 角色低于 operator,或身份不被组织策略允许?
│ ── 是 ──→ 关闭 1008 (PolicyViolation) "Chat requires the operator role."
│
└─ 通过 ──→ 第三步
```

连接会先被接受再关闭,而不是在握手阶段返回 403。浏览器无法读取握手失败的状态码,而 Web Chat 会将关闭码 1008 视为授权失败并停止重连。

OIDC 身份同样适用。缺少所配置 `RoleClaim` 的 JWT 会解析为 `viewer`,无法聊天;请通过该声明为需要使用 Web Chat 的用户授予 `operator` 角色。

**第三步:`TryResolveAuthorizedUserIdForWebSocket`**

```
WebSocket 已连接
Expand All @@ -160,7 +178,24 @@ WebSocket 已连接
└─ 调用 AuthorizeOperatorRequest() ──→ 提取 AccountId 作为 userId
```

### 3.3 `IsAuthorizedRequest` 详细逻辑
### 3.3 智能体执行所需角色

`IsAuthorizedRequest` 只确认调用方已通过认证。会把请求变为智能体输入或其他变更的入口,还需要 `operator` 角色(与 `POST /api/integration/messages` 相同),由 `EndpointHelpers.CanExecuteAgent` 检查:

| 入口 | 角色低于 operator 时 |
|------|----------------------|
| `/ws` | 先接受,再以 1008 (PolicyViolation) 关闭 |
| `POST /v1/chat/completions`、`POST /v1/responses` | 403,返回 OpenAI 风格的 `permission_error` 响应体 |
| A2A 执行路径(发现端点仍然公开) | 403 |
| `POST /apps/chat` | 403 |
| `/apps/mcp/{appId}` 的 `tools/call` | 返回工具错误结果;列出和读取 App 工具与资源仍可用 |
| MCP `openclaw.send_message`、`openclaw.run_workflow`、`openclaw.respond_workflow` | 返回工具错误结果;只读 MCP 工具对 viewer 仍可用 |

引导令牌和开放回环会解析为 `admin`,不受影响。新建的操作员账户默认为 `viewer`,因此用于 Companion、CLI/TUI 聊天或 API 客户端的账户需要 `operator` 角色。

每次拒绝都会在 `OpenClaw.Gateway.Authorization` 类别下记录一条警告日志,包含入口、认证方式、账户和角色(绝不包含凭据),便于管理员找出需要提升角色的账户。如需无中断迁移,可设置 `OpenClaw:Security:AllowViewerAgentExecution=true`,从日志中找出被放行的账户,为其授予 `operator` 角色,然后关闭该设置。该设置是临时的,将在下一个版本移除。

### 3.4 `IsAuthorizedRequest` 详细逻辑

```csharp
// 第 1 步:Loopback 豁免
Expand Down
6 changes: 4 additions & 2 deletions docs/zh-CN/MCPAPP.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ OpenClaw.NET 为浏览器侧 MCP App UI 暴露了一组面向 gateway 的 host
| 路由 | 用途 |
|------|------|
| `/apps/health` | 返回当前选中的 MCP App id,以及浏览器应连接的 gateway MCP 端点 |
| `/apps/chat` | 把浏览器 host 的聊天请求桥接到现有 `GatewayAppRuntime`,并输出 `session`、`text`、`tool`、`result`、`done` 形状的 SSE |
| `/apps/mcp/{appId}` | 把 MCP 请求代理到该 App 已经连接好的 `McpClient` |
| `/apps/chat` | 把浏览器 host 的聊天请求桥接到现有 `GatewayAppRuntime`,并输出 `session`、`text`、`tool`、`result`、`done` 形状的 SSE。需要 `operator` 角色 |
| `/apps/mcp/{appId}` | 把 MCP 请求代理到该 App 已经连接好的 `McpClient`。`tools/call` 需要 `operator` 角色;列表和读取对任何已认证角色仍可用 |

关键点是:浏览器 UI 应连接 `/apps/mcp/{appId}`,而不是直接连接 MCP App 的原始上游 URL。这样浏览器触发的 MCP 调用与 Agent 触发的 MCP 调用才能落在同一条 OpenClaw 管理的会话上。

`/apps/*` 路由使用 gateway 的常规认证。只有当 gateway 绑定在回环地址且 `AlwaysRequireAuth` 关闭时,才无需凭据即可访问;否则浏览器 host 必须携带令牌或浏览器会话。仅凭回环客户端 IP 不会被信任:在同机反向代理之后,每个调用方的 IP 都是回环地址。

### 会话复用行为

- `/apps/health` 返回的 `mcp` 字段指向 gateway 自己的 `/apps/mcp/{appId}` 路由。
Expand Down
10 changes: 10 additions & 0 deletions src/OpenClaw.Client/OpenClawWebSocketClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ public bool IsConnected
public event Action<WsServerEnvelope>? OnEnvelopeReceived;
public event Action<string>? OnError;

/// <summary>
/// Raised when the gateway closes the connection, with its close status and reason
/// (for example 1008 PolicyViolation when the account lacks the operator role).
/// Not raised when this client disconnects.
/// </summary>
public event Action<WebSocketCloseStatus?, string?>? OnClosed;

public async Task ConnectAsync(Uri wsUri, string? bearerToken, CancellationToken ct)
{
await DisconnectAsync(ct);
Expand Down Expand Up @@ -171,7 +178,10 @@ private async Task ReceiveLoopAsync(WebSocket ws, CancellationToken ct)
{
result = await ws.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close)
{
OnClosed?.Invoke(result.CloseStatus, result.CloseStatusDescription);
return;
}

if (writer.WrittenCount + result.Count > _maxMessageBytes)
throw new InvalidOperationException("Inbound message too large.");
Expand Down
10 changes: 10 additions & 0 deletions src/OpenClaw.Companion/Services/GatewayWebSocketClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public GatewayWebSocketClient(int maxMessageBytes = 256 * 1024)
_inner.OnTextMessage += text => OnTextMessage?.Invoke(text);
_inner.OnEnvelopeReceived += envelope => OnEnvelopeReceived?.Invoke(envelope);
_inner.OnError += error => OnError?.Invoke(error);
_inner.OnClosed += (status, reason) => OnClosed?.Invoke(status, reason);
}

public bool IsConnected
Expand All @@ -20,6 +21,7 @@ public bool IsConnected
public event Action<string>? OnTextMessage;
public event Action<OpenClaw.Core.Models.WsServerEnvelope>? OnEnvelopeReceived;
public event Action<string>? OnError;
public event Action<WebSocketCloseStatus?, string?>? OnClosed;

public async Task ConnectAsync(Uri wsUri, string? bearerToken, CancellationToken ct)
=> await _inner.ConnectAsync(wsUri, bearerToken, ct);
Expand All @@ -45,4 +47,12 @@ internal void SetConnectedSocketForTest(WebSocket ws)
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
method?.Invoke(_inner, [ws]);
}

internal Task RunReceiveLoopForTest(WebSocket ws, CancellationToken ct)
{
var method = typeof(OpenClaw.Client.OpenClawWebSocketClient).GetMethod(
"RunReceiveLoopForTest",
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
return (Task)method!.Invoke(_inner, [ws, ct])!;
}
}
Loading
Loading