diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc060c0..2d404cea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,13 +45,13 @@ All notable changes to this project are tracked in this file. - `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. +- Removed the temporary `OpenClaw:Security:AllowViewerAgentExecution` migration switch and its posture risk flag. Viewer credentials remain read-only on every agent-execution surface. Grant `operator` to accounts that need to chat or run the agent, and remove the retired key from configuration (including environment variables), even if it is `false`; the gateway now refuses to start while the key is present. - 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`. - 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. +- 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` from the authorization and role check, 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 6a65d87b..3272bca9 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -16,7 +16,6 @@ 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) | @@ -194,9 +193,11 @@ WebSocket connected 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. +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. -`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. +The temporary `OpenClaw:Security:AllowViewerAgentExecution` migration switch has been removed. Before upgrading, grant `operator` to accounts that need to chat or run the agent. Remove the retired key from JSON configuration, command-line arguments, and environment variables (`OpenClaw__Security__AllowViewerAgentExecution`), even if its value is `false`; the gateway refuses to start while the key is present. Viewer accounts retain read-only access. + +`GET /auth/session` reports the result of the authorization and role check as `canExecuteAgent`, so clients can explain a refusal before connecting. Companion uses it; a gateway that predates the field omits it. ### 3.4 `IsAuthorizedRequest` — Detailed Logic @@ -476,4 +477,4 @@ In this mode, `AuthMode` is `"token"` but `Oidc.Authority` is set, so the JWT au 3. **CSRF protection**: Browser sessions require CSRF token validation on API endpoints; WebSocket endpoints are exempt because browsers do send cookies during WebSocket handshakes, so WebSocket security relies on strict `Origin` header validation (see item 6) rather than cookie-based CSRF tokens 4. **JWT validation**: Full signature, issuer, audience, and expiration validation is provided by the ASP.NET Core JWT Bearer middleware 5. **Rate limiting**: All authenticated endpoints are subject to rate limiting, keyed by IP, operator account, and browser session -6. **Origin checking**: WebSocket endpoints validate the `Origin` header to prevent cross-site WebSocket hijacking \ No newline at end of file +6. **Origin checking**: WebSocket endpoints validate the `Origin` header to prevent cross-site WebSocket hijacking diff --git a/docs/zh-CN/AUTHENTICATION.md b/docs/zh-CN/AUTHENTICATION.md index 8aacb30f..ce9ebe7d 100644 --- a/docs/zh-CN/AUTHENTICATION.md +++ b/docs/zh-CN/AUTHENTICATION.md @@ -16,7 +16,6 @@ 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` | 浏览器会话空闲超时(分钟) | @@ -194,9 +193,11 @@ WebSocket 已连接 引导令牌和开放回环会解析为 `admin`,不受影响。新建的操作员账户默认为 `viewer`,因此用于 Companion、CLI/TUI 聊天或 API 客户端的账户需要 `operator` 角色。 -每次拒绝都会在 `OpenClaw.Gateway.Authorization` 类别下记录一条警告日志,包含入口、认证方式、账户和角色(绝不包含凭据),便于管理员找出需要提升角色的账户。如需无中断迁移,可设置 `OpenClaw:Security:AllowViewerAgentExecution=true`,从日志中找出被放行的账户,为其授予 `operator` 角色,然后关闭该设置。该设置是临时的,将在下一个版本移除。 +每次拒绝都会在 `OpenClaw.Gateway.Authorization` 类别下记录一条警告日志,包含入口、认证方式、账户和角色(绝不包含凭据),便于管理员找出需要提升角色的账户。 -`GET /auth/session` 会以 `canExecuteAgent` 字段报告这项检查的结果(包括 `AllowViewerAgentExecution` 的影响),客户端可以在连接前说明拒绝原因。Companion 会使用该字段;早于此字段的网关不会返回它。 +临时迁移开关 `OpenClaw:Security:AllowViewerAgentExecution` 已移除。升级前,请为需要聊天或运行智能体的账户授予 `operator` 角色,并从 JSON 配置、命令行参数和环境变量(`OpenClaw__Security__AllowViewerAgentExecution`)中删除该键,即使其值为 `false`;只要该键仍存在,网关就会拒绝启动。Viewer 账户仍可使用只读功能。 + +`GET /auth/session` 会以 `canExecuteAgent` 字段报告认证和角色检查的结果,客户端可以在连接前说明拒绝原因。Companion 会使用该字段;早于此字段的网关不会返回它。 ### 3.4 `IsAuthorizedRequest` 详细逻辑 @@ -476,4 +477,4 @@ if (!resp.ok) { 3. **CSRF 保护**:浏览器会话在 API 端点上要求 CSRF 令牌验证;WebSocket 端点在握手阶段浏览器会携带 Cookie,因此依赖严格的 `Origin` 头校验(参见第 6 条)来防止跨域攻击,而非基于 Cookie 的 CSRF 令牌 4. **JWT 验证**:由 ASP.NET Core JWT Bearer 中间件提供完整的签名、签发者、受众和过期时间验证 5. **速率限制**:所有认证端点均受速率限制保护,以 IP、操作员账户和浏览器会话为维度 -6. **Origin 检查**:WebSocket 端点验证 `Origin` 头,防止跨域 WebSocket 攻击 \ No newline at end of file +6. **Origin 检查**:WebSocket 端点验证 `Origin` 头,防止跨域 WebSocket 攻击 diff --git a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.cs b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.cs index cc319159..f17b44c8 100644 --- a/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.cs +++ b/src/OpenClaw.Companion/ViewModels/MainWindowViewModel.cs @@ -530,8 +530,8 @@ private async Task ConnectAsync() 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. + // up front and keep the read-only status views. Only its answer counts: a gateway that doesn't report + // canExecuteAgent decides at connect time. await LoadAdminStatusAsyncInternal(); if (_agentExecutionAllowedByGateway == false) { diff --git a/src/OpenClaw.Core/Models/GatewayConfig.cs b/src/OpenClaw.Core/Models/GatewayConfig.cs index 4de35924..c186df3a 100644 --- a/src/OpenClaw.Core/Models/GatewayConfig.cs +++ b/src/OpenClaw.Core/Models/GatewayConfig.cs @@ -384,14 +384,6 @@ public sealed class SecurityConfig public string[] KnownProxies { get; set; } = []; public bool RequireRequesterMatchForHttpToolApproval { get; set; } = false; - /// - /// 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, mutating MCP tools, and /ws/live. Each such request is logged so the accounts - /// can be promoted. - /// - public bool AllowViewerAgentExecution { get; set; } = false; - /// /// When binding to a non-loopback address, the gateway refuses to start if the local tooling /// is configured in an unsafe way (e.g. shell enabled or wildcard roots). Set this to true diff --git a/src/OpenClaw.Gateway/Bootstrap/GatewayBootstrapExtensions.cs b/src/OpenClaw.Gateway/Bootstrap/GatewayBootstrapExtensions.cs index 815d0658..399745b5 100644 --- a/src/OpenClaw.Gateway/Bootstrap/GatewayBootstrapExtensions.cs +++ b/src/OpenClaw.Gateway/Bootstrap/GatewayBootstrapExtensions.cs @@ -152,6 +152,14 @@ private static void WriteConfigSourceDiagnostics(ConfigSourceDiagnostics diagnos internal static GatewayConfig LoadGatewayConfig(IConfiguration configuration, bool loadPersistedSettings = true) { var openClawSection = configuration.GetSection("OpenClaw"); + if (openClawSection.GetSection("Security").GetChildren() + .Any(section => string.Equals(section.Key, "AllowViewerAgentExecution", StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException( + "OpenClaw:Security:AllowViewerAgentExecution has been removed. " + + "Remove this key from configuration and grant the operator role to accounts that need to chat or run the agent."); + } + var config = openClawSection.Get() ?? new GatewayConfig(); ApplyConfiguredLlmOverrides(openClawSection, config); ApplyConfiguredToolingOverrides(openClawSection, config); diff --git a/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Support.cs b/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Support.cs index bb68078d..7734f6c8 100644 --- a/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Support.cs +++ b/src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Support.cs @@ -254,7 +254,7 @@ private static AuthSessionResponse MapAuthSessionResponse( Username = auth.Username, DisplayName = auth.DisplayName, IsBootstrapAdmin = auth.IsBootstrapAdmin, - CanExecuteAgent = EndpointHelpers.AllowsAgentExecution(auth, startup), + CanExecuteAgent = EndpointHelpers.AllowsAgentExecution(auth), PublicBind = startup.IsNonLoopbackBind, AllowedAuthModes = [.. policy.AllowedAuthModes], EffectiveToolSurface = preset.Surface, diff --git a/src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs b/src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs index 552ec660..87ca2339 100644 --- a/src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs +++ b/src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs @@ -360,13 +360,12 @@ public static (OperatorAuthorizationResult? Authorization, IResult? Failure) Aut /// 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. + /// Denials 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) + internal static bool AllowsAgentExecution(OperatorAuthorizationResult auth) => auth.IsAuthorized - && (IsRoleAllowed(auth.Role, "integration.mutate.agent", out _) || startup.Config.Security.AllowViewerAgentExecution); + && IsRoleAllowed(auth.Role, "integration.mutate.agent", out _); public static bool CanExecuteAgent( HttpContext ctx, @@ -376,7 +375,7 @@ public static bool CanExecuteAgent( { var browserSessions = ctx.RequestServices.GetRequiredService(); var auth = AuthorizeOperatorRequest(ctx, startup, browserSessions, requireCsrf); - if (auth.IsAuthorized && IsRoleAllowed(auth.Role, "integration.mutate.agent", out _)) + if (AllowsAgentExecution(auth)) return true; var logger = ctx.RequestServices.GetRequiredService().CreateLogger("OpenClaw.Gateway.Authorization"); @@ -390,14 +389,6 @@ public static bool CanExecuteAgent( return false; } - if (startup.Config.Security.AllowViewerAgentExecution) - { - logger.LogWarning( - "Allowed {Action} for {AuthMode} account {AccountId} ({Username}) with role {Role} only because Security.AllowViewerAgentExecution is on. Grant the operator role before that setting is removed.", - action, auth.AuthMode, auth.AccountId, auth.Username, auth.Role); - return true; - } - logger.LogWarning( "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); diff --git a/src/OpenClaw.Gateway/SecurityPostureBuilder.cs b/src/OpenClaw.Gateway/SecurityPostureBuilder.cs index 3973f05e..7d0e0bff 100644 --- a/src/OpenClaw.Gateway/SecurityPostureBuilder.cs +++ b/src/OpenClaw.Gateway/SecurityPostureBuilder.cs @@ -99,12 +99,6 @@ public static SecurityPostureResponse Build(GatewayStartupContext startup, Gatew recommendations.Add("Enable Discord interaction signature validation before exposing a public bind."); } - if (config.Security.AllowViewerAgentExecution) - { - riskFlags.Add("viewer_agent_execution_allowed"); - recommendations.Add("Grant the operator role to accounts that chat or run the agent, then turn off OpenClaw:Security:AllowViewerAgentExecution. The setting is temporary and will be removed."); - } - if (browserAvailability.ConfiguredEnabled && !browserAvailability.Registered) { riskFlags.Add("browser_tool_unavailable"); diff --git a/src/OpenClaw.Gateway/wwwroot/admin.html b/src/OpenClaw.Gateway/wwwroot/admin.html index 76ffef74..62c9809f 100644 --- a/src/OpenClaw.Gateway/wwwroot/admin.html +++ b/src/OpenClaw.Gateway/wwwroot/admin.html @@ -993,7 +993,7 @@

Operator Accounts

-
Read-only by default: can't chat or run the agent from web chat, Companion, the CLI, or API clients. Choose operator for those; Security.AllowViewerAgentExecution is a temporary migration exception.
+
Read-only: can't chat or run the agent from web chat, Companion, the CLI, or API clients. Choose operator for those.
@@ -3230,7 +3230,7 @@

Notes

} const operatorAccountRoleHints = { - viewer: "Read-only by default: can't chat or run the agent from web chat, Companion, the CLI, or API clients. Choose operator for those; Security.AllowViewerAgentExecution is a temporary migration exception.", + viewer: "Read-only: can't chat or run the agent from web chat, Companion, the CLI, or API clients. Choose operator for those.", operator: 'Can chat, run the agent, and decide approvals.', admin: 'Operator access plus settings, plugins, accounts, and organization policy.' }; diff --git a/src/OpenClaw.Tests/CompanionConnectionTests.cs b/src/OpenClaw.Tests/CompanionConnectionTests.cs index db522b02..240af5b3 100644 --- a/src/OpenClaw.Tests/CompanionConnectionTests.cs +++ b/src/OpenClaw.Tests/CompanionConnectionTests.cs @@ -85,11 +85,10 @@ public async Task Connect_WhenGatewayReportsAgentExecutionDenied_ShouldExplainWi } [AvaloniaFact] - public async Task Connect_WhenGatewayAllowsViewerAgentExecution_ShouldAttemptChat() + public async Task Connect_WhenGatewayAllowsOperatorAgentExecution_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"; + var vm = CreateViewModelWithAuthSession("""{"authMode":"account_token","role":"operator","username":"runner","canExecuteAgent":true}"""); + vm.AuthToken = "operator-token"; await vm.ConnectCommand.ExecuteAsync(null); Dispatcher.UIThread.RunJobs(); diff --git a/src/OpenClaw.Tests/GatewayAdminEndpointTests.cs b/src/OpenClaw.Tests/GatewayAdminEndpointTests.cs index 9af0328f..024e53f3 100644 --- a/src/OpenClaw.Tests/GatewayAdminEndpointTests.cs +++ b/src/OpenClaw.Tests/GatewayAdminEndpointTests.cs @@ -628,39 +628,13 @@ public async Task AgentExecution_WhenViewerDenied_ShouldLogAccountAndRoleWithout Assert.DoesNotContain(token, entry, StringComparison.Ordinal); } - [Fact] - public async Task AgentExecution_WhenViewerAndAllowViewerAgentExecution_ShouldRunAgentAndLog() - { - var logs = new CapturingLoggerProvider(); - await using var harness = await CreateHarnessAsync( - nonLoopbackBind: true, - configure: config => config.Security.AllowViewerAgentExecution = true, - configureServices: (services, _) => services.AddSingleton(logs)); - var token = CreateAccountToken(harness, "legacy-viewer", OperatorRoleNames.Viewer); - harness.Runtime.AgentRuntime.RunAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns("viewer reply"); - - var response = await SendChatCompletionAsync(harness, token); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var entry = Assert.Single(logs.Warnings, message => message.Contains("AllowViewerAgentExecution", StringComparison.Ordinal)); - 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) + [InlineData(OperatorRoleNames.Viewer, false)] + [InlineData(OperatorRoleNames.Operator, true)] + [InlineData(OperatorRoleNames.Admin, true)] + public async Task AuthSession_ShouldReportWhetherTheCallerCanRunTheAgent(string role, bool expected) { - await using var harness = await CreateHarnessAsync( - nonLoopbackBind: true, - configure: config => config.Security.AllowViewerAgentExecution = allowViewerAgentExecution); + await using var harness = await CreateHarnessAsync(nonLoopbackBind: true); var token = CreateAccountToken(harness, $"session-{role}", role); using var request = new HttpRequestMessage(HttpMethod.Get, "/auth/session"); @@ -673,35 +647,15 @@ public async Task AuthSession_ShouldReportWhetherTheCallerCanRunTheAgent(string } [Fact] - public async Task AgentExecution_WhenAllowViewerAgentExecutionWithoutCredentials_ShouldStillReject() + public async Task AgentExecution_WhenInvalidCredentials_ShouldReject() { - await using var harness = await CreateHarnessAsync( - nonLoopbackBind: true, - configure: config => config.Security.AllowViewerAgentExecution = true); + await using var harness = await CreateHarnessAsync(nonLoopbackBind: true); var response = await SendChatCompletionAsync(harness, bearerToken: "not-a-real-token"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } - [Fact] - public async Task AdminPosture_WhenAllowViewerAgentExecution_ShouldReportRisk() - { - await using var harness = await CreateHarnessAsync( - nonLoopbackBind: true, - configure: config => config.Security.AllowViewerAgentExecution = true); - - using var request = new HttpRequestMessage(HttpMethod.Get, "/admin/posture"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", harness.AuthToken); - var response = await harness.Client.SendAsync(request); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - using var payload = await ReadJsonAsync(response); - Assert.Contains( - payload.RootElement.GetProperty("riskFlags").EnumerateArray().Select(static item => item.GetString()).OfType(), - flag => flag == "viewer_agent_execution_allowed"); - } - private static async Task SendChatCompletionAsync(GatewayTestHarness harness, string bearerToken) { using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions") @@ -7837,8 +7791,7 @@ public async Task AdminUi_OperatorAccountRole_ShouldExplainViewerCannotChat() var html = await File.ReadAllTextAsync(adminHtmlPath); Assert.Contains("id=\"operator-account-role-hint\"", html, StringComparison.Ordinal); - Assert.Contains("Read-only by default: can't chat or run the agent", html, StringComparison.Ordinal); - Assert.Contains("Security.AllowViewerAgentExecution is a temporary migration exception", html, StringComparison.Ordinal); + Assert.Contains("Read-only: can't chat or run the agent", html, StringComparison.Ordinal); Assert.Contains("operatorAccountRoleInput.addEventListener('change', updateOperatorAccountRoleHint)", html, StringComparison.Ordinal); } diff --git a/src/OpenClaw.Tests/GatewayBootstrapExtensionsTests.cs b/src/OpenClaw.Tests/GatewayBootstrapExtensionsTests.cs index b4df8808..27b61f9a 100644 --- a/src/OpenClaw.Tests/GatewayBootstrapExtensionsTests.cs +++ b/src/OpenClaw.Tests/GatewayBootstrapExtensionsTests.cs @@ -8,6 +8,50 @@ namespace OpenClaw.Tests; public sealed class GatewayBootstrapExtensionsTests { + [Theory] + [InlineData("AllowViewerAgentExecution", "true")] + [InlineData("AllowViewerAgentExecution", "false")] + [InlineData("AllowViewerAgentExecution", "")] + [InlineData("AllowViewerAgentExecution", null)] + [InlineData("allowvieweragentexecution", "true")] + [InlineData("AllowViewerAgentExecution", "invalid-setting-value")] + public void LoadGatewayConfig_RemovedViewerExecutionSetting_ThrowsWithMigrationInstructions(string key, string? value) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [$"OpenClaw:Security:{key}"] = value + }) + .Build(); + + var error = Assert.Throws(() => GatewayBootstrapExtensions.LoadGatewayConfig(configuration)); + + Assert.Contains("OpenClaw:Security:AllowViewerAgentExecution has been removed", error.Message, StringComparison.Ordinal); + Assert.Contains("Remove this key", error.Message, StringComparison.Ordinal); + Assert.Contains("grant the operator role", error.Message, StringComparison.Ordinal); + Assert.DoesNotContain("invalid-setting-value", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void LoadGatewayConfig_RemovedViewerExecutionEnvironmentVariable_Throws() + { + var prefix = $"OPENCLAW_TEST_{Guid.NewGuid():N}_"; + var variable = prefix + "OpenClaw__Security__AllowViewerAgentExecution"; + Environment.SetEnvironmentVariable(variable, "true"); + try + { + var configuration = new ConfigurationBuilder().AddEnvironmentVariables(prefix).Build(); + + var error = Assert.Throws(() => GatewayBootstrapExtensions.LoadGatewayConfig(configuration)); + + Assert.Contains("OpenClaw:Security:AllowViewerAgentExecution has been removed", error.Message, StringComparison.Ordinal); + } + finally + { + Environment.SetEnvironmentVariable(variable, null); + } + } + [Fact] public void LoadGatewayConfig_LegacyTelegramConfigWithoutUpdateMode_DefaultsToWebhook() {