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
3 changes: 2 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,7 @@ 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.
- 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
7 changes: 5 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
7 changes: 5 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
19 changes: 17 additions & 2 deletions src/OpenClaw.Companion/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ public sealed partial class MainWindowViewModel : ViewModelBase
[ObservableProperty]
private string _operatorRole = OperatorRoleNames.Viewer;

private bool? _agentExecutionAllowedByGateway;

[ObservableProperty]
private string _operatorAuthMode = "account_token";

Expand Down Expand Up @@ -526,11 +528,22 @@ private async Task ConnectAsync()
SaveSettings();

Status = "Connecting…";

// Ask the gateway first: an account it won't let run the agent would be admitted and then closed, so say why
// up front and keep the read-only status views. Only its answer counts, not the role: a viewer can still chat
// under Security.AllowViewerAgentExecution, and a gateway that doesn't report it decides at connect time.
await LoadAdminStatusAsyncInternal();
if (_agentExecutionAllowedByGateway == false)
{
Status = "Disconnected";
AddSystemMessage($"Signed in as {OperatorIdentity} with the {OperatorRole} role. Chat needs the operator role; ask an admin to change this account's role.");
return;
}

await _client.ConnectAsync(uri, string.IsNullOrWhiteSpace(AuthToken) ? null : AuthToken, CancellationToken.None);
IsConnected = true;
Status = "Connected";
await SendCanvasReadyAsync();
await LoadAdminStatusAsyncInternal();
await LoadWhatsAppSetupAsync();
}
catch (Exception ex)
Expand Down Expand Up @@ -628,6 +641,7 @@ private async Task LoadAdminStatusAsync()

