Skip to content

fix(gateway): run /apps/chat turns one at a time and save them - #263

Merged
Telli merged 11 commits into
mainfrom
fix/apps-chat-session-lock
Sep 29, 2026
Merged

Telli merged 11 commits into
mainfrom
fix/apps-chat-session-lock

Conversation

@Telli

@Telli Telli commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Description

Stacked on #260 (← #259 ← #257). Merge those first, then retarget this PR to main.

POST /apps/chat is the only turn surface that neither takes the session lock nor saves the session afterwards. The worker, A2A, and /v1/* stable sessions do both.

  • Concurrent turns. Two requests with the same sessionId ran RunStreamingAsync at the same time against one Session, interleaving history writes and token accounting. An MCP App UI can easily send a follow-up while a turn is still streaming.
  • Lost turns. The turn was saved only if its session later expired or was evicted. Shutdown does not save active sessions, so a restart could lose /apps/chat history. With feat(gateway): record session owners and restrict writes to owner or admin #260 that also includes the session's ownerAccountId.

This is based on #260 because it edits the same lines: the lock covers #260's ownership check.

Summary

  • Acquire the session lock right after resolving the session, as OpenClawA2AExecutionBridge does. It is held through the ownership check and the whole turn, so a second request waits for the first to finish.
  • Save the session in a finally when the turn ends, with the lock held. It uses CancellationToken.None, so a client that disconnects mid-turn still gets the history the turn produced.
  • Documented in docs/MCPAPP.md (with zh-CN).

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • Documentation update
  • Tests

Validation

  • dotnet build OpenClaw.Net.slnx --configuration Release: 0 warnings, 0 errors
  • dotnet test OpenClaw.Net.slnx --configuration Release --no-build: OpenClaw.Tests 3,158 passed / 11 skipped / 0 failed; NacosLiveAcceptance 25 passed; LayaService 67 passed
  • dotnet run --project samples/OpenClaw.HelloAgent -c Release --no-build

Both new tests failed first for the right reason:

  • Chat_ConcurrentTurnsOnSameSession_RunOneAtATime: the second turn started while the first was held open.
  • Chat_PersistsSessionAfterTurn: nothing was saved to the store.

Review Notes

  • I considered NativeAOT compatibility (no new reflection or serialization)
  • I considered security posture and unsafe defaults
  • I updated docs/tests where needed (docs/MCPAPP.md with zh-CN, CHANGELOG.md)
  • This PR is scoped and does not mix unrelated changes

Commercial or Customer-Driven Contribution Disclosure

Found while reviewing the #256 surfaces. Vendor-neutral gateway fix.

Checklist

  • I have read the CONTRIBUTING guidelines
  • My code follows the code style implementation of this project
  • I have added tests that prove my fix is effective or that my feature works
  • All new and existing tests passed locally (dotnet test)
  • I have updated the documentation (README.md, comments) if required
  • I have checked for security implications (input validation, authorization)
  • I have checked the relevant maintainer review checklist
  • I have disclosed whether this directly supports a company or customer use case

🤖 Generated with Claude Code

Telli and others added 6 commits September 28, 2026 14:54
Account-token verification runs PBKDF2 (120,000 iterations, about 17 ms
of CPU) inside OperatorAccountService's global lock and then rewrites
the accounts file. The role checks added in this branch meant that
/v1/*, /apps/chat, A2A, /ws, /ws/live, and mutating MCP tools verified
the same token twice per request, halving account-token throughput on
those surfaces.

Cache the verification outcome in HttpContext.Items for the rest of the
request. Revocation, disabling, and role changes still apply from the
next request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
/ws/live admitted any authenticated role. It runs no tools, but it opens
a live session on the gateway's provider credentials, so a viewer token
could spend them. It now uses CanExecuteAgent like /ws and closes below
operator with 1008.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Companion opened the chat socket before loading the account's role, so
a viewer was admitted and then closed by the gateway. It now loads the
role first; when the gateway reports a role below operator it explains
that chat needs the operator role instead of connecting, and keeps the
read-only status views. A role that is only the no-token placeholder
does not block the connection, and the role is applied as soon as the
auth session loads rather than after the setup status call.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Session.AuthenticatedUserId scopes per-user capability bindings and is
passed to MCP servers as _meta.userId, falling back to SenderId. Only
/ws set it, so turns from REST messages, MCP send_message, A2A, /v1/*,
and /apps/chat ran as whatever senderId or A2A contextId the caller
supplied.

- EndpointHelpers.ResolveAuthenticatedAccountId resolves the signed-in
  account (reusing the per-request token verification). REST and MCP
  stamp it on queued messages; /v1/*, /apps/chat, and the A2A bridge
  set it on the session. A2A captures it in middleware through an
  async-local, so detached SDK work still sees it.
- The worker no longer lets an account-less external turn inherit the
  previous writer's account: after an operator posted into a Telegram
  session, the Telegram user's turns ran as that operator. System,
  scheduled, automation, and background-continuation turns still act on
  the session's behalf and keep its identity.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…admin

Sessions are keyed only by id, so any operator who knew a session id
could post into another account's conversation.

- Session.OwnerAccountId is set once, when a signed-in account creates
  the session (pipeline, /v1/*, /apps/chat, A2A), and never reassigned.
- SessionAccess.CanWrite: owned sessions accept the owner or an admin;
  unowned sessions (channels, cron, older data) stay open and are never
  claimed by writing to them; callers without an account (bootstrap,
  open loopback, channel and system turns) are not restricted.
- Enforced on every write surface: REST and MCP send_message refuse
  before queueing (403 / tool error), the worker refuses pipeline turns
  including /ws with a reply, /apps/chat returns 403, and the A2A bridge
  emits an error event. Admin status travels with the message because
  OIDC admins are not in the account store.
- Reads are unchanged. GET /api/integration/sessions?owner=me lists the
  caller's own sessions, backed by an owner filter in the file and
  SQLite stores and in the shared summary filter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
POST /apps/chat was the only turn surface that neither took the session
lock nor saved the session afterwards. 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 restart could
lose it.

Hold the session lock from the ownership check through the turn, as the
A2A bridge does, and save the session when the turn ends, including
when the client disconnects mid-turn.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings September 28, 2026 23:08

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 59 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 6fbbe5b1-e994-43e2-9581-0abc733e1cd2

📥 Commits

Reviewing files that changed from the base of the PR and between 03d9a76 and b38f089.

📒 Files selected for processing (43)
  • CHANGELOG.md
  • docs/AUTHENTICATION.md
  • docs/MCPAPP.md
  • docs/zh-CN/AUTHENTICATION.md
  • docs/zh-CN/MCPAPP.md
  • src/OpenClaw.Channels/WebSocketChannel.cs
  • src/OpenClaw.Companion/ViewModels/MainWindowViewModel.cs
  • src/OpenClaw.Core/Memory/FileMemoryStore.cs
  • src/OpenClaw.Core/Memory/SqliteMemoryStore.cs
  • src/OpenClaw.Core/Models/AdminApiModels.cs
  • src/OpenClaw.Core/Models/GatewayConfig.cs
  • src/OpenClaw.Core/Models/Messages.cs
  • src/OpenClaw.Core/Models/Session.cs
  • src/OpenClaw.Core/Models/SessionAdminModels.cs
  • src/OpenClaw.Core/Sessions/SessionAccess.cs
  • src/OpenClaw.Core/Sessions/SessionManager.cs
  • src/OpenClaw.Gateway/A2A/A2ACallerContext.cs
  • src/OpenClaw.Gateway/A2A/A2AEndpointExtensions.cs
  • src/OpenClaw.Gateway/A2A/OpenClawA2AExecutionBridge.cs
  • src/OpenClaw.Gateway/Composition/IntegrationApiFacade.cs
  • src/OpenClaw.Gateway/Composition/SessionAdminListing.cs
  • src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Recovery.cs
  • src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Sessions.cs
  • src/OpenClaw.Gateway/Endpoints/AdminEndpoints.Support.cs
  • src/OpenClaw.Gateway/Endpoints/AppsEndpoints.cs
  • src/OpenClaw.Gateway/Endpoints/EndpointHelpers.cs
  • src/OpenClaw.Gateway/Endpoints/IntegrationEndpoints.cs
  • src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.ChatCompletions.cs
  • src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.Responses.cs
  • src/OpenClaw.Gateway/Endpoints/OpenAiEndpoints.cs
  • src/OpenClaw.Gateway/Endpoints/WebSocketEndpoints.cs
  • src/OpenClaw.Gateway/Extensions/GatewayInboundMessageWorker.cs
  • src/OpenClaw.Gateway/Extensions/TurnIdentity.cs
  • src/OpenClaw.Gateway/Mcp/OpenClawMcpTools.cs
  • src/OpenClaw.Tests/AppsEndpointsTests.cs
  • src/OpenClaw.Tests/CompanionConnectionTests.cs
  • src/OpenClaw.Tests/EndpointHelpersAuthenticationTests.cs
  • src/OpenClaw.Tests/GatewayAdminEndpointTests.cs
  • src/OpenClaw.Tests/GatewayWorkersTests.cs
  • src/OpenClaw.Tests/SessionOwnershipTests.cs
  • src/OpenClaw.Tests/TurnIdentityTests.cs
  • src/OpenClaw.Tests/WebSocketChannelTests.cs
  • src/OpenClaw.Tests/WebSocketEndpointsTests.cs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Telli and others added 5 commits September 29, 2026 03:58
…role

Companion's preflight refused to open chat for any gateway-reported role
below operator. With Security.AllowViewerAgentExecution on, the gateway
still admits those viewers, so the documented migration switch did not
work for Companion users.

GET /auth/session now reports canExecuteAgent, computed by the same rule
CanExecuteAgent enforces, including the opt-out. Companion blocks only
when the gateway says false; when the field is absent (older gateways)
it connects and lets the gateway decide.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…p-authenticated-account

# Conflicts:
#	CHANGELOG.md
- /ws resolved its account with a helper that returned nothing on a
  loopback-bound gateway, even with AlwaysRequireAuth or OIDC. Those
  turns carried no account, so the owner check treated them as
  accountless and let them write anywhere, and new sessions had no
  owner. /ws now uses the same caller resolution as the other surfaces.
- /v1/* stable sessions are keyed by the bearer token's hash or, for
  browser sessions, the client address. Two signed-in accounts behind
  one address derived the same session and the second ran the first's
  conversation. Both endpoints now apply the owner check under the
  session lock and return 403 session_forbidden.
- Session management (delete, metadata, abort, branch restore, guided
  recovery) stayed operator-wide, so another operator could delete an
  owned session and recreate the id as its own. Those routes now follow
  the owner-or-admin rule.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Telli
Telli changed the base branch from feat/session-ownership to main September 29, 2026 12:11
@Telli

Telli commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Reopening to run CI against main.

@Telli Telli closed this Sep 29, 2026
@Telli Telli reopened this Sep 29, 2026
@Telli
Telli merged commit 603b567 into main Sep 29, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants