Skip to content

feat(gateway): add session message mutations#65337

Closed
Trojaner wants to merge 1 commit intoopenclaw:mainfrom
Trojaner:webchat-ui-foundation
Closed

feat(gateway): add session message mutations#65337
Trojaner wants to merge 1 commit intoopenclaw:mainfrom
Trojaner:webchat-ui-foundation

Conversation

@Trojaner
Copy link
Copy Markdown

Summary

  • Add gateway RPC handlers for session-level message mutations: fork, edit, delete, regenerate, continue, and version navigation
  • Add workspace file serving (/__file__/) and image proxy (/__image_proxy__/) HTTP endpoints for webchat media
  • Add cumulative assistant text stripping for branch-aware message reading
  • Suppress cron-event heartbeats from webchat surfaces
  • Add activeLeafId to SessionEntry for branched session navigation
  • Add provider field to AgentRunContext for cron-event identification

New files

  • src/gateway/server-methods/sessions-messages.ts -- Core mutation handlers (fork, edit, delete, versions, switch-version, regenerate, continue)
  • src/gateway/session-branch-reader.ts -- Branch-aware message reading via SessionManager
  • src/gateway/session-cumulative-text.ts -- Strips cumulative assistant text prefixes from tool-call chains
  • src/gateway/chat-image-transform.ts -- Transforms local file paths in markdown images to gateway-served URLs
  • src/gateway/control-ui-file-serving.ts -- Workspace file serving and remote image proxy endpoints

Test plan

  • TypeScript compilation passes (0 gateway errors)
  • 44 unit tests pass across 5 test files
  • Manual verification of fork/edit/delete/regenerate/continue via webchat UI
  • Verify file serving and image proxy endpoints work end-to-end

🤖 Generated with Claude Code

…enerate, continue, versions)

Port backend gateway infrastructure for message-level operations:
- sessions.fork: fork chat sessions at any message entry
- sessions.messages.edit: inline editing via branching
- sessions.messages.delete: turn deletion via branching
- sessions.messages.versions: list version siblings for an entry
- sessions.messages.switch-version: switch active branch to a different version
- chat.regenerate: re-generate from the last user message
- chat.continue: continue / prefill assistant responses
- Suppress cron-event heartbeats in webchat context
- Add workspace file serving (/__file__/) and image proxy (/__image_proxy__/) endpoints
- Add cumulative assistant text stripping for branch-aware message reading

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@openclaw-barnacle openclaw-barnacle bot added app: web-ui App: web-ui gateway Gateway runtime size: XL labels Apr 12, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 12, 2026

Greptile Summary

This PR adds branch-aware session message mutations (fork, edit, delete, regenerate, continue, version navigation) to the gateway RPC layer, along with workspace file serving (/__file__/) and an image proxy (/__image_proxy__/) for webchat media.

  • P1 security in control-ui-file-serving.ts: the image proxy uses redirect: \"follow\", which allows an open redirect on any public server to bypass the SSRF hostname blocklist. Switch to redirect: \"manual\" and refuse redirects (or re-validate each hop).
  • Two P2 concerns: the chat.continue entryId path calls manager.branch() in-memory without persisting a branch marker to the transcript (contrast with regenerate which writes openclaw:branch-regenerate), and the _rewriteFile() private method is accessed via unsafe cast in the fork handler.

Confidence Score: 4/5

Safe to merge after addressing the redirect-based SSRF in the image proxy; remaining findings are P2.

One P1 security issue (SSRF via redirect bypass in handleImageProxyRequest) needs to be fixed before merge. The rest of the changes are well-structured: branch re-stitching logic is solid, tests cover the key edit/delete scenarios, and the file serving endpoint has appropriate security guards.

src/gateway/control-ui-file-serving.ts — the handleImageProxyRequest SSRF via redirect should be addressed.

Security Review

  • SSRF via redirect bypass (src/gateway/control-ui-file-serving.ts): handleImageProxyRequest uses redirect: \"follow\" when fetching upstream images but only validates the original URL's hostname against the private-address blocklist. A public server that issues an HTTP redirect to a private or metadata address (e.g. http://169.254.169.254/) will cause the gateway to make requests to those addresses. The content-type filter reduces data exfiltration risk but does not prevent the gateway from hitting internal HTTP endpoints. Recommend switching to redirect: \"manual\" and refusing or re-validating the Location header before following any redirect.