private async Task LoadAdminStatusAsyncInternal()
{
_agentExecutionAllowedByGateway = null;
using var client = CreateAdminClient(out var error);
if (client is null)
{
Expand All @@ -647,8 +661,9 @@ private async Task LoadAdminStatusAsyncInternal()
try
{
var auth = await client.GetAuthSessionAsync(CancellationToken.None);
var setup = await client.GetSetupStatusAsync(CancellationToken.None);
ApplyOperatorIdentity(auth.AuthMode, auth.Role, auth.DisplayName, auth.Username, auth.IsBootstrapAdmin);
_agentExecutionAllowedByGateway = auth.CanExecuteAgent;
var setup = await client.GetSetupStatusAsync(CancellationToken.None);
AdminStatus = auth.IsBootstrapAdmin
? "Using bootstrap/breakglass admin auth."
: $"Authenticated as {auth.DisplayName ?? auth.Username ?? "operator"} via {auth.AuthMode}.";
Expand Down
6 changes: 6 additions & 0 deletions src/OpenClaw.Core/Models/AdminApiModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ public sealed class AuthSessionResponse
public string? Username { get; init; }
public string? DisplayName { get; init; }
public bool IsBootstrapAdmin { get; init; }

/// <summary>
/// Whether this caller may run the agent (chat over /ws, /v1/*, A2A, and the other agent surfaces).
/// Null from gateways that predate the field; those decide only when the client connects.
/// </summary>
public bool? CanExecuteAgent { get; init; }
public bool PublicBind { get; init; }
public string[] AllowedAuthModes { get; init; } = [];
public string EffectiveToolSurface { get; init; } = "web";
Expand Down
3 changes: 2 additions & 1 deletion src/OpenClaw.Core/Models/GatewayConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,8 @@ public sealed class SecurityConfig
/// <summary>
/// Temporary compatibility switch, to be removed in the next release. When true, authenticated identities
/// below the operator role (such as viewer accounts) can still run the agent through /ws, /v1/*, A2A,
/// /apps/chat, MCP App tool calls, and mutating MCP tools. Each such request is logged so the accounts can be promoted.
/// /apps/chat, MCP App tool calls, mutating MCP tools, and /ws/live. Each such request is logged so the accounts
/// can be promoted.
/// </summary>
public bool AllowViewerAgentExecution { get; set; } = false;

Expand Down
1 change: 1 addition & 0 deletions src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Support.cs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ private static AuthSessionResponse MapAuthSessionResponse(
Username = auth.Username,
DisplayName = auth.DisplayName,
IsBootstrapAdmin = auth.IsBootstrapAdmin,
CanExecuteAgent = EndpointHelpers.AllowsAgentExecution(auth, startup),
PublicBind = startup.IsNonLoopbackBind,
AllowedAuthModes = [.. policy.AllowedAuthModes],
EffectiveToolSurface = preset.Surface,
Expand Down
38 changes: 34 additions & 4 deletions src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ public static bool IsAuthorizedRequest(HttpContext ctx, GatewayConfig config, bo
if (operatorAccounts is not null)
{
var token = GatewaySecurity.GetToken(ctx, config.Security.AllowQueryStringToken);
if (!string.IsNullOrWhiteSpace(token) && operatorAccounts.TryAuthenticateToken(token, out _))
if (!string.IsNullOrWhiteSpace(token) && TryAuthenticateAccountToken(ctx, operatorAccounts, token, out _))
return true;
}
}
Expand Down Expand Up @@ -161,7 +161,7 @@ public static OperatorAuthorizationResult AuthorizeOperatorRequest(
if (IsAllowedAuthMode(policy, OrganizationAuthModeNames.AccountToken) &&
!string.IsNullOrWhiteSpace(token) &&
operatorAccounts is not null &&
operatorAccounts.TryAuthenticateToken(token, out var accountIdentity))
TryAuthenticateAccountToken(ctx, operatorAccounts, token, out var accountIdentity))
{
return new OperatorAuthorizationResult(
true,
Expand Down Expand Up @@ -203,6 +203,30 @@ operatorAccounts is not null &&
IsBootstrapAdmin: false);
}

private sealed record AccountTokenVerification(string Token, bool Succeeded, OperatorIdentitySnapshot? Identity);

// Token verification is deliberately slow (PBKDF2) and serialized inside OperatorAccountService, and one
// request can pass through several checks (authentication, role, identity). Verify each request's token
// once and reuse the outcome; revocation still applies from the next request.
private static bool TryAuthenticateAccountToken(
HttpContext ctx,
OperatorAccountService operatorAccounts,
string token,
out OperatorIdentitySnapshot? identity)
{
if (ctx.Items.TryGetValue(typeof(AccountTokenVerification), out var cached) &&
cached is AccountTokenVerification previous &&
string.Equals(previous.Token, token, StringComparison.Ordinal))
{
identity = previous.Identity;
return previous.Succeeded;
}

var succeeded = operatorAccounts.TryAuthenticateToken(token, out identity);
ctx.Items[typeof(AccountTokenVerification)] = new AccountTokenVerification(token, succeeded, identity);
return succeeded;
}

public static bool TrySetMaxRequestBodySize(HttpContext ctx, long maxBytes)
{
var feature = ctx.Features.Get<IHttpMaxRequestBodySizeFeature>();
Expand Down Expand Up @@ -333,11 +357,17 @@ public static (OperatorAuthorizationResult? Authorization, IResult? Failure) Aut

/// <summary>
/// Surfaces that turn a request into agent input or another mutation (chat, the OpenAI-compatible API,
/// A2A, MCP Apps chat and tool calls, mutating MCP tools) require the same role as POST /api/integration/messages.
/// A2A, MCP Apps chat and tool calls, mutating MCP tools, the live model bridge) require the same role as
/// POST /api/integration/messages.
/// Authentication alone is not enough: viewer credentials must stay read-only.
/// Denials, and admissions under Security.AllowViewerAgentExecution, are logged with the account so
/// admins can find identities that need the operator role.
/// </summary>
// The rule CanExecuteAgent enforces, without its logging, so /auth/session can report it before a client tries.
internal static bool AllowsAgentExecution(OperatorAuthorizationResult auth, GatewayStartupContext startup)
=> auth.IsAuthorized
&& (IsRoleAllowed(auth.Role, "integration.mutate.agent", out _) || startup.Config.Security.AllowViewerAgentExecution);

public static bool CanExecuteAgent(
HttpContext ctx,
GatewayStartupContext startup,
Expand Down Expand Up @@ -369,7 +399,7 @@ public static bool CanExecuteAgent(
}

logger.LogWarning(
"Denied {Action} for {AuthMode} account {AccountId} ({Username}) with role {Role}: running the agent requires the operator role.",
"Denied {Action} for {AuthMode} account {AccountId} ({Username}) with role {Role}: this action requires the operator role.",
action, auth.AuthMode, auth.AccountId, auth.Username, auth.Role);
return false;
}
Expand Down
8 changes: 8 additions & 0 deletions src/OpenClaw.Gateway/Endpoints/WebSocketEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ public static void MapOpenClawWebSocketEndpoints(
return;

var ws = await ctx.WebSockets.AcceptWebSocketAsync();

// The live bridge runs no tools but spends provider credentials, so it needs the same role as /ws.
if (!EndpointHelpers.CanExecuteAgent(ctx, startup))
{
await ws.CloseAsync(WebSocketCloseStatus.PolicyViolation, EndpointHelpers.OperatorRoleRequiredMessage, ctx.RequestAborted);
return;
}

try
{
var openRequest = await ReceiveLiveOpenRequestAsync(ws, ctx.RequestAborted);
Expand Down
Loading
Loading