Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ All notable changes to this project are tracked in this file.

- 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.
- `/ws/live`: closed with code 1008. The live model bridge runs no tools but spends provider credentials.
- `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.
Expand All @@ -46,7 +47,11 @@ All notable changes to this project are tracked in this file.
- 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.
- Turns now run as the signed-in account instead of a caller-supplied sender id. `Session.AuthenticatedUserId` scopes per-user capability bindings and is passed to MCP servers as `_meta.userId`. Previously only `/ws` set it, so REST messages, MCP `send_message`, A2A, `/v1/*`, and `/apps/chat` turns ran as whatever `senderId` or `contextId` the caller supplied. MCP servers that read `_meta.userId` now receive account ids for those turns.
- A turn from an external sender without an account no longer inherits the previous writer's account. Previously, after an operator posted into a Telegram session, the Telegram user's next turns ran as that operator. System, scheduled, automation, and background-continuation turns keep the session's identity.
- Sessions record the account that created them as `ownerAccountId`, and only the owner or an admin can post to an owned session. Every surface refuses other accounts: REST (403), MCP `send_message` (tool error), `/apps/chat` (403), `/v1/*` stable sessions (403, `session_forbidden`), A2A (error event), and pipeline turns including `/ws` (reply). Session management follows the same rule: delete, metadata, abort, branch restore, and guided recovery return 403 for other non-admin accounts. `/ws` now resolves its caller like the other surfaces, so loopback-bound gateways with `AlwaysRequireAuth` or OIDC record and enforce owners. Previously any operator could post into any session whose id it knew. Unowned sessions (channels, cron, sessions created before this change) stay open and are never claimed by writing to them. Reading is unchanged. `GET /api/integration/sessions?owner=me` lists the caller's own sessions.
- `POST /apps/chat` now holds the session lock for the whole turn and saves the session afterwards, like the other turn surfaces. Previously two requests for the same `sessionId` ran concurrently against one history, and a turn was saved only if its session later expired or was evicted, so a gateway restart could lose it.
- 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. It also asks the gateway before connecting: `GET /auth/session` now reports `canExecuteAgent`, which follows `AllowViewerAgentExecution`, and when it is `false` Companion explains the missing `operator` role instead of opening chat. The read-only status views stay available. Against a gateway that does not report the field, Companion connects and the gateway decides.
- 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
43 changes: 41 additions & 2 deletions docs/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Request enters

### 3.2 WebSocket 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):
WebSocket endpoints (`/ws`, `/ws/live`) authenticate in Phase 1 and then apply the role check (Phase 2). `/ws` also resolves the user ID (Phase 3):

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

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

**Phase 2 (`/ws` only): `EndpointHelpers.CanExecuteAgent`**
**Phase 2: `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:

Expand Down Expand Up @@ -185,6 +185,7 @@ WebSocket connected
| Surface | Below operator |
|---------|----------------|
| `/ws` | Accepted, then closed with 1008 (PolicyViolation) |
| `/ws/live` | Accepted, then closed with 1008. The live bridge runs no tools but spends provider credentials |
| `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 |
Expand All @@ -195,6 +196,8 @@ Bootstrap tokens and open loopback resolve to `admin` and are unaffected. New op

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.

`GET /auth/session` reports the result of this check as `canExecuteAgent`, including the effect of `AllowViewerAgentExecution`, so clients can explain a refusal before connecting. Companion uses it; a gateway that predates the field omits it.

### 3.4 `IsAuthorizedRequest` — Detailed Logic

```csharp
Expand Down Expand Up @@ -235,6 +238,42 @@ return false; // 401 Unauthorized

---

### 3.5 Turn Identity

`Session.AuthenticatedUserId` is the identity a turn runs with. It scopes per-user capability bindings and is passed to MCP servers as `_meta.userId`. When it is empty, those fall back to the session's `SenderId`.

Surfaces that run turns set it from the signed-in account (`EndpointHelpers.ResolveAuthenticatedAccountId`), never from a caller-supplied sender id:

| Surface | Identity source |
|---------|-----------------|
| `POST /api/integration/messages`, MCP `openclaw.send_message` | Account of the request; the body's `senderId` is only used for routing and display |
| `/ws` | Account resolved at connection |
| `POST /v1/chat/completions`, `POST /v1/responses`, `POST /apps/chat` | Account of the request |
| A2A execution | Account of the request; the A2A `contextId` is only the sender id |

Open loopback and bootstrap callers have no account, so their turns run without one.

A pipeline turn from an external sender without an account runs without one too. It does not inherit the account of whoever wrote to the session before. For example, a Telegram user's turn never runs as an operator who posted into that Telegram session. System, scheduled, automation, and background-continuation turns act on the session's behalf and keep its identity.

### 3.6 Session Ownership

A session created by a signed-in account records it as `Session.OwnerAccountId`. The owner is set once, at creation, and never reassigned.

| Session | Who can post to it |
|---------|--------------------|
| Owned | The owner and admins. Other accounts are refused on every surface: REST returns 403, MCP returns a tool error, `/apps/chat` returns 403, `/v1/*` stable sessions return 403 with code `session_forbidden`, A2A returns an error event, and pipeline turns (including `/ws`) get a reply saying the conversation belongs to another account |
| Unowned (created by channels, cron, bootstrap or loopback callers, or before ownership existed) | Anyone allowed to post. Writing to an unowned session never claims it |

Callers without an account (bootstrap, open loopback, channel and system turns) are not restricted by ownership. They are admin-equivalent, or they address sessions by their own keys.

The same rule covers session management: deleting a session (`DELETE /admin/sessions/{id}`), changing its metadata, aborting its run, restoring one of its branches, and guided recovery return 403 for other non-admin accounts. Promoting a session to an automation only reads it and is unaffected.

Reading stays open to every role that can read sessions, so dashboards and audit are unaffected.

`GET /api/integration/sessions?owner=me` lists only the caller's own sessions, active and persisted. `SessionSummary.ownerAccountId` carries the owner. Callers without an account own no sessions.

`/v1/*` stable sessions (`X-OpenClaw-Session-Id`) are keyed by the bearer token's hash or, for browser sessions, the client address, so two signed-in accounts behind one address can derive the same session; the owner check keeps them apart. `/ws` resolves its caller the same way as the other surfaces, so a loopback-bound gateway that still requires auth (`AlwaysRequireAuth` or OIDC) records owners and enforces them.

## 4. Middleware Pipeline

Authentication middleware is registered in `Program.cs` in the following order:
Expand Down
2 changes: 1 addition & 1 deletion docs/MCPAPP.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ The `/apps/*` routes use the gateway's normal authentication. They are open with
- `/apps/health` returns an `mcp` URL that points back to the gateway's own `/apps/mcp/{appId}` route.
- `/apps/mcp/{appId}` forwards `tools/list`, `resources/list`, `resources/read`, and `tools/call` to the App already loaded in `McpAppRegistry`.
- When the browser includes `?sessionId=...` on the MCP endpoint URL, OpenClaw injects that value into `tools/call` as `_meta.sessionId` before forwarding upstream.
- `/apps/chat` creates or resumes a gateway session with that same id and streams host-friendly SSE frames back to the browser.
- `/apps/chat` creates or resumes a gateway session with that same id and streams host-friendly SSE frames back to the browser. Turns on one session run one at a time: a second request for the same id waits until the first turn finishes. The session is saved after each turn.

This is the bridge that lets a rich MCP App UI and the Agent collaborate against the same App session instead of creating two unrelated MCP connections.

Expand Down
43 changes: 41 additions & 2 deletions docs/zh-CN/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ HTTP API 端点使用 `AuthorizeOperatorRequest` 方法([EndpointHelpers.cs](.

### 3.2 WebSocket 认证流程

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

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

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

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

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

Expand Down Expand Up @@ -185,6 +185,7 @@ WebSocket 已连接
| 入口 | 角色低于 operator 时 |
|------|----------------------|
| `/ws` | 先接受,再以 1008 (PolicyViolation) 关闭 |
| `/ws/live` | 先接受,再以 1008 关闭。实时桥接不运行工具,但会消耗提供商凭据额度 |
| `POST /v1/chat/completions`、`POST /v1/responses` | 403,返回 OpenAI 风格的 `permission_error` 响应体 |
| A2A 执行路径(发现端点仍然公开) | 403 |
| `POST /apps/chat` | 403 |
Expand All @@ -195,6 +196,8 @@ WebSocket 已连接

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

`GET /auth/session` 会以 `canExecuteAgent` 字段报告这项检查的结果(包括 `AllowViewerAgentExecution` 的影响),客户端可以在连接前说明拒绝原因。Companion 会使用该字段;早于此字段的网关不会返回它。

### 3.4 `IsAuthorizedRequest` 详细逻辑

```csharp
Expand Down Expand Up @@ -235,6 +238,42 @@ return false; // 401 Unauthorized

---

### 3.5 轮次身份

`Session.AuthenticatedUserId` 是一个轮次运行时所用的身份。它划分按用户的能力绑定范围,并以 `_meta.userId` 传给 MCP 服务器。为空时,二者回退到会话的 `SenderId`。

运行轮次的入口会根据已登录账户设置它(`EndpointHelpers.ResolveAuthenticatedAccountId`),绝不采用调用方提供的发送者 ID:

| 入口 | 身份来源 |
|------|----------|
| `POST /api/integration/messages`、MCP `openclaw.send_message` | 请求的账户;请求体中的 `senderId` 只用于路由和显示 |
| `/ws` | 连接时解析出的账户 |
| `POST /v1/chat/completions`、`POST /v1/responses`、`POST /apps/chat` | 请求的账户 |
| A2A 执行 | 请求的账户;A2A 的 `contextId` 只作为发送者 ID |

开放回环和引导令牌调用方没有账户,因此它们的轮次不带账户身份运行。

来自外部发送者且不带账户的管道轮次同样不带账户身份运行,不会继承此前写入该会话的账户。例如,Telegram 用户的轮次绝不会以曾向该 Telegram 会话发消息的操作员身份运行。系统、定时、自动化和后台续跑轮次代表会话执行,保留会话原有身份。

### 3.6 会话归属

由已登录账户创建的会话会把该账户记录为 `Session.OwnerAccountId`。归属只在创建时设置一次,之后不会改变。

| 会话 | 谁可以向其发送消息 |
|------|--------------------|
| 有归属 | 所有者和管理员。其他账户在所有入口都会被拒绝:REST 返回 403,MCP 返回工具错误,`/apps/chat` 返回 403,`/v1/*` 稳定会话返回 403(错误码 `session_forbidden`),A2A 返回错误事件,管道轮次(包括 `/ws`)会收到“该会话属于另一个账户”的回复 |
| 无归属(由渠道、定时任务、引导令牌或回环调用方创建,或创建于引入归属之前) | 任何被允许发送消息的调用方。向无归属会话写入永远不会占有它 |

没有账户的调用方(引导令牌、开放回环、渠道和系统轮次)不受归属限制。它们等同于管理员,或者按各自的键访问会话。

同一规则也适用于会话管理:删除会话(`DELETE /admin/sessions/{id}`)、修改元数据、中止运行、恢复分支以及引导式恢复,对其他非管理员账户返回 403。将会话提升为自动化只读取会话,不受影响。

读取对所有能读取会话的角色保持开放,仪表盘和审计不受影响。

`GET /api/integration/sessions?owner=me` 只列出调用方自己的会话(活动和持久化的)。`SessionSummary.ownerAccountId` 给出所有者。没有账户的调用方不拥有任何会话。

`/v1/*` 稳定会话(`X-OpenClaw-Session-Id`)按 Bearer 令牌的哈希或(浏览器会话时)客户端地址派生,因此同一地址后的两个已登录账户可能得到同一个会话;归属检查将它们区分开。`/ws` 与其他入口使用相同的调用方解析,因此仍要求认证的回环绑定网关(`AlwaysRequireAuth` 或 OIDC)也会记录并执行归属。

## 四、中间件管道

认证相关的中间件在 `Program.cs` 中按以下顺序注册:
Expand Down
2 changes: 1 addition & 1 deletion docs/zh-CN/MCPAPP.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ OpenClaw.NET 为浏览器侧 MCP App UI 暴露了一组面向 gateway 的 host
- `/apps/health` 返回的 `mcp` 字段指向 gateway 自己的 `/apps/mcp/{appId}` 路由。
- `/apps/mcp/{appId}` 会把 `tools/list`、`resources/list`、`resources/read`、`tools/call` 转发给 `McpAppRegistry` 中已加载的 App。
- 如果浏览器在 MCP 端点 URL 上带了 `?sessionId=...`,OpenClaw 会在转发 `tools/call` 前把它注入到 `_meta.sessionId`。
- `/apps/chat` 会用同一个 session id 创建或恢复 gateway 会话,并把 host 友好的 SSE 事件流回浏览器。
- `/apps/chat` 会用同一个 session id 创建或恢复 gateway 会话,并把 host 友好的 SSE 事件流回浏览器。同一会话的轮次依次执行:对同一 id 的第二个请求会等待第一轮结束。每轮结束后会保存会话。

这就是交互式 MCP App UI 和 Agent 能够围绕同一个 App 会话协作的桥梁,而不是各自单独建一条不相关的 MCP 连接。

Expand Down
4 changes: 3 additions & 1 deletion src/OpenClaw.Channels/WebSocketChannel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ public async Task HandleConnectionAsync(
string clientId,
IPAddress? remoteIp,
CancellationToken ct,
string? authenticatedUserId = null)
string? authenticatedUserId = null,
bool authenticatedUserIsAdmin = false)
{
if (!TryAddConnection(clientId, ws, remoteIp, out var state))
{
Expand Down Expand Up @@ -132,6 +133,7 @@ await SendEnvelopeToStateAsync(
SenderId = clientId,
RequestCancellation = ct,
AuthenticatedUserId = authenticatedUserId,
AuthenticatedUserIsAdmin = authenticatedUserIsAdmin,
SessionId = parsed.SessionId,
Type = parsed.Type,
Text = parsed.Text ?? "",
Expand Down
Loading
Loading