Comments Outside Diff (1)

  1. src/infra/agent-events.ts, line 151-162 (link)

    P2 New provider field is not merged on re-registration

    The PR adds provider to AgentRunContext and server-chat.ts relies on runContext.provider === "cron-event" to suppress heartbeat output. However the re-registration merge path only updates sessionKey, verboseLevel, isControlUiVisible, and isHeartbeatprovider is never updated here. If registerAgentRunContext is called a second time for the same runId with a provider value, the existing context retains whatever provider (or lack thereof) was set on first registration.

    This is consistent with how the first-registration spread works, but the asymmetry with isHeartbeat (which is updated on re-registration) may be unintentional. If cron-event runs are always registered with provider on their very first call, this is fine; if any code path calls registerAgentRunContext again with provider: "cron-event" expecting it to take effect, the suppression check in server-chat.ts will silently fail.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/infra/agent-events.ts
    Line: 151-162
    
    Comment:
    **New `provider` field is not merged on re-registration**
    
    The PR adds `provider` to `AgentRunContext` and `server-chat.ts` relies on `runContext.provider === "cron-event"` to suppress heartbeat output. However the re-registration merge path only updates `sessionKey`, `verboseLevel`, `isControlUiVisible`, and `isHeartbeat``provider` is never updated here. If `registerAgentRunContext` is called a second time for the same `runId` with a `provider` value, the existing context retains whatever `provider` (or lack thereof) was set on first registration.
    
    This is consistent with how the first-registration spread works, but the asymmetry with `isHeartbeat` (which *is* updated on re-registration) may be unintentional. If cron-event runs are always registered with `provider` on their very first call, this is fine; if any code path calls `registerAgentRunContext` again with `provider: "cron-event"` expecting it to take effect, the suppression check in `server-chat.ts` will silently fail.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/gateway/control-ui-file-serving.ts
Line: 305-317

Comment:
**SSRF via redirect bypass**

The hostname blocklist (lines 269–292) is applied to the *original* URL only. Because `redirect: "follow"` is used, an open redirect on any public host (e.g. `https://public.example.com/r?url=http://169.254.169.254/...`) will cause the gateway to silently forward requests to private/metadata addresses that the blocklist is meant to block. The content-type filter limits data exfiltration but does not prevent the gateway from making the request itself, which is still a Server-Side Request Forgery.

Use `redirect: "manual"` (or `redirect: "error"`) and re-validate the `Location` header hostname before following, or simply refuse all redirects:

```typescript
upstream = await fetch(targetUrl, {
  signal: controller.signal,
  redirect: "manual",   // never follow automatically
  headers: {
    Accept: "image/*",
    "User-Agent": "OpenClaw-ImageProxy/1.0",
  },
});

if (upstream.status >= 300 && upstream.status < 400) {
  res.statusCode = 403;
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
  res.end("Forbidden: redirects are not allowed");
  return true;
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/gateway/server-methods/sessions-messages.ts
Line: 215-219

Comment:
**Private method accessed via unsafe cast**

`_rewriteFile()` is a private internal of `SessionManager` reached through `as unknown as { _rewriteFile(): void }`. If the upstream library renames or removes this method, the cast will still compile but the runtime call will throw `TypeError: manager._rewriteFile is not a function`, surfacing as a confusing `UNAVAILABLE` error for the fork caller.

If immediate file persistence is needed, consider asking the library to expose a public `flush()` / `save()` API, or document this as a workaround with a TODO to upstream the feature.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/infra/agent-events.ts
Line: 151-162

Comment:
**New `provider` field is not merged on re-registration**

The PR adds `provider` to `AgentRunContext` and `server-chat.ts` relies on `runContext.provider === "cron-event"` to suppress heartbeat output. However the re-registration merge path only updates `sessionKey`, `verboseLevel`, `isControlUiVisible`, and `isHeartbeat``provider` is never updated here. If `registerAgentRunContext` is called a second time for the same `runId` with a `provider` value, the existing context retains whatever `provider` (or lack thereof) was set on first registration.

This is consistent with how the first-registration spread works, but the asymmetry with `isHeartbeat` (which *is* updated on re-registration) may be unintentional. If cron-event runs are always registered with `provider` on their very first call, this is fine; if any code path calls `registerAgentRunContext` again with `provider: "cron-event"` expecting it to take effect, the suppression check in `server-chat.ts` will silently fail.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/gateway/server-methods/sessions-messages.ts
Line: 888-908

Comment:
**In-memory `manager.branch()` not persisted before `chat.send`**

When `entryId` is provided, `manager.branch(targetEntry.id)` updates the in-memory manager's leaf position, but nothing is written to the transcript before `chatHandlers["chat.send"]` is called. `chat.send` will open a *new* `SessionManager` from the same file, so it will see the transcript's natural (last-appended) leaf — not the branch set here.

The regenerate handler handles this correctly by writing a persistent marker entry:
```ts
manager.appendCustomEntry("openclaw:branch-regenerate", { ... });
```

The continue handler with `entryId` relies entirely on `activeLeafId` in the session store being respected by `chat.send`. If `chat.send` derives history from `SessionManager.getLeafId()` (the transcript's natural leaf) rather than from `activeLeafId`, the continue-from-entry feature will silently use the wrong branch context.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(gateway): add session message mutat..." | Re-trigger Greptile

Comment on lines +305 to +317
try {
upstream = await fetch(targetUrl, {
signal: controller.signal,
redirect: "follow",
headers: {
Accept: "image/*",
"User-Agent": "OpenClaw-ImageProxy/1.0",
},
});
} finally {
clearTimeout(timeout);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security SSRF via redirect bypass

The hostname blocklist (lines 269–292) is applied to the original URL only. Because redirect: "follow" is used, an open redirect on any public host (e.g. https://public.example.com/r?url=http://169.254.169.254/...) will cause the gateway to silently forward requests to private/metadata addresses that the blocklist is meant to block. The content-type filter limits data exfiltration but does not prevent the gateway from making the request itself, which is still a Server-Side Request Forgery.

Use redirect: "manual" (or redirect: "error") and re-validate the Location header hostname before following, or simply refuse all redirects:

upstream = await fetch(targetUrl, {
  signal: controller.signal,
  redirect: "manual",   // never follow automatically
  headers: {
    Accept: "image/*",
    "User-Agent": "OpenClaw-ImageProxy/1.0",
  },
});

if (upstream.status >= 300 && upstream.status < 400) {
  res.statusCode = 403;
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
  res.end("Forbidden: redirects are not allowed");
  return true;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/control-ui-file-serving.ts
Line: 305-317

Comment:
**SSRF via redirect bypass**

The hostname blocklist (lines 269–292) is applied to the *original* URL only. Because `redirect: "follow"` is used, an open redirect on any public host (e.g. `https://public.example.com/r?url=http://169.254.169.254/...`) will cause the gateway to silently forward requests to private/metadata addresses that the blocklist is meant to block. The content-type filter limits data exfiltration but does not prevent the gateway from making the request itself, which is still a Server-Side Request Forgery.

Use `redirect: "manual"` (or `redirect: "error"`) and re-validate the `Location` header hostname before following, or simply refuse all redirects:

