Skip to content

fix(acp): persist spawned child session history#40137

Merged
mbelinky merged 5 commits intomainfrom
mbelinky/fix-acp-child-session-history
Mar 8, 2026
Merged

fix(acp): persist spawned child session history#40137
mbelinky merged 5 commits intomainfrom
mbelinky/fix-acp-child-session-history

Conversation

@mbelinky
Copy link
Contributor

@mbelinky mbelinky commented Mar 8, 2026

Summary

  • Problem: ACP spawned child sessions could execute and emit output, but their transcripts were not reliably persisted, so sessions_history could come back empty.
  • Why it matters: successful ACP runs looked broken to callers because follow-up tooling could not read child history, and ACP children also did not participate correctly in spawned-session tree visibility.
  • What changed: persist ACP child turn transcripts, preserve exact transcript text, record spawnedBy for ACP children, and share transcript-path/session-file resolution with best-effort spawn-time persistence.
  • What did NOT change (scope boundary): ACP execution/runtime behavior, delivery routing beyond the existing oneshot fix, and non-ACP session persistence.

Change Type (select all)

  • Bug fix
  • Refactor

Scope (select all touched areas)

  • Gateway / orchestration
  • Memory / storage
  • API / contracts

Linked Issue/PR

User-visible / Behavior Changes

  • sessions_history for successful ACP child runs now returns persisted transcript history instead of an empty result.
  • ACP child sessions now participate in spawned-session lineage via spawnedBy.
  • ACP transcript history preserves exact leading/trailing whitespace from the initiating prompt and final assistant reply.
  • Spawn-time transcript-path persistence failures no longer block ACP execution; the run still starts and logs a warning.

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (No)
  • If any Yes, explain risk + mitigation:

Repro + Verification

Environment

  • OS: macOS locally; Ubuntu on mb-server
  • Runtime/container: Node via pnpm
  • Model/provider: ACP runtime tests with mocked ACP manager
  • Integration/channel (if any): ACP spawn + session history paths
  • Relevant config (redacted): default test config with acp.enabled=true, backend="acpx"

Steps

  1. Spawn an ACP child run.
  2. Let the task complete successfully.
  3. Query sessions_history for the child session.

Expected

  • Child history is persisted and readable.
  • ACP children are visible as spawned children.
  • Transcript text matches the exact prompt/reply content.

Actual

  • Before this fix, child history could be empty even when the task ran successfully.

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

  • Verified scenarios:
    • Local: pnpm exec vitest run src/commands/agent.acp.test.ts src/agents/acp-spawn.test.ts src/agents/tools/sessions-spawn-tool.test.ts --maxWorkers=1
    • Local: NODE_OPTIONS=--max-old-space-size=8192 pnpm exec tsc -p tsconfig.json --noEmit
    • mb-server: pnpm exec vitest run src/commands/agent.acp.test.ts src/agents/acp-spawn.test.ts src/agents/tools/sessions-spawn-tool.test.ts --maxWorkers=3
    • mb-server: NODE_OPTIONS=--max-old-space-size=8192 pnpm exec tsc -p tsconfig.json --noEmit
  • Edge cases checked:
    • ACP spawn still succeeds when spawn-time session-file persistence fails.
    • Thread-bound ACP sessions resolve a thread-specific transcript path.
    • Exact whitespace is preserved in ACP transcript history.
  • What you did not verify:
    • Full end-to-end Telegram/Discord manual flow in CI.

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? (Yes)
  • Config/env changes? (No)
  • Migration needed? (No)
  • If yes, exact upgrade steps:

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly: revert the three ACP commits in this PR.
  • Files/config to restore: src/commands/agent.ts, src/agents/acp-spawn.ts, src/config/sessions/transcript.ts
  • Known bad symptoms reviewers should watch for: ACP children missing from spawned-session trees, empty sessions_history, or ACP spawn unexpectedly failing on transcript-path storage issues.

Risks and Mitigations

  • Risk: best-effort spawn-time transcript persistence could mask storage-path problems until later transcript writes.
    • Mitigation: it emits a warning and keeps execution available; runtime transcript persistence still exercises the same path and tests cover the degraded case.

@aisle-research-bot
Copy link

aisle-research-bot bot commented Mar 8, 2026

🔒 Aisle Security Analysis

We found 2 potential security issue(s) in this PR:

# Severity Title
1 🟠 High Prototype pollution via unvalidated sessionKey used as plain-object key in session store writes
2 🔵 Low TOCTOU + symlink-following in ACP transcript persistence can overwrite/truncate arbitrary files

1. 🟠 Prototype pollution via unvalidated sessionKey used as plain-object key in session store writes

Property Value
Severity High
CWE CWE-1321
Location src/commands/agent.ts:127-140

Description

The session store is represented as a plain JavaScript object (Record<string, SessionEntry>) and is indexed and written using sessionKey directly.

Because JavaScript objects treat certain property names specially (notably __proto__), an attacker who can control sessionKey can trigger prototype mutation/pollution when the code assigns store[sessionKey] = ....

Why this is exploitable:

  • Input control: Some ingress paths accept a caller-supplied session key (e.g., HTTP header x-openclaw-session-key via resolveGatewayRequestContext() used by openresponses-http.ts, and RPC params like send.sessionKey). These values are not guaranteed to be canonicalized to agent:<id>:... before reaching the session-store mutation helpers.
  • Sink: persistSessionEntry() writes store[params.sessionKey] = merged; inside updateSessionStore().
  • If params.sessionKey is "__proto__", the assignment can invoke the __proto__ setter on Object.prototype, changing the prototype of the in-memory store object and potentially impacting subsequent logic that relies on property lookups.

Vulnerable code:

const persisted = await updateSessionStore(params.storePath, (store) => {
  const merged = mergeSessionEntry(store[params.sessionKey], params.entry);// ...
  store[params.sessionKey] = merged;
  return merged;
});

Impact depends on surrounding usage, but prototype mutation can lead to:

  • unexpected behavior when reading keys from the store (prototype chain interference)
  • denial of service (hard-to-debug crashes/logic errors)
  • unsafe interactions with other code that assumes a “normal” object prototype

Recommendation

Harden the session store against unsafe keys. Prefer a null-prototype dictionary (or Map) for all session-store objects, and/or canonicalize/validate session keys before any indexing.

Option A: Use a null-prototype store object

Convert parsed stores to Object.create(null) and ensure all in-memory stores retain a null prototype.

function toNullProtoStore<T>(obj: Record<string, T>): Record<string, T> {
  const out: Record<string, T> = Object.create(null);
  for (const [k, v] of Object.entries(obj)) out[k] = v;
  return out;
}// after JSON.parse(raw)
const parsed = JSON.parse(raw);
if (isSessionStoreRecord(parsed)) {
  store = toNullProtoStore(parsed);
}

Option B: Validate/normalize keys at boundaries

Reject keys that can mutate prototypes when used as property names:

const FORBIDDEN_KEYS = new Set(["__proto__", "prototype", "constructor"]);
function assertSafeSessionKey(key: string) {
  const k = key.trim().toLowerCase();
  if (FORBIDDEN_KEYS.has(k)) throw new Error("invalid sessionKey");
}

assertSafeSessionKey(params.sessionKey);

Additionally, consider canonicalizing explicit session keys into the safe agent:<id>:... form before persisting to the store.


2. 🔵 TOCTOU + symlink-following in ACP transcript persistence can overwrite/truncate arbitrary files

Property Value
Severity Low
CWE CWE-367
Location src/commands/agent.ts:273-293

Description

The ACP transcript persistence flow performs a time-of-check/time-of-use pattern and then writes to the transcript path without defending against symlinks.

  • persistAcpTurnTranscript() checks existence via fs.access(sessionFile) and stores the result in hadSessionFile.
  • It then opens the path (SessionManager.open(sessionFile)) and calls prepareSessionManagerForRun().
  • prepareSessionManagerForRun() may truncate the transcript path using fs.writeFile(sessionFile, "").

Because the path is opened/written by pathname and no lstat/O_NOFOLLOW/inode verification is performed, an attacker with write access to the transcripts directory (e.g., due to permissive directory permissions or a shared/writable state directory) can replace sessionFile with a symlink between the access check and subsequent writes, causing the process to append to or truncate an arbitrary file accessible to the running user.

Vulnerable code (TOCTOU + unsafe open-by-path):

const hadSessionFile = await fs
  .access(sessionFile)
  .then(() => true)
  .catch(() => false);
const sessionManager = SessionManager.open(sessionFile);
await prepareSessionManagerForRun({ sessionManager, sessionFile, hadSessionFile, ... });

Recommendation

Eliminate the pre-check/open TOCTOU and add symlink-resistant file handling for transcript paths.

Recommended defense-in-depth:

  1. Reject symlinks before opening/truncating:
import fs from "node:fs/promises";

async function assertNotSymlink(p: string) {
  const st = await fs.lstat(p).catch(() => null);
  if (st?.isSymbolicLink()) throw new Error(`Refusing to write to symlink: ${p}`);
}

await assertNotSymlink(sessionFile);
  1. If you control the low-level open inside transcript persistence, open with O_NOFOLLOW (when available) and use the resulting fd for writes. Node example:
import fs from "node:fs/promises";
import fssync from "node:fs";

const flags = fssync.constants.O_CREAT | fssync.constants.O_APPEND | fssync.constants.O_WRONLY |
  (typeof fssync.constants.O_NOFOLLOW === "number" ? fssync.constants.O_NOFOLLOW : 0);

const fh = await fs.open(sessionFile, flags, 0o600);
try {// write using fh.write / fh.appendFile
} finally {
  await fh.close();
}
  1. Avoid truncating-by-path; if truncation is necessary, do it using an fd opened safely (or write a new file and atomic-rename into place).

  2. Ensure transcript directories are created with restrictive permissions (e.g., 0o700) to prevent untrusted users from placing symlinks in the first place.


Analyzed PR: #40137 at commit 62de5d5

Last updated on: 2026-03-08T20:00:21Z

@openclaw-barnacle openclaw-barnacle bot added commands Command implementations agents Agent runtime and tooling size: M maintainer Maintainer-authored PR labels Mar 8, 2026
@mbelinky mbelinky self-assigned this Mar 8, 2026
Copy link

@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: 2303a042d1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

method: "sessions.patch",
params: {
key: sessionKey,
spawnedBy: requesterInternalKey,

Choose a reason for hiding this comment

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

P1 Badge Drop spawnedBy from ACP session patch payload

Adding spawnedBy to the ACP sessions.patch request makes every ACP spawn fail at runtime: spawnAcpDirect creates keys like agent:<id>:acp:<uuid>, but applySessionsPatchToStore currently rejects spawnedBy unless the key is a subagent:* session (src/gateway/sessions-patch.ts:100-102). In environments using the real gateway (not mocked tests), this patch call returns an error and the spawn exits through the error path before initialization.

Useful? React with 👍 / 👎.

Comment on lines +413 to +417
if (sessionId) {
sessionEntry = await persistAcpSpawnSessionFileBestEffort({
sessionId,
sessionKey,
sessionStore,

Choose a reason for hiding this comment

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

P2 Badge Avoid persisting transcript path before ACP thread binding

For thread=true ACP spawns, this early persistence writes a non-thread session file before the child thread ID is known; the later thread-bind pass cannot correct it because resolveSessionTranscriptFile only applies the thread-based fallback when sessionEntry.sessionFile is absent (src/config/sessions/transcript.ts:106-112). The result is that thread-bound ACP sessions keep the generic transcript file instead of a thread-scoped path, which breaks the intended per-thread transcript partitioning.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 8, 2026

Greptile Summary

This PR fixes a reliability gap where ACP child session transcripts were not persisted after successful runs, causing sessions_history to appear empty. The fix adds transcript persistence at turn-level, records spawnedBy lineage for ACP children, and extracts shared transcript resolution logic. Failures at spawn-time and turn-level are handled as best-effort (logged warnings, non-blocking), so ACP execution is never blocked by storage issues.

Confidence Score: 5/5

  • This PR is safe to merge with no identified issues; all failures are handled best-effort and cannot regress existing ACP execution.
  • The PR implements transcript persistence for ACP child sessions with comprehensive test coverage and proper error handling. All changes are well-scoped to ACP-specific logic; failures are caught and logged rather than thrown, ensuring the spawn always proceeds. No security, data-loss, or breaking-change risks are present. The implementation correctly normalizes keys and preserves exact transcript whitespace as intended.
  • No files require special attention. All changes are sound and thoroughly tested.

Last reviewed commit: bb12350

@openclaw-barnacle openclaw-barnacle bot added the cli CLI command changes label Mar 8, 2026
@mbelinky mbelinky force-pushed the mbelinky/fix-acp-child-session-history branch 2 times, most recently from c136435 to f0ea774 Compare March 8, 2026 18:18
@mbelinky mbelinky force-pushed the mbelinky/fix-acp-child-session-history branch from 2257d2c to 62de5d5 Compare March 8, 2026 18:36
@openclaw-barnacle openclaw-barnacle bot removed the cli CLI command changes label Mar 8, 2026
@mbelinky mbelinky merged commit 404b152 into main Mar 8, 2026
23 of 27 checks passed
@mbelinky mbelinky deleted the mbelinky/fix-acp-child-session-history branch March 8, 2026 18:37
@mbelinky
Copy link
Contributor Author

mbelinky commented Mar 8, 2026

Merged via squash.

Thanks @mbelinky!

Copy link

@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: 62de5d5669

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

method: "sessions.patch",
params: {
key: sessionKey,
spawnedBy: requesterInternalKey,

Choose a reason for hiding this comment

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

P1 Badge Remove spawnedBy from ACP sessions.patch payload

This call now always sends spawnedBy for ACP session keys (agent:<id>:acp:<uuid>), but applySessionsPatchToStore rejects spawnedBy unless the key is subagent:* (src/gateway/sessions-patch.ts), so sessions.patch returns an invalid-request error and the spawn falls into the catch/cleanup path before initialization. In practice this makes ACP spawns fail in real gateway environments (the unit test mocks don’t enforce the server-side validator).

Useful? React with 👍 / 👎.

Comment on lines +414 to +421
sessionEntry = await persistAcpSpawnSessionFileBestEffort({
sessionId,
sessionKey,
sessionStore,
storePath,
sessionEntry,
agentId: targetAgentId,
stage: "spawn",

Choose a reason for hiding this comment

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

P2 Badge Avoid persisting ACP transcript path before thread binding

For thread=true ACP spawns, this pre-bind persistence writes a session file path before the child thread ID exists, and the later thread-bind pass cannot correct it because resolveSessionTranscriptFile only applies a thread-based fallback when sessionEntry.sessionFile is absent (src/config/sessions/transcript.ts:106-112). That leaves thread-bound ACP sessions on the generic transcript file instead of a thread-scoped file.

Useful? React with 👍 / 👎.

hhy5562877 pushed a commit to hhy5562877/openclaw that referenced this pull request Mar 8, 2026
Merged via squash.

Prepared head SHA: 62de5d5
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
echennells added a commit to echennells/openclaw that referenced this pull request Mar 8, 2026
PR openclaw#40137 added spawnedBy to ACP session spawn but the validation in
sessions-patch.ts only allowed these fields for subagent:* sessions.
This caused "spawnedBy is only supported for subagent:* sessions" errors
when spawning ACP sessions.

Update validation to allow spawnedBy and spawnDepth for both subagent:*
and acp:* session keys.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
GordonSH-oss pushed a commit to GordonSH-oss/openclaw that referenced this pull request Mar 9, 2026
Merged via squash.

Prepared head SHA: 62de5d5
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
echennells added a commit to echennells/openclaw that referenced this pull request Mar 9, 2026
PR openclaw#40137 added spawnedBy to ACP session spawn but the validation in
sessions-patch.ts only allowed these fields for subagent:* sessions.
This caused "spawnedBy is only supported for subagent:* sessions" errors
when spawning ACP sessions.

Update validation to allow spawnedBy and spawnDepth for both subagent:*
and acp:* session keys.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
jenawant pushed a commit to jenawant/openclaw that referenced this pull request Mar 10, 2026
Merged via squash.

Prepared head SHA: 62de5d5
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling commands Command implementations maintainer Maintainer-authored PR size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: ACP Claude run accepted but child session history stays empty after env is fixed

1 participant