feat(gateway): add session message mutations#65337
feat(gateway): add session message mutations#65337Trojaner wants to merge 1 commit intoopenclaw:mainfrom
Conversation
…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]>
Greptile SummaryThis PR adds branch-aware session message mutations (fork, edit, delete, regenerate, continue, version navigation) to the gateway RPC layer, along with workspace file serving (
Confidence Score: 4/5Safe 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.
|
| try { | ||
| upstream = await fetch(targetUrl, { | ||
| signal: controller.signal, | ||
| redirect: "follow", | ||
| headers: { | ||
| Accept: "image/*", | ||
| "User-Agent": "OpenClaw-ImageProxy/1.0", | ||
| }, | ||
| }); | ||
| } finally { | ||
| clearTimeout(timeout); | ||
| } | ||
|
|
There was a problem hiding this comment.
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.| // 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(); | ||
| } |
There was a problem hiding this 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.
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.|
|
||
| // 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(), | ||
| }; | ||
| } | ||
| }, | ||
| ); |
There was a problem hiding this 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:
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.There was a problem hiding this comment.
💡 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".
| requestStages.push({ | ||
| name: "workspace-file", | ||
| run: () => | ||
| handleWorkspaceFileRequest(req, res, { | ||
| basePath: controlUiBasePath, |
There was a problem hiding this comment.
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 👍 / 👎.
| upstream = await fetch(targetUrl, { | ||
| signal: controller.signal, | ||
| redirect: "follow", | ||
| headers: { |
There was a problem hiding this comment.
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 👍 / 👎.
| // Store the assistant prefill so chat.send can thread it into replyOptions | ||
| if (assistantPrefillText) { | ||
| pendingAssistantPrefill.set(idempotencyKey, assistantPrefillText); | ||
| } |
There was a problem hiding this comment.
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 👍 / 👎.
Summary
/__file__/) and image proxy (/__image_proxy__/) HTTP endpoints for webchat mediaactiveLeafIdtoSessionEntryfor branched session navigationproviderfield toAgentRunContextfor cron-event identificationNew 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 SessionManagersrc/gateway/session-cumulative-text.ts-- Strips cumulative assistant text prefixes from tool-call chainssrc/gateway/chat-image-transform.ts-- Transforms local file paths in markdown images to gateway-served URLssrc/gateway/control-ui-file-serving.ts-- Workspace file serving and remote image proxy endpointsTest plan
🤖 Generated with Claude Code