```typescript
upstream = await fetch(targetUrl, {
  signal: controller.signal,
  redirect: "manual",   // never follow automatically
  headers: {
    Accept: "image/*",
    "User-Agent": "OpenClaw-ImageProxy/1.0",
  },
});

if (upstream.status >= 300 && upstream.status < 400) {
  res.statusCode = 403;
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
  res.end("Forbidden: redirects are not allowed");
  return true;
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +215 to +219
// Force-write the file now so that history loading works immediately after fork,
// even for sessions that only contain user messages.
if (!fs.existsSync(newFile)) {
(manager as unknown as { _rewriteFile(): void })._rewriteFile();
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Private method accessed via unsafe cast

_rewriteFile() is a private internal of SessionManager reached through as unknown as { _rewriteFile(): void }. If the upstream library renames or removes this method, the cast will still compile but the runtime call will throw TypeError: manager._rewriteFile is not a function, surfacing as a confusing UNAVAILABLE error for the fork caller.

If immediate file persistence is needed, consider asking the library to expose a public flush() / save() API, or document this as a workaround with a TODO to upstream the feature.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server-methods/sessions-messages.ts
Line: 215-219

Comment:
**Private method accessed via unsafe cast**

`_rewriteFile()` is a private internal of `SessionManager` reached through `as unknown as { _rewriteFile(): void }`. If the upstream library renames or removes this method, the cast will still compile but the runtime call will throw `TypeError: manager._rewriteFile is not a function`, surfacing as a confusing `UNAVAILABLE` error for the fork caller.

If immediate file persistence is needed, consider asking the library to expose a public `flush()` / `save()` API, or document this as a workaround with a TODO to upstream the feature.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +888 to +908

// If a specific entry is given, branch from it
if (typeof p.entryId === "string" && p.entryId.trim()) {
const targetEntry = manager.getEntry(p.entryId.trim());
if (!targetEntry) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "entry not found"));
return;
}
manager.branch(targetEntry.id);
await updateSessionStore(
resolveGatewaySessionStoreTarget({ cfg: loadConfig(), key: canonicalKey }).storePath,
(store) => {
if (store[canonicalKey]) {
store[canonicalKey] = {
...store[canonicalKey],
activeLeafId: targetEntry.id,
updatedAt: Date.now(),
};
}
},
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 In-memory manager.branch() not persisted before chat.send

When entryId is provided, manager.branch(targetEntry.id) updates the in-memory manager's leaf position, but nothing is written to the transcript before chatHandlers["chat.send"] is called. chat.send will open a new SessionManager from the same file, so it will see the transcript's natural (last-appended) leaf — not the branch set here.

The regenerate handler handles this correctly by writing a persistent marker entry:

manager.appendCustomEntry("openclaw:branch-regenerate", { ... });

The continue handler with entryId relies entirely on activeLeafId in the session store being respected by chat.send. If chat.send derives history from SessionManager.getLeafId() (the transcript's natural leaf) rather than from activeLeafId, the continue-from-entry feature will silently use the wrong branch context.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server-methods/sessions-messages.ts
Line: 888-908

Comment:
**In-memory `manager.branch()` not persisted before `chat.send`**

When `entryId` is provided, `manager.branch(targetEntry.id)` updates the in-memory manager's leaf position, but nothing is written to the transcript before `chatHandlers["chat.send"]` is called. `chat.send` will open a *new* `SessionManager` from the same file, so it will see the transcript's natural (last-appended) leaf — not the branch set here.

The regenerate handler handles this correctly by writing a persistent marker entry:
```ts
manager.appendCustomEntry("openclaw:branch-regenerate", { ... });
```

The continue handler with `entryId` relies entirely on `activeLeafId` in the session store being respected by `chat.send`. If `chat.send` derives history from `SessionManager.getLeafId()` (the transcript's natural leaf) rather than from `activeLeafId`, the continue-from-entry feature will silently use the wrong branch context.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b8d47805f6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1046 to +1050
requestStages.push({
name: "workspace-file",
run: () =>
handleWorkspaceFileRequest(req, res, {
basePath: controlUiBasePath,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce auth before registering file/image proxy handlers

These stages are added directly to the HTTP pipeline without any preceding authorizeGatewayHttpRequestOrReply check, yet both handlers explicitly state they rely on gateway-level auth. In deployments where the gateway is reachable beyond loopback, this exposes /__file__/... and /__image_proxy__/... to unauthenticated callers and enables arbitrary media-file reads or proxy fetches without a bearer token.

Useful? React with 👍 / 👎.

Comment on lines +306 to +309
upstream = await fetch(targetUrl, {
signal: controller.signal,
redirect: "follow",
headers: {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Revalidate proxy destination after following redirects

The SSRF guard only validates the original targetUrl, but fetch is configured with redirect: "follow", so an attacker-controlled public URL can 30x-redirect to internal hosts (for example metadata or localhost services) after validation has passed. Even if the response is later rejected by content-type checks, the internal request is already made, which defeats the intended private-network block.

Useful? React with 👍 / 👎.

Comment on lines +974 to +977
// Store the assistant prefill so chat.send can thread it into replyOptions
if (assistantPrefillText) {
pendingAssistantPrefill.set(idempotencyKey, assistantPrefillText);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Thread continue prefill into chat.send execution path

chat.continue stores extracted/explicit prefill text in pendingAssistantPrefill, but there is no corresponding read in chat.send (or elsewhere), so the model only sees the placeholder user message ("Continue") instead of the intended assistant prefill. This breaks explicit “start reply with” behavior and leaves stale entries in the map over time.

Useful? React with 👍 / 👎.

@Trojaner Trojaner closed this Apr 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: web-ui App: web-ui gateway Gateway runtime size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant