Skip to content

fix: keep system events from extending session resets#71845

Merged
steipete merged 1 commit into
mainfrom
contract-first/runtime-plan-harness-consolidated
Apr 26, 2026
Merged

fix: keep system events from extending session resets#71845
steipete merged 1 commit into
mainfrom
contract-first/runtime-plan-harness-consolidated

Conversation

@steipete

Copy link
Copy Markdown
Contributor

Summary

  • add explicit sessionStartedAt and lastInteractionAt lifecycle timestamps to session rows
  • make daily resets depend on session start and idle resets depend on the last real user/channel interaction, not session-store updatedAt
  • keep heartbeat, cron, exec, and gateway bookkeeping from extending reset freshness while preserving their routing/status writes
  • recover legacy daily reset freshness from the JSONL session header when sessionStartedAt is missing
  • document the new reset semantics across session, heartbeat, cron, config, and reference docs

Fixes

Fixes #68315
Fixes #63732
Fixes #63820
Fixes #69083

Supersedes #53014, #47839, and #51026.

Verification

  • pnpm tsgo --pretty false
  • pnpm test src/config/sessions/sessions.test.ts src/auto-reply/reply/session.heartbeat-no-reset.test.ts src/agents/command/session-store.test.ts src/cron/isolated-agent/session.test.ts src/gateway/server-methods/agent.test.ts
  • pnpm test src/auto-reply/reply/session.heartbeat-no-reset.test.ts src/auto-reply/reply/model-selection.test.ts
  • pnpm test src/gateway/server-methods/agent.test.ts src/gateway/server-methods/chat.directive-tags.test.ts
  • pnpm format:docs:check
  • pnpm docs:check-mdx
  • pnpm lint:docs
  • git diff --check

Note: broad pnpm check:changed hit unrelated/flaky runner failures; the directly rerun affected shards passed.

@chatgpt-codex-connector

Copy link
Copy Markdown
Contributor

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@aisle-research-bot

aisle-research-bot Bot commented Apr 26, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

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

# Severity Title
1 🟠 High Client-controlled sessionId enables session fixation / transcript hijack in gateway agent handler
2 🟡 Medium Session freshness bypass: /command override persistence always updates lastInteractionAt
3 🔵 Low Client-controlled session lifecycle flags allow tampering with lastInteractionAt updates
4 🔵 Low Session freshness can be manipulated via untrusted transcript header timestamp fallback
1. 🟠 Client-controlled sessionId enables session fixation / transcript hijack in gateway agent handler
Property Value
Severity High
CWE CWE-384
Location src/gateway/server-methods/agent.ts:668-675

Description

In agentHandlers.agent.invoke, the gateway will honor a caller-supplied request.sessionId (requestedSessionId) and use it as the effective sessionId for the run when either:

  • the session key has no existing entry (!entry?.sessionId), or
  • the existing session is considered fresh (canReuseSession)

This makes sessionId effectively client-controlled for new/fresh sessions.

Why this is a security issue (in multi-user / multi-tenant deployments):

  • sessionId is the identifier used to locate the underlying transcript file on disk (via resolveSessionFilePath(...) in other code paths).
  • Because the transcript path is derived from sessionId (not from an authenticated principal), a malicious client who learns/guesses another user’s sessionId can supply it and cause the agent run to load/append to the victim’s transcript.
  • The gateway also persists this chosen sessionId into the session store entry for the chosen sessionKey, potentially linking disparate identities and enabling continued access.

Vulnerable code:

const usableRequestedSessionId =
  requestedSessionId && (!entry?.sessionId || canReuseSession)
    ? requestedSessionId
    : undefined;
const sessionId = usableRequestedSessionId
  ? usableRequestedSessionId
  : ((canReuseSession ? entry?.sessionId : undefined) ?? randomUUID());

Even though session IDs are usually UUIDs, they can still be exposed through logs, client-side state, screenshots, shared links, or other leaks; once known, this becomes a direct conversation takeover primitive.

Recommendation

Do not allow unprivileged callers to choose the underlying transcript sessionId.

Options (choose one):

  1. Strict binding (recommended): once a sessionKey exists, always require request.sessionId (if present) to equal the stored entry.sessionId; otherwise reject.
if (requestedSessionId && entry?.sessionId && requestedSessionId !== entry.sessionId) {
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "sessionId does not match sessionKey"));
  return;
}
const sessionId = entry?.sessionId ?? randomUUID();
  1. Privileged override only: only honor request.sessionId for trusted/internal callers (e.g., owner/admin scope), and validate it with validateSessionId() before use.

  2. Derive sessionId server-side: ignore client-provided sessionId entirely for gateway requests; use server-generated IDs and expose them via responses if clients need to resume.

