diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5411dcda..cb95327b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
@@ -46,7 +47,9 @@ 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.
+- 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`).
diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md
index 1ce6e2db..cd1c7901 100644
--- a/docs/AUTHENTICATION.md
+++ b/docs/AUTHENTICATION.md
@@ -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`**
@@ -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:
@@ -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 |
@@ -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
@@ -235,6 +238,23 @@ 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.
+
## 4. Middleware Pipeline
Authentication middleware is registered in `Program.cs` in the following order:
diff --git a/docs/zh-CN/AUTHENTICATION.md b/docs/zh-CN/AUTHENTICATION.md
index 13d03dde..0825c488 100644
--- a/docs/zh-CN/AUTHENTICATION.md
+++ b/docs/zh-CN/AUTHENTICATION.md
@@ -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`**
@@ -149,7 +149,7 @@ WebSocket 请求 (/ws)
└─ 通过 ──→ 接受 WebSocket 连接
```
-**第二步(仅 `/ws`):`EndpointHelpers.CanExecuteAgent`**
+**第二步:`EndpointHelpers.CanExecuteAgent`**
每个 `/ws` 帧都会成为智能体输入,因此连接需要与 `POST /api/integration/messages` 相同的 `operator` 角色。角色通过 `AuthorizeOperatorRequest` 解析,与 HTTP API 使用同一认证链:
@@ -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 |
@@ -195,6 +196,8 @@ WebSocket 已连接
每次拒绝都会在 `OpenClaw.Gateway.Authorization` 类别下记录一条警告日志,包含入口、认证方式、账户和角色(绝不包含凭据),便于管理员找出需要提升角色的账户。如需无中断迁移,可设置 `OpenClaw:Security:AllowViewerAgentExecution=true`,从日志中找出被放行的账户,为其授予 `operator` 角色,然后关闭该设置。该设置是临时的,将在下一个版本移除。
+`GET /auth/session` 会以 `canExecuteAgent` 字段报告这项检查的结果(包括 `AllowViewerAgentExecution` 的影响),客户端可以在连接前说明拒绝原因。Companion 会使用该字段;早于此字段的网关不会返回它。
+
### 3.4 `IsAuthorizedRequest` 详细逻辑
```csharp
@@ -235,6 +238,23 @@ 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 会话发消息的操作员身份运行。系统、定时、自动化和后台续跑轮次代表会话执行,保留会话原有身份。
+
## 四、中间件管道
认证相关的中间件在 `Program.cs` 中按以下顺序注册:
diff --git a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.cs b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.cs
index 08e9d62b..cc319159 100644
--- a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.cs
+++ b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.cs
@@ -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";
@@ -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)
@@ -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)
{
@@ -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}.";
diff --git a/src/OpenClaw.Core/Models/AdminApiModels.cs b/src/OpenClaw.Core/Models/AdminApiModels.cs
index 9c5f22e1..34c5dd00 100644
--- a/src/OpenClaw.Core/Models/AdminApiModels.cs
+++ b/src/OpenClaw.Core/Models/AdminApiModels.cs
@@ -27,6 +27,12 @@ public sealed class AuthSessionResponse
public string? Username { get; init; }
public string? DisplayName { get; init; }
public bool IsBootstrapAdmin { get; init; }
+
+ ///
+ /// 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.
+ ///
+ public bool? CanExecuteAgent { get; init; }
public bool PublicBind { get; init; }
public string[] AllowedAuthModes { get; init; } = [];
public string EffectiveToolSurface { get; init; } = "web";
diff --git a/src/OpenClaw.Core/Models/GatewayConfig.cs b/src/OpenClaw.Core/Models/GatewayConfig.cs
index 17c5b175..4de35924 100644
--- a/src/OpenClaw.Core/Models/GatewayConfig.cs
+++ b/src/OpenClaw.Core/Models/GatewayConfig.cs
@@ -387,7 +387,8 @@ public sealed class SecurityConfig
///
/// 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.
///
public bool AllowViewerAgentExecution { get; set; } = false;
diff --git a/src/OpenClaw.Gateway/A2A/A2ACallerContext.cs b/src/OpenClaw.Gateway/A2A/A2ACallerContext.cs
new file mode 100644
index 00000000..b300c206
--- /dev/null
+++ b/src/OpenClaw.Gateway/A2A/A2ACallerContext.cs
@@ -0,0 +1,16 @@
+namespace OpenClaw.Gateway.A2A;
+
+///
+/// The signed-in account behind the current A2A request. The A2A middleware sets it before the SDK runs the
+/// handler; unlike the HTTP context, an async-local value still flows into work the SDK detaches from the request.
+///
+internal static class A2ACallerContext
+{
+ private static readonly AsyncLocal Current = new();
+
+ public static string? AccountId
+ {
+ get => Current.Value;
+ set => Current.Value = value;
+ }
+}
diff --git a/src/OpenClaw.Gateway/A2A/A2AEndpointExtensions.cs b/src/OpenClaw.Gateway/A2A/A2AEndpointExtensions.cs
index 4b501af5..e4488471 100644
--- a/src/OpenClaw.Gateway/A2A/A2AEndpointExtensions.cs
+++ b/src/OpenClaw.Gateway/A2A/A2AEndpointExtensions.cs
@@ -80,6 +80,8 @@ public static void UseOpenClawA2AAuth(
return;
}
+ A2ACallerContext.AccountId = EndpointHelpers.ResolveAuthenticatedAccountId(ctx, startup);
+
if (!runtime.Operations.ActorRateLimits.TryConsume(
"ip",
EndpointHelpers.GetRemoteIpKey(ctx),
diff --git a/src/OpenClaw.Gateway/A2A/OpenClawA2AExecutionBridge.cs b/src/OpenClaw.Gateway/A2A/OpenClawA2AExecutionBridge.cs
index a1f4a153..37315782 100644
--- a/src/OpenClaw.Gateway/A2A/OpenClawA2AExecutionBridge.cs
+++ b/src/OpenClaw.Gateway/A2A/OpenClawA2AExecutionBridge.cs
@@ -32,6 +32,9 @@ public async Task ExecuteStreamingAsync(
await using var sessionLock = await runtime.SessionManager.AcquireSessionLockAsync(session.Id, cancellationToken);
+ // The A2A request's SenderId is the caller's contextId, so the turn's identity comes from the signed-in account.
+ session.AuthenticatedUserId = A2ACallerContext.AccountId;
+
var (handled, commandResponse) = await runtime.CommandProcessor.TryProcessCommandAsync(
session,
request.UserText,
diff --git a/src/OpenClaw.Gateway/Composition/IntegrationApiFacade.cs b/src/OpenClaw.Gateway/Composition/IntegrationApiFacade.cs
index 9372c469..a780593a 100644
--- a/src/OpenClaw.Gateway/Composition/IntegrationApiFacade.cs
+++ b/src/OpenClaw.Gateway/Composition/IntegrationApiFacade.cs
@@ -787,7 +787,14 @@ public async Task ListLearningProposalsAsync(strin
return rejected;
}
- public async Task QueueMessageAsync(IntegrationMessageRequest request, CancellationToken cancellationToken)
+ ///
+ /// The signed-in account resolved by the calling surface. It becomes the turn's identity; the request's
+ /// senderId is caller-supplied and only used for routing and display.
+ ///
+ public async Task QueueMessageAsync(
+ IntegrationMessageRequest request,
+ CancellationToken cancellationToken,
+ string? authenticatedUserId = null)
{
var effectiveChannelId = string.IsNullOrWhiteSpace(request.ChannelId) ? "integration-api" : request.ChannelId.Trim();
var effectiveSenderId = string.IsNullOrWhiteSpace(request.SenderId) ? "http-client" : request.SenderId.Trim();
@@ -805,7 +812,8 @@ public async Task QueueMessageAsync(IntegrationMessa
Type = "user_message",
Text = request.Text,
MessageId = request.MessageId,
- ReplyToMessageId = request.ReplyToMessageId
+ ReplyToMessageId = request.ReplyToMessageId,
+ AuthenticatedUserId = authenticatedUserId
};
if (!_runtime.Pipeline.InboundWriter.TryWrite(message))
diff --git a/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Support.cs b/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Support.cs
index e916e8e7..ba6c8dbc 100644
--- a/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Support.cs
+++ b/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Support.cs
@@ -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,
diff --git a/src/OpenClaw.Gateway/Endpoints/AppsEndpoints.cs b/src/OpenClaw.Gateway/Endpoints/AppsEndpoints.cs
index f88936e8..d7dfbf73 100644
--- a/src/OpenClaw.Gateway/Endpoints/AppsEndpoints.cs
+++ b/src/OpenClaw.Gateway/Endpoints/AppsEndpoints.cs
@@ -82,6 +82,7 @@ public static void MapOpenClawAppsEndpoints(
? $"apps-{Guid.NewGuid():N}"
: requestedSessionId!;
var session = await runtime.SessionManager.GetOrCreateByIdAsync(sessionId, "apps", sessionId, ct);
+ session.AuthenticatedUserId = EndpointHelpers.ResolveAuthenticatedAccountId(ctx, startup);
await SendAsync(ctx, new JsonObject { ["type"] = "session", ["sessionId"] = sessionId }, ct);
diff --git a/src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs b/src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs
index 209ec51c..59d36709 100644
--- a/src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs
+++ b/src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs
@@ -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;
}
}
@@ -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,
@@ -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();
@@ -333,11 +357,17 @@ public static (OperatorAuthorizationResult? Authorization, IResult? Failure) Aut
///
/// 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.
///
+ // 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,
@@ -369,11 +399,23 @@ 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;
}
+ ///
+ /// The signed-in account behind a request, used as the identity of the turns it starts (per-user capability
+ /// scope and the userId passed to MCP servers) in place of any caller-supplied sender id.
+ /// Null for open loopback and bootstrap callers, which have no account and are admin-equivalent.
+ ///
+ public static string? ResolveAuthenticatedAccountId(HttpContext ctx, GatewayStartupContext startup)
+ {
+ var browserSessions = ctx.RequestServices.GetRequiredService();
+ var auth = AuthorizeOperatorRequest(ctx, startup, browserSessions, requireCsrf: false);
+ return auth.IsAuthorized && !string.IsNullOrWhiteSpace(auth.AccountId) ? auth.AccountId : null;
+ }
+
public static async Task WriteOperatorRoleRequiredAsync(HttpContext ctx)
{
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
diff --git a/src/OpenClaw.Gateway/Endpoints/IntegrationEndpoints.cs b/src/OpenClaw.Gateway/Endpoints/IntegrationEndpoints.cs
index b61fe05e..747a028e 100644
--- a/src/OpenClaw.Gateway/Endpoints/IntegrationEndpoints.cs
+++ b/src/OpenClaw.Gateway/Endpoints/IntegrationEndpoints.cs
@@ -849,7 +849,7 @@ await runtime.PaymentRuntime.GetPaymentStatusAsync(id, provider, BuildPaymentCon
}
return Results.Json(
- await facade.QueueMessageAsync(request, ctx.RequestAborted),
+ await facade.QueueMessageAsync(request, ctx.RequestAborted, EndpointHelpers.ResolveAuthenticatedAccountId(ctx, startup)),
CoreJsonContext.Default.IntegrationMessageResponse,
statusCode: StatusCodes.Status202Accepted);
});
diff --git a/src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.ChatCompletions.cs b/src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.ChatCompletions.cs
index 356c3f00..2decf367 100644
--- a/src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.ChatCompletions.cs
+++ b/src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.ChatCompletions.cs
@@ -98,6 +98,10 @@ private static void MapChatCompletionsEndpoint(
persistStableSessionOnExit = true;
}
+ // The turn runs as the signed-in account: it scopes per-user capability bindings and is the
+ // userId MCP servers see. Callers without an account (open loopback, bootstrap) run without one.
+ session.AuthenticatedUserId = EndpointHelpers.ResolveAuthenticatedAccountId(ctx, startup);
+
var httpMwCtx = new MessageContext
{
ChannelId = "openai-http",
diff --git a/src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.Responses.cs b/src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.Responses.cs
index 0adfdfe2..893500ec 100644
--- a/src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.Responses.cs
+++ b/src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.Responses.cs
@@ -87,6 +87,10 @@ private static void MapResponsesEndpoint(
persistStableSessionOnExit = true;
}
+ // The turn runs as the signed-in account: it scopes per-user capability bindings and is the
+ // userId MCP servers see. Callers without an account (open loopback, bootstrap) run without one.
+ session.AuthenticatedUserId = EndpointHelpers.ResolveAuthenticatedAccountId(ctx, startup);
+
var httpMwCtx = new MessageContext
{
ChannelId = "openai-responses",
diff --git a/src/OpenClaw.Gateway/Endpoints/WebSocketEndpoints.cs b/src/OpenClaw.Gateway/Endpoints/WebSocketEndpoints.cs
index af908099..7b3fbc1b 100644
--- a/src/OpenClaw.Gateway/Endpoints/WebSocketEndpoints.cs
+++ b/src/OpenClaw.Gateway/Endpoints/WebSocketEndpoints.cs
@@ -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);
diff --git a/src/OpenClaw.Gateway/Extensions/GatewayInboundMessageWorker.cs b/src/OpenClaw.Gateway/Extensions/GatewayInboundMessageWorker.cs
index 6ac0b73b..a78820fa 100644
--- a/src/OpenClaw.Gateway/Extensions/GatewayInboundMessageWorker.cs
+++ b/src/OpenClaw.Gateway/Extensions/GatewayInboundMessageWorker.cs
@@ -385,12 +385,8 @@ await pipeline.OutboundWriter.WriteAsync(new OutboundMessage
sessionLock = await sessionManager.AcquireSessionLockAsync(session.Id, processingCt);
- if (!string.IsNullOrWhiteSpace(msg.AuthenticatedUserId) &&
- !string.Equals(session.AuthenticatedUserId, msg.AuthenticatedUserId, StringComparison.Ordinal))
- {
- session.AuthenticatedUserId = msg.AuthenticatedUserId;
+ if (TurnIdentity.Apply(session, msg))
await sessionManager.PersistAsync(session, processingCt, sessionLockHeld: true);
- }
if (automationService is not null && !string.IsNullOrWhiteSpace(msg.CronJobName))
{
diff --git a/src/OpenClaw.Gateway/Extensions/TurnIdentity.cs b/src/OpenClaw.Gateway/Extensions/TurnIdentity.cs
new file mode 100644
index 00000000..448c0fd1
--- /dev/null
+++ b/src/OpenClaw.Gateway/Extensions/TurnIdentity.cs
@@ -0,0 +1,35 @@
+using OpenClaw.Core.Models;
+
+namespace OpenClaw.Gateway.Extensions;
+
+///
+/// Decides whose identity a pipeline turn runs with. scopes per-user
+/// capability bindings and is passed to MCP servers as the userId, so it must describe the caller of the current
+/// turn rather than whoever last wrote to the session.
+///
+internal static class TurnIdentity
+{
+ /// Returns true when the session's identity changed and should be persisted.
+ public static bool Apply(Session session, InboundMessage message)
+ {
+ string? identity;
+ if (!string.IsNullOrWhiteSpace(message.AuthenticatedUserId))
+ identity = message.AuthenticatedUserId;
+ else if (ActsOnSessionsBehalf(message))
+ return false;
+ else
+ identity = null; // an external sender without an account must not inherit a previous writer's account
+
+ if (string.Equals(session.AuthenticatedUserId, identity, StringComparison.Ordinal))
+ return false;
+
+ session.AuthenticatedUserId = identity;
+ return true;
+ }
+
+ private static bool ActsOnSessionsBehalf(InboundMessage message)
+ => message.IsSystem
+ || !string.IsNullOrWhiteSpace(message.CronJobName)
+ || !string.IsNullOrWhiteSpace(message.AutomationRunId)
+ || !string.IsNullOrWhiteSpace(message.BackgroundRunId);
+}
diff --git a/src/OpenClaw.Gateway/Mcp/OpenClawMcpTools.cs b/src/OpenClaw.Gateway/Mcp/OpenClawMcpTools.cs
index 0603cf1f..81efc048 100644
--- a/src/OpenClaw.Gateway/Mcp/OpenClawMcpTools.cs
+++ b/src/OpenClaw.Gateway/Mcp/OpenClawMcpTools.cs
@@ -305,7 +305,7 @@ public async Task SendMessage(
[Description("Optional reply-to message ID.")] string? replyToMessageId = null,
CancellationToken ct = default)
{
- RequireOperator("openclaw.send_message");
+ var caller = RequireOperator("openclaw.send_message");
return JsonSerializer.Serialize(
await _facade.QueueMessageAsync(new IntegrationMessageRequest
{
@@ -315,17 +315,18 @@ await _facade.QueueMessageAsync(new IntegrationMessageRequest
SessionId = sessionId,
MessageId = messageId,
ReplyToMessageId = replyToMessageId
- }, ct),
+ }, ct, EndpointHelpers.ResolveAuthenticatedAccountId(caller, _startup)),
CoreJsonContext.Default.IntegrationMessageResponse);
}
// Read-only tools stay open to viewers; mutating tools require the role their REST equivalents require.
// Without a request context (e.g. a detached task) the caller is unknown, so deny.
- private void RequireOperator(string toolName)
+ private HttpContext RequireOperator(string toolName)
{
var ctx = _httpContextAccessor.HttpContext;
if (ctx is null || !EndpointHelpers.CanExecuteAgent(ctx, _startup, $"MCP tool {toolName}"))
throw new McpException(EndpointHelpers.OperatorRoleRequiredMessage);
+ return ctx;
}
private static JsonElement? ParsePayloadJson(string? payloadJson)
diff --git a/src/OpenClaw.Tests/CompanionConnectionTests.cs b/src/OpenClaw.Tests/CompanionConnectionTests.cs
index 24d3b076..db522b02 100644
--- a/src/OpenClaw.Tests/CompanionConnectionTests.cs
+++ b/src/OpenClaw.Tests/CompanionConnectionTests.cs
@@ -1,8 +1,11 @@
+using System.Net;
using System.Net.WebSockets;
+using System.Text;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using OpenClaw.Companion.Models;
using OpenClaw.Companion.Services;
+using OpenClaw.Client;
using OpenClaw.Companion.ViewModels;
using Xunit;
@@ -66,6 +69,85 @@ public async Task ServerClose_WhenClientReconnectsBeforeUiDispatch_ShouldKeepCon
Assert.DoesNotContain(vm.Messages, message => message.Role == ChatRole.System);
}
+ [AvaloniaFact]
+ public async Task Connect_WhenGatewayReportsAgentExecutionDenied_ShouldExplainWithoutOpeningChat()
+ {
+ var vm = CreateViewModelWithAuthSession("""{"authMode":"account_token","role":"viewer","username":"reader","canExecuteAgent":false}""");
+ vm.AuthToken = "viewer-token";
+
+ await vm.ConnectCommand.ExecuteAsync(null);
+ Dispatcher.UIThread.RunJobs();
+
+ Assert.False(vm.IsConnected);
+ Assert.Equal("Disconnected", vm.Status);
+ Assert.Contains(vm.Messages, m => m.Text.Contains("operator role", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(vm.Messages, m => m.Text.StartsWith("Connect failed", StringComparison.Ordinal));
+ }
+
+ [AvaloniaFact]
+ public async Task Connect_WhenGatewayAllowsViewerAgentExecution_ShouldAttemptChat()
+ {
+ // Security.AllowViewerAgentExecution lets a viewer chat, so the role alone must not stop Companion.
+ var vm = CreateViewModelWithAuthSession("""{"authMode":"account_token","role":"viewer","username":"reader","canExecuteAgent":true}""");
+ vm.AuthToken = "viewer-token";
+
+ await vm.ConnectCommand.ExecuteAsync(null);
+ Dispatcher.UIThread.RunJobs();
+
+ Assert.Contains(vm.Messages, m => m.Text.StartsWith("Connect failed", StringComparison.Ordinal));
+ Assert.DoesNotContain(vm.Messages, m => m.Text.Contains("operator role", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [AvaloniaFact]
+ public async Task Connect_WhenGatewayDoesNotReportAgentExecution_ShouldAttemptChat()
+ {
+ // An older gateway reports only the role; it decides at connect time, so Companion must not guess.
+ var vm = CreateViewModelWithAuthSession("""{"authMode":"account_token","role":"viewer","username":"reader"}""");
+ vm.AuthToken = "viewer-token";
+
+ await vm.ConnectCommand.ExecuteAsync(null);
+ Dispatcher.UIThread.RunJobs();
+
+ Assert.Contains(vm.Messages, m => m.Text.StartsWith("Connect failed", StringComparison.Ordinal));
+ Assert.DoesNotContain(vm.Messages, m => m.Text.Contains("operator role", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [AvaloniaFact]
+ public async Task Connect_WhenNoTokenLoaded_ShouldStillAttemptChat()
+ {
+ // Without a token the viewer role is only a placeholder, not something the gateway reported.
+ var vm = CreateViewModelWithAuthSession("""{"authMode":"account_token","role":"viewer"}""");
+
+ await vm.ConnectCommand.ExecuteAsync(null);
+ Dispatcher.UIThread.RunJobs();
+
+ Assert.Contains(vm.Messages, m => m.Text.StartsWith("Connect failed", StringComparison.Ordinal));
+ Assert.DoesNotContain(vm.Messages, m => m.Text.Contains("operator role", StringComparison.OrdinalIgnoreCase));
+ }
+
+ private MainWindowViewModel CreateViewModelWithAuthSession(string authSessionJson)
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "openclaw-companion-connection-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ _tempDirs.Add(dir);
+ var vm = new MainWindowViewModel(
+ new SettingsStore(dir),
+ new GatewayWebSocketClient(),
+ (baseUrl, authToken) => new OpenClawHttpClient(baseUrl, authToken, new HttpClient(new CallbackHandler(request =>
+ request.RequestUri!.AbsolutePath == "/auth/session"
+ ? new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(authSessionJson, Encoding.UTF8, "application/json") }
+ : new HttpResponseMessage(HttpStatusCode.NotFound)))));
+ // Nothing listens here, so an attempted chat connection fails fast and visibly.
+ vm.ServerUrl = "ws://127.0.0.1:9/ws";
+ return vm;
+ }
+
+ private sealed class CallbackHandler(Func callback) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ => Task.FromResult(callback(request));
+ }
+
private (MainWindowViewModel ViewModel, GatewayWebSocketClient Client) CreateConnectedViewModel()
{
var dir = Path.Combine(Path.GetTempPath(), "openclaw-companion-connection-tests", Guid.NewGuid().ToString("N"));
diff --git a/src/OpenClaw.Tests/EndpointHelpersAuthenticationTests.cs b/src/OpenClaw.Tests/EndpointHelpersAuthenticationTests.cs
new file mode 100644
index 00000000..3bf10f14
--- /dev/null
+++ b/src/OpenClaw.Tests/EndpointHelpersAuthenticationTests.cs
@@ -0,0 +1,81 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using OpenClaw.Core.Models;
+using OpenClaw.Gateway;
+using OpenClaw.Gateway.Bootstrap;
+using OpenClaw.Gateway.Endpoints;
+using Xunit;
+
+namespace OpenClaw.Tests;
+
+public sealed class EndpointHelpersAuthenticationTests : IDisposable
+{
+ private readonly string _storagePath = Path.Combine(Path.GetTempPath(), "openclaw-endpoint-auth-tests", Guid.NewGuid().ToString("N"));
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_storagePath, recursive: true); }
+ catch { }
+ }
+
+ [Fact]
+ public void AccountToken_WhenCheckedTwiceInOneRequest_ShouldBeVerifiedOnce()
+ {
+ var (startup, services, accounts, accountId, token) = CreateOperatorToken();
+ var ctx = CreateRequest(services, token.Token);
+
+ Assert.True(EndpointHelpers.IsAuthorizedRequest(ctx, startup.Config, startup.IsNonLoopbackBind));
+
+ // Revoking after the first check makes a second verification observable: a re-run of the slow
+ // token check would now fail, while a reused result for this request still succeeds.
+ Assert.True(accounts.RevokeToken(accountId, token.TokenInfo!.Id));
+ var auth = EndpointHelpers.AuthorizeOperatorRequest(ctx, startup, services.GetRequiredService(), requireCsrf: false);
+
+ Assert.True(auth.IsAuthorized);
+ Assert.Equal(accountId, auth.AccountId);
+ }
+
+ [Fact]
+ public void AccountToken_WhenRevokedBeforeNextRequest_ShouldBeRejected()
+ {
+ var (startup, services, accounts, accountId, token) = CreateOperatorToken();
+ Assert.True(EndpointHelpers.IsAuthorizedRequest(CreateRequest(services, token.Token), startup.Config, startup.IsNonLoopbackBind));
+
+ Assert.True(accounts.RevokeToken(accountId, token.TokenInfo!.Id));
+
+ Assert.False(EndpointHelpers.IsAuthorizedRequest(CreateRequest(services, token.Token), startup.Config, startup.IsNonLoopbackBind));
+ }
+
+ private (GatewayStartupContext Startup, ServiceProvider Services, OperatorAccountService Accounts, string AccountId, OperatorAccountTokenCreateResponse Token) CreateOperatorToken()
+ {
+ var config = new GatewayConfig { AuthToken = "bootstrap-token" };
+ var startup = new GatewayStartupContext
+ {
+ Config = config,
+ RuntimeState = RuntimeModeResolver.Resolve(config.Runtime, dynamicCodeSupported: true),
+ IsNonLoopbackBind = true
+ };
+ var accounts = new OperatorAccountService(_storagePath, NullLogger.Instance);
+ var account = accounts.Create(new OperatorAccountCreateRequest
+ {
+ Username = "memo-operator",
+ Password = "P@ssw0rd123!",
+ Role = OperatorRoleNames.Operator
+ });
+ var token = accounts.CreateToken(account.Id, new OperatorAccountTokenCreateRequest { Label = "memo" })!;
+
+ var services = new ServiceCollection();
+ services.AddSingleton(new BrowserSessionAuthService(config));
+ services.AddSingleton(accounts);
+ services.AddSingleton(new OrganizationPolicyService(_storagePath, NullLogger.Instance));
+ return (startup, services.BuildServiceProvider(), accounts, account.Id, token);
+ }
+
+ private static DefaultHttpContext CreateRequest(IServiceProvider services, string token)
+ {
+ var ctx = new DefaultHttpContext { RequestServices = services };
+ ctx.Request.Headers.Authorization = $"Bearer {token}";
+ return ctx;
+ }
+}
diff --git a/src/OpenClaw.Tests/GatewayAdminEndpointTests.cs b/src/OpenClaw.Tests/GatewayAdminEndpointTests.cs
index 777b1b2e..3f2a4ebb 100644
--- a/src/OpenClaw.Tests/GatewayAdminEndpointTests.cs
+++ b/src/OpenClaw.Tests/GatewayAdminEndpointTests.cs
@@ -380,6 +380,32 @@ public async Task WebSocketChat_WhenOpenLoopbackWithoutCredentials_ShouldStayOpe
Assert.Null(await ReceiveWithinAsync(ws, TimeSpan.FromMilliseconds(500)));
}
+ [Fact]
+ public async Task WebSocketLive_WhenViewerAccountToken_ShouldCloseWithPolicyViolation()
+ {
+ await using var harness = await CreateHarnessAsync(nonLoopbackBind: true);
+ var token = CreateAccountToken(harness, "live-viewer", OperatorRoleNames.Viewer);
+
+ using var ws = await ConnectWebSocketAsync(harness, token, "/ws/live");
+ var received = await ReceiveWithinAsync(ws, TimeSpan.FromSeconds(5));
+
+ Assert.NotNull(received);
+ Assert.Equal(WebSocketMessageType.Close, received.MessageType);
+ Assert.Equal(WebSocketCloseStatus.PolicyViolation, ws.CloseStatus);
+ Assert.Contains("operator", ws.CloseStatusDescription, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task WebSocketLive_WhenOperatorAccountToken_ShouldStayOpen()
+ {
+ await using var harness = await CreateHarnessAsync(nonLoopbackBind: true);
+ var token = CreateAccountToken(harness, "live-operator", OperatorRoleNames.Operator);
+
+ using var ws = await ConnectWebSocketAsync(harness, token, "/ws/live");
+
+ Assert.Null(await ReceiveWithinAsync(ws, TimeSpan.FromMilliseconds(500)));
+ }
+
[Theory]
[InlineData("/v1/chat/completions", """{"messages":[{"role":"user","content":"hello"}]}""")]
[InlineData("/v1/responses", """{"input":"hello"}""")]
@@ -626,6 +652,26 @@ public async Task AgentExecution_WhenViewerAndAllowViewerAgentExecution_ShouldRu
Assert.Contains("legacy-viewer", entry, StringComparison.Ordinal);
}
+ [Theory]
+ [InlineData(OperatorRoleNames.Viewer, false, false)]
+ [InlineData(OperatorRoleNames.Operator, false, true)]
+ [InlineData(OperatorRoleNames.Viewer, true, true)]
+ public async Task AuthSession_ShouldReportWhetherTheCallerCanRunTheAgent(string role, bool allowViewerAgentExecution, bool expected)
+ {
+ await using var harness = await CreateHarnessAsync(
+ nonLoopbackBind: true,
+ configure: config => config.Security.AllowViewerAgentExecution = allowViewerAgentExecution);
+ var token = CreateAccountToken(harness, $"session-{role}", role);
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, "/auth/session");
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ var response = await harness.Client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ using var payload = await ReadJsonAsync(response);
+ Assert.Equal(expected, payload.RootElement.GetProperty("canExecuteAgent").GetBoolean());
+ }
+
[Fact]
public async Task AgentExecution_WhenAllowViewerAgentExecutionWithoutCredentials_ShouldStillReject()
{
@@ -690,6 +736,154 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except
}
}
+ [Fact]
+ public async Task IntegrationMessages_WhenAccountToken_ShouldStampAccountNotClaimedSender()
+ {
+ await using var harness = await CreateHarnessAsync(nonLoopbackBind: true);
+ var (token, accountId) = CreateAccountTokenWithId(harness, "rest-operator", OperatorRoleNames.Operator);
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, "/api/integration/messages")
+ {
+ Content = JsonContent("""{"text":"hello","senderId":"someone-else","sessionId":"stamp-rest"}""")
+ };
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ var response = await harness.Client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
+ Assert.True(harness.Runtime.Pipeline.InboundReader.TryRead(out var queued));
+ Assert.Equal("someone-else", queued.SenderId);
+ Assert.Equal(accountId, queued.AuthenticatedUserId);
+ }
+
+ [Fact]
+ public async Task McpSendMessage_WhenAccountToken_ShouldStampAccountNotClaimedSender()
+ {
+ await using var harness = await CreateHarnessAsync(nonLoopbackBind: true);
+ var (token, accountId) = CreateAccountTokenWithId(harness, "mcp-operator", OperatorRoleNames.Operator);
+
+ using var result = await CallMcpToolAsync(harness, token, "openclaw.send_message", """{"text":"hello","senderId":"someone-else"}""");
+
+ Assert.False(result.RootElement.TryGetProperty("isError", out var isError) && isError.GetBoolean());
+ Assert.True(harness.Runtime.Pipeline.InboundReader.TryRead(out var queued));
+ Assert.Equal(accountId, queued.AuthenticatedUserId);
+ }
+
+ [Fact]
+ public async Task ChatCompletions_WhenAccountToken_ShouldRunTurnAsAccount()
+ {
+ await using var harness = await CreateHarnessAsync(nonLoopbackBind: true);
+ var (token, accountId) = CreateAccountTokenWithId(harness, "openai-identity", OperatorRoleNames.Operator);
+ Session? ran = null;
+ harness.Runtime.AgentRuntime.RunAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .Returns(callInfo =>
+ {
+ ran = callInfo.Arg();
+ return Task.FromResult("ok");
+ });
+
+ var response = await SendChatCompletionAsync(harness, token);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(accountId, ran?.AuthenticatedUserId);
+ }
+
+ [Fact]
+ public async Task AppsChat_WhenAccountToken_ShouldRunTurnAsAccount()
+ {
+ await using var harness = await CreateHarnessAsync(nonLoopbackBind: true);
+ var (token, accountId) = CreateAccountTokenWithId(harness, "apps-identity", OperatorRoleNames.Operator);
+ Session? ran = null;
+ harness.Runtime.AgentRuntime.RunStreamingAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(callInfo =>
+ {
+ ran = callInfo.Arg();
+ return NoAgentEvents();
+ });
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, "/apps/chat") { Content = JsonContent("""{"message":"hello","sessionId":"stamp-apps"}""") };
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ var response = await harness.Client.SendAsync(request);
+ await response.Content.ReadAsStringAsync();
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(accountId, ran?.AuthenticatedUserId);
+ }
+
+ [Fact]
+ public async Task A2AExecution_WhenAccountToken_ShouldExposeCallerAccountToHandlers()
+ {
+ await using var harness = await CreateHarnessAsync(
+ nonLoopbackBind: true,
+ configureServices: (services, _) => services.Configure(options => options.EnableA2A = true),
+ configureApp: app => app.MapPost("/a2a", () => Results.Text(A2ACallerContext.AccountId ?? "")));
+ var (token, accountId) = CreateAccountTokenWithId(harness, "a2a-identity", OperatorRoleNames.Operator);
+
+ using var request = new HttpRequestMessage(HttpMethod.Post, "/a2a") { Content = JsonContent("{}") };
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ var response = await harness.Client.SendAsync(request);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(accountId, await response.Content.ReadAsStringAsync());
+ }
+
+ [Fact]
+ public async Task A2ABridge_WhenCallerAccountKnown_ShouldRunTurnAsAccount()
+ {
+ await using var harness = await CreateHarnessAsync(nonLoopbackBind: true);
+ Session? ran = null;
+ harness.Runtime.AgentRuntime.RunStreamingAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(callInfo =>
+ {
+ ran = callInfo.Arg();
+ return NoAgentEvents();
+ });
+ var bridge = new OpenClawA2AExecutionBridge(new GatewayRuntimeHolder { Runtime = harness.Runtime }, NullLogger.Instance);
+
+ A2ACallerContext.AccountId = "acct-a2a";
+ try
+ {
+ await bridge.ExecuteStreamingAsync(
+ new OpenClaw.MicrosoftAgentFrameworkAdapter.A2A.OpenClawA2AExecutionRequest
+ {
+ SessionId = "stamp-a2a",
+ ChannelId = "a2a",
+ SenderId = "claimed-context",
+ UserText = "hello"
+ },
+ (_, _) => ValueTask.CompletedTask,
+ CancellationToken.None);
+ }
+ finally
+ {
+ A2ACallerContext.AccountId = null;
+ }
+
+ Assert.Equal("acct-a2a", ran?.AuthenticatedUserId);
+ }
+
+ private static async IAsyncEnumerable NoAgentEvents()
+ {
+ await Task.CompletedTask;
+ yield break;
+ }
+
+ private static (string Token, string AccountId) CreateAccountTokenWithId(GatewayTestHarness harness, string username, string role)
+ {
+ var operatorAccounts = harness.App.Services.GetRequiredService();
+ var account = operatorAccounts.Create(new OperatorAccountCreateRequest
+ {
+ Username = username,
+ Password = "P@ssw0rd123!",
+ Role = role
+ });
+ return (operatorAccounts.CreateToken(account.Id, new OperatorAccountTokenCreateRequest { Label = username })!.Token, account.Id);
+ }
+
private static async Task CallMcpToolAsync(GatewayTestHarness harness, string bearerToken, string toolName, string argumentsJson)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/mcp")
@@ -719,12 +913,12 @@ private static string CreateAccountToken(GatewayTestHarness harness, string user
return token!.Token;
}
- private static async Task ConnectWebSocketAsync(GatewayTestHarness harness, string? bearerToken)
+ private static async Task ConnectWebSocketAsync(GatewayTestHarness harness, string? bearerToken, string path = "/ws")
{
var client = harness.App.GetTestServer().CreateWebSocketClient();
if (bearerToken is not null)
client.ConfigureRequest = request => request.Headers.Authorization = $"Bearer {bearerToken}";
- return await client.ConnectAsync(new Uri("ws://localhost/ws"), CancellationToken.None);
+ return await client.ConnectAsync(new Uri("ws://localhost" + path), CancellationToken.None);
}
// Returns null when nothing arrives in time, which for these tests means the server kept the connection open.
diff --git a/src/OpenClaw.Tests/TurnIdentityTests.cs b/src/OpenClaw.Tests/TurnIdentityTests.cs
new file mode 100644
index 00000000..0e5e6f16
--- /dev/null
+++ b/src/OpenClaw.Tests/TurnIdentityTests.cs
@@ -0,0 +1,68 @@
+using OpenClaw.Core.Models;
+using OpenClaw.Gateway.Extensions;
+using Xunit;
+
+namespace OpenClaw.Tests;
+
+public sealed class TurnIdentityTests
+{
+ [Fact]
+ public void Apply_WhenMessageCarriesAccount_ShouldRunAsThatAccount()
+ {
+ var session = NewSession(previousAccount: "acct-a");
+
+ TurnIdentity.Apply(session, Message(authenticatedUserId: "acct-b"));
+
+ Assert.Equal("acct-b", session.AuthenticatedUserId);
+ }
+
+ [Fact]
+ public void Apply_WhenExternalSenderHasNoAccount_ShouldNotInheritPreviousAccount()
+ {
+ // An operator wrote into this Telegram session earlier; the Telegram user's own turn must not run as that operator.
+ var session = NewSession(previousAccount: "acct-operator");
+
+ TurnIdentity.Apply(session, Message(authenticatedUserId: null));
+
+ Assert.Null(session.AuthenticatedUserId);
+ }
+
+ [Theory]
+ [InlineData("system")]
+ [InlineData("cron")]
+ [InlineData("automation")]
+ [InlineData("background")]
+ public void Apply_WhenTurnActsOnSessionsBehalf_ShouldKeepSessionIdentity(string kind)
+ {
+ var session = NewSession(previousAccount: "acct-owner");
+ var message = kind switch
+ {
+ "system" => Message(authenticatedUserId: null) with { IsSystem = true },
+ "cron" => Message(authenticatedUserId: null) with { CronJobName = "nightly" },
+ "automation" => Message(authenticatedUserId: null) with { AutomationRunId = "run-1" },
+ _ => Message(authenticatedUserId: null) with { BackgroundRunId = "bg-1" }
+ };
+
+ TurnIdentity.Apply(session, message);
+
+ Assert.Equal("acct-owner", session.AuthenticatedUserId);
+ }
+
+ private static Session NewSession(string? previousAccount)
+ => new()
+ {
+ Id = "telegram:user-1",
+ ChannelId = "telegram",
+ SenderId = "user-1",
+ AuthenticatedUserId = previousAccount
+ };
+
+ private static InboundMessage Message(string? authenticatedUserId)
+ => new()
+ {
+ ChannelId = "telegram",
+ SenderId = "user-1",
+ Text = "hi",
+ AuthenticatedUserId = authenticatedUserId
+ };
+}