Additionally, consider binding sessions to an authenticated principal (tenant/user) and enforcing authorization on any request that specifies a sessionKey.

2. 🟡 Session freshness bypass: /command override persistence always updates lastInteractionAt
Property Value
Severity Medium
CWE CWE-613
Location src/agents/agent-command.ts:641-652

Description

In agentCommandInternal, the code persists session overrides whenever sessionStore && sessionKey and unconditionally sets lastInteractionAt to now.

This undermines the new lifecycle behavior (introduced elsewhere in this diff) that intends cron/heartbeat/internal system events not to keep sessions alive:

  • updateSessionStoreAfterAgentRun() now supports touchInteraction: false for cron/heartbeat/internal events and will avoid updating lastInteractionAt.
  • However, the earlier override-persistence block in agent-command.ts writes lastInteractionAt: now regardless of run kind or whether any explicit override was actually provided.

Impact:

  • Non-interactive runs that still reach this code path can refresh lastInteractionAt, preventing idle resets/compaction policies from triggering.
  • This can prolong retention of prior conversation context beyond intended limits, increasing risk of cross-turn privacy leaks (stale context reused longer than expected).

Vulnerable code:

const next: SessionEntry = {
  ...entry,
  sessionId,
  updatedAt: now,
  sessionStartedAt: entry.sessionStartedAt ?? now,
  lastInteractionAt: now,
};

Recommendation

Only update lastInteractionAt when the run represents a real user interaction (or when an explicit override is actually present), and ensure cron/heartbeat/internal event runs cannot touch it.

For example:

const now = Date.now();
const hasExplicitOverride = Boolean(thinkOverride || verboseOverride /* || other overrides */);
const shouldTouchInteraction =
  hasExplicitOverride &&
  opts.bootstrapContextRunKind !== "cron" &&
  opts.bootstrapContextRunKind !== "heartbeat" &&
  !opts.internalEvents?.length;

const next: SessionEntry = {
  ...entry,
  sessionId,
  updatedAt: now,
  sessionStartedAt: entry.sessionStartedAt ?? now,
  lastInteractionAt: shouldTouchInteraction ? now : entry.lastInteractionAt,
};

Alternatively, gate the entire persistence block behind hasExplicitOverride if it is intended only for explicit /command overrides.

3. 🔵 Client-controlled session lifecycle flags allow tampering with lastInteractionAt updates
Property Value
Severity Low
CWE CWE-807
Location src/gateway/server-methods/agent.ts:679-682

Description

In agent gateway handler, the decision to advance lastInteractionAt is based on request fields bootstrapContextRunKind and internalEvents.

  • Both fields are accepted from the client request (present in the public RPC schema with additionalProperties: false), so an external caller can set them arbitrarily.
  • The handler uses these fields to compute touchInteraction. If touchInteraction is false, lastInteractionAt is not updated.
  • This allows a caller to influence session freshness tracking:
    • Set bootstrapContextRunKind to "heartbeat"/"cron" or provide any internalEvents to suppress lastInteractionAt updates, making the session appear idle and potentially triggering unexpected resets/rollovers.
    • Conversely, omit these fields (or set bootstrapContextRunKind: "default" and no internalEvents) to ensure lastInteractionAt is updated, potentially keeping a session “fresh” when it should not be.

Vulnerable code:

const touchInteraction =
  request.bootstrapContextRunKind !== "cron" &&
  request.bootstrapContextRunKind !== "heartbeat" &&
  !request.internalEvents?.length;
...
lastInteractionAt: touchInteraction ? now : entry?.lastInteractionAt,

If multiple principals can call the same agent/session key (e.g., shared/global sessions), this becomes a session-lifecycle tampering vector that can impact other users by forcing unexpected resets or preventing expected resets.

Recommendation

Do not trust client input for lifecycle bookkeeping flags.

  • Derive bootstrapContextRunKind and internalEvents from server-side context (e.g., cron scheduler / heartbeat subsystem), not from the external RPC payload.
  • If these fields must remain in the API, enforce authorization and validate them against the authenticated caller type (e.g., only allow "cron"/"heartbeat" from internal/system clients).

Example (conceptual):

const isInternalCaller = client?.connect?.client?.id === GATEWAY_CLIENT_IDS.SYSTEM;
const runKind = isInternalCaller ? request.bootstrapContextRunKind : "default";
const internalEvents = isInternalCaller ? request.internalEvents : undefined;

const touchInteraction = runKind !== "cron" && runKind !== "heartbeat" && !internalEvents?.length;

This prevents external callers from manipulating lastInteractionAt and session freshness behavior.

4. 🔵 Session freshness can be manipulated via untrusted transcript header timestamp fallback
Property Value
Severity Low
CWE CWE-20
Location src/config/sessions/lifecycle.ts:75-88

Description

readSessionHeaderStartedAtMs() reads the first JSONL line of the transcript and uses header.timestamp as the authoritative sessionStartedAt fallback for legacy session rows (missing sessionStartedAt). This value then influences daily reset freshness (evaluateSessionFreshness() uses sessionStartedAt).

If an attacker can modify/delete/recreate transcript files (e.g., due to group-writable state dir, misconfigured OPENCLAW_STATE_DIR, or a compromised local plugin/process running under the same account), they can forge the transcript header timestamp:

  • Future timestamp → keep a session “fresh” across daily resets, causing unintended session reuse/retention.
  • Old timestamp → force frequent resets (availability/UX impact).

Vulnerable code:

return parseTimestampMs(header.timestamp);

There is no plausibility check (e.g., rejecting timestamps far in the future relative to now), and the code treats the transcript header as trusted input.

Recommendation

Treat transcript header timestamps as untrusted and apply sanity bounds before using them for lifecycle decisions.

Suggested hardening:

  • Pass now into the resolver and reject timestamps outside a reasonable window (e.g., older than some max history if desired, and newer than now + allowedClockSkewMs).
  • Prefer store-managed timestamps when available; only use transcript header as a best-effort fallback.
  • Consider enforcing secure directory permissions (e.g., 0700) for the state/sessions directories during creation or via a startup audit.

Example:

function clampHeaderTimestampMs(ts: number | undefined, now: number): number | undefined {
  if (ts == null) return undefined;
  const maxFutureSkewMs = 5 * 60_000;
  if (ts > now + maxFutureSkewMs) return undefined;
  return ts;
}// ...
const parsed = parseTimestampMs(header.timestamp);
return clampHeaderTimestampMs(parsed, Date.now());

Analyzed PR: #71845 at commit 1f42c3f

Last updated on: 2026-04-26T01:31:55Z

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation gateway Gateway runtime agents Agent runtime and tooling labels Apr 26, 2026
@cursor

cursor Bot commented Apr 26, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes core session rollover logic and session-store schema, which can affect when sessions reset and how long-running conversations are preserved; includes legacy transcript-header fallback to reduce migration risk but still touches many entry points (gateway, cron, heartbeat, CLI).

Overview
Session reset freshness is now based on explicit lifecycle timestamps instead of updatedAt. Session rows gain sessionStartedAt (daily freshness) and lastInteractionAt (idle freshness), and evaluateSessionFreshness is updated accordingly.

System/background turns no longer keep sessions “fresh”. Heartbeat/cron/exec/internal bookkeeping paths write updatedAt for routing/status but avoid advancing lastInteractionAt, while interactive runs and explicit resets do; gateway invokeAgent also enforces freshness and won’t honor a caller-provided sessionId when the stored session is stale.

Legacy compatibility is added for older stores. A new resolveSessionLifecycleTimestamps helper can recover missing sessionStartedAt from the transcript JSONL header, and tests/docs are updated to reflect the new semantics and troubleshooting guidance.

Reviewed by Cursor Bugbot for commit 1f42c3f. Bugbot is set up for automated code reviews on this repo. Configure here.

@greptile-apps

greptile-apps Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds sessionStartedAt and lastInteractionAt lifecycle timestamps to session rows and wires them into the daily and idle reset policies, so that heartbeat, cron, exec, and gateway bookkeeping events no longer extend a session's freshness window. A fallback reads the legacy JSONL header to recover sessionStartedAt for sessions that predate this change.

  • The isolated-agent cron path (src/cron/isolated-agent/session.ts line 184) unconditionally sets lastInteractionAt: params.nowMs on every run, advancing the idle expiry timer for every cron tick — directly contradicting the fix applied in the gateway and auto-reply paths. Sessions with an idle-minutes policy will never expire via cron runs.

Confidence Score: 3/5

PR has a genuine behavioural regression in the cron isolated-agent path that reverses the idle-reset fix for sessions managed through that code path.

A single P1 defect is present: cron sessions always advance lastInteractionAt, meaning the idle expiry fix has no effect for the isolated cron agent code path. Every other code path (gateway, auto-reply, agent-command) correctly guards this field, making the inconsistency notable. The rest of the changes are well-structured and thoroughly tested.

src/cron/isolated-agent/session.ts — lastInteractionAt is always set to the current time regardless of whether it is a cron-triggered (non-interactive) run.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/cron/isolated-agent/session.ts
Line: 184

Comment:
**Cron always advances `lastInteractionAt`, defeating idle-reset fix**

Every invocation of `resolveCronSession` unconditionally writes `lastInteractionAt: params.nowMs`. The PR's stated goal is to *keep cron from extending reset freshness*, but this line advances the idle expiry timer on every cron tick for sessions managed through the isolated-agent path. The gateway and auto-reply paths correctly gate this with a `touchInteraction` flag or an `isSystemEvent` guard — the cron path has no equivalent.

When a session is configured with an idle-minutes reset, any cron run will keep the session alive indefinitely, which is the exact bug being fixed everywhere else.

```suggestion
    lastInteractionAt: isNewSession ? params.nowMs : (baseEntry?.lastInteractionAt ?? params.nowMs),
```

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/agent.ts
Line: 720-727

Comment:
**Duplicate `resolveSessionLifecycleTimestamps` call may double-read the JSONL file**

`resolveSessionLifecycleTimestamps` is called once to populate `freshness` (~line 658) and again here when building `nextEntryPatch`. For legacy sessions where `entry.sessionStartedAt` is absent, `readSessionHeaderStartedAtMs` performs a synchronous filesystem open/read/close on each call. Capturing the first result in a variable eliminates the redundant I/O:

```ts
const lifecycleTs = resolveSessionLifecycleTimestamps({
  entry,
  storePath,
  agentId: resolveAgentIdFromSessionKey(canonicalKey),
});
// use lifecycleTs for both freshness and nextEntryPatch
```

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

Reviews (1): Last reviewed commit: "fix: keep system events from extending s..." | Re-trigger Greptile

Comment thread src/cron/isolated-agent/session.ts Outdated
agentId: params.agentId,
storePath,
}).sessionStartedAt),
lastInteractionAt: params.nowMs,

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 Cron always advances lastInteractionAt, defeating idle-reset fix

Every invocation of resolveCronSession unconditionally writes lastInteractionAt: params.nowMs. The PR's stated goal is to keep cron from extending reset freshness, but this line advances the idle expiry timer on every cron tick for sessions managed through the isolated-agent path. The gateway and auto-reply paths correctly gate this with a touchInteraction flag or an isSystemEvent guard — the cron path has no equivalent.

When a session is configured with an idle-minutes reset, any cron run will keep the session alive indefinitely, which is the exact bug being fixed everywhere else.

Suggested change
lastInteractionAt: params.nowMs,
lastInteractionAt: isNewSession ? params.nowMs : (baseEntry?.lastInteractionAt ?? params.nowMs),
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cron/isolated-agent/session.ts
Line: 184

Comment:
**Cron always advances `lastInteractionAt`, defeating idle-reset fix**

Every invocation of `resolveCronSession` unconditionally writes `lastInteractionAt: params.nowMs`. The PR's stated goal is to *keep cron from extending reset freshness*, but this line advances the idle expiry timer on every cron tick for sessions managed through the isolated-agent path. The gateway and auto-reply paths correctly gate this with a `touchInteraction` flag or an `isSystemEvent` guard — the cron path has no equivalent.

When a session is configured with an idle-minutes reset, any cron run will keep the session alive indefinitely, which is the exact bug being fixed everywhere else.

```suggestion
    lastInteractionAt: isNewSession ? params.nowMs : (baseEntry?.lastInteractionAt ?? params.nowMs),
```

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

Comment on lines +720 to +727
sessionStartedAt: isNewSession
? now
: (entry?.sessionStartedAt ??
resolveSessionLifecycleTimestamps({
entry,
storePath,
agentId: resolveAgentIdFromSessionKey(canonicalKey),
}).sessionStartedAt),

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 Duplicate resolveSessionLifecycleTimestamps call may double-read the JSONL file

resolveSessionLifecycleTimestamps is called once to populate freshness (~line 658) and again here when building nextEntryPatch. For legacy sessions where entry.sessionStartedAt is absent, readSessionHeaderStartedAtMs performs a synchronous filesystem open/read/close on each call. Capturing the first result in a variable eliminates the redundant I/O:

const lifecycleTs = resolveSessionLifecycleTimestamps({
  entry,
  storePath,
  agentId: resolveAgentIdFromSessionKey(canonicalKey),
});
// use lifecycleTs for both freshness and nextEntryPatch
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/server-methods/agent.ts
Line: 720-727

Comment:
**Duplicate `resolveSessionLifecycleTimestamps` call may double-read the JSONL file**

`resolveSessionLifecycleTimestamps` is called once to populate `freshness` (~line 658) and again here when building `nextEntryPatch`. For legacy sessions where `entry.sessionStartedAt` is absent, `readSessionHeaderStartedAtMs` performs a synchronous filesystem open/read/close on each call. Capturing the first result in a variable eliminates the redundant I/O:

```ts
const lifecycleTs = resolveSessionLifecycleTimestamps({
  entry,
  storePath,
  agentId: resolveAgentIdFromSessionKey(canonicalKey),
});
// use lifecycleTs for both freshness and nextEntryPatch
```

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

Comment thread src/config/sessions/reset-policy.ts
Comment thread src/auto-reply/reply/get-reply-fast-path.ts
@steipete
steipete force-pushed the contract-first/runtime-plan-harness-consolidated branch from c138d46 to 26e8435 Compare April 26, 2026 00:53
Comment thread src/config/sessions/reset-policy.ts
entry,
agentId: params.agentId,
storePath,
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Repeated synchronous file I/O for legacy timestamp resolution

Low Severity

resolveSessionLifecycleTimestamps is called twice for the same entry in both the cron session and gateway agent paths — once during freshness evaluation and again when constructing the session entry. Each call can trigger synchronous file I/O (fs.openSync/fs.readSync) to read the JSONL header for legacy sessions without sessionStartedAt. The main session path in session.ts correctly avoids this by storing the result in a lifecycleTimestamps variable.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 26e84351d4de0eb9ecbb3de3d0af3526f8843f91. Configure here.

@steipete
steipete force-pushed the contract-first/runtime-plan-harness-consolidated branch from 26e8435 to c8b0dd3 Compare April 26, 2026 01:12
Comment thread src/gateway/server-methods/agent.ts
@steipete
steipete force-pushed the contract-first/runtime-plan-harness-consolidated branch from c8b0dd3 to 1f42c3f Compare April 26, 2026 01:29
@steipete
steipete merged commit 566d2d7 into main Apr 26, 2026
12 of 13 checks passed
@steipete
steipete deleted the contract-first/runtime-plan-harness-consolidated branch April 26, 2026 01:29
@steipete

Copy link
Copy Markdown
Contributor Author

Landed in 566d2d73a323708df467a4f6645e0b882235b287 after squashing and rebasing on current main.

Codex verification:

  • pnpm test src/auto-reply/reply/session.heartbeat-no-reset.test.ts src/config/sessions/sessions.test.ts src/cron/isolated-agent/session.test.ts src/gateway/server-methods/agent.test.ts
  • pnpm check:changed
  • pnpm lint:core direct rerun after one transient check:changed SIGTERM during lint
  • pnpm check before the final rebase/push

The parity-gate failure observed before the final push was the existing qa-lab thread-memory-isolation timeout, not a reset/heartbeat regression; the refreshed PR SHA had no failures when merged.

@cursor cursor Bot 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1f42c3f. Configure here.

isNewSession =
!entry ||
(!canReuseSession && !usableRequestedSessionId) ||
Boolean(usableRequestedSessionId && entry?.sessionId !== usableRequestedSessionId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Gateway agent path missing system-event rollover guard

Medium Severity

The main reply path in session.ts prevents system events from triggering session rollovers via (isSystemEvent && canReuseExistingEntry) in the freshEntry computation. The gateway agent handler computes canReuseSession and isNewSession without an equivalent guard for bootstrapContextRunKind === "cron" or "heartbeat". When a cron or heartbeat request arrives through this path with a stale session, canReuseSession is false, causing an unintended session rollover. Additionally, when this rollover happens, lastInteractionAt is set to the old stale entry?.lastInteractionAt rather than now, so the newly-created session may immediately appear idle-stale.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1f42c3f. Configure here.

ayesha-aziz123 pushed a commit to ayesha-aziz123/openclaw that referenced this pull request Apr 26, 2026
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
globalcaos pushed a commit to globalcaos/tinkerclaw that referenced this pull request May 13, 2026
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
jameslcowan pushed a commit to jameslcowan/openclaw that referenced this pull request Jun 2, 2026
sablehead pushed a commit to sablehead/openclaw that referenced this pull request Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling docs Improvements or additions to documentation gateway Gateway runtime maintainer Maintainer-authored PR size: L

Projects

None yet

1 participant