Skip to content

Hide transcript-only OpenClaw history artifacts#69217

Closed
BradGroux wants to merge 12 commits into
openclaw:mainfrom
BradGroux:codex/53615
Closed

Hide transcript-only OpenClaw history artifacts#69217
BradGroux wants to merge 12 commits into
openclaw:mainfrom
BradGroux:codex/53615

Conversation

@BradGroux

Copy link
Copy Markdown
Contributor

Summary

This patch stops transcript-only OpenClaw assistant artifacts from leaking back out through user-facing history surfaces.

The concrete issue here is the delivery-mirror and gateway-injected assistant messages that are intentionally written into transcripts for replay, mirroring, or bootstrap purposes, but should not show up as visible conversational history in chat.history, sessions.get, or the session history HTTP endpoint.

This is part of the broader duplicate transcript and replay umbrella tracked in #69208. It is most closely related to the delivery-mirror and consumer leakage family, especially #39469.

Root Cause

OpenClaw already distinguishes some internal transcript-only assistant artifacts in a few paths, but the gateway history readers were still consuming raw transcript messages too directly.

That meant messages with:

  • provider: "openclaw"
  • model: "delivery-mirror" or model: "gateway-injected"

could still reappear in history APIs even though they are not real user-visible assistant turns.

What Changed

  • Added a shared helper in src/config/sessions/transcript.ts to identify transcript-only OpenClaw assistant artifacts.
  • Reused that helper in the embedded subscribe path instead of keeping a duplicated local predicate.
  • Updated chat.history sanitization to drop transcript-only OpenClaw assistant artifacts.
  • Updated sessions.get to filter those transcript-only artifacts from returned history.
  • Added regression coverage for:
    • chat.history
    • sessions.get
    • session history HTTP reads

User Impact

Before this change, some transcript-only artifacts could be surfaced back to users or downstream consumers as if they were real assistant replies.

After this change, those internal mirror and injected transcript entries are preserved where they are needed for transcript mechanics, but they no longer appear in the main history surfaces that users and clients treat as conversational state.

Validation

Passed:

  • commit-time repo checks triggered by git commit
    • pnpm check:no-conflict-markers
    • pnpm tool-display:check
    • pnpm check:host-env-policy:swift
    • pnpm tsgo
    • pnpm lint
    • pnpm lint:webhook:no-low-level-body-read
    • pnpm lint:auth:no-pairing-store-group
    • pnpm lint:auth:pairing-account-scope
  • node scripts/run-vitest.mjs run src/gateway/server.chat.gateway-server-chat.test.ts src/gateway/server.sessions.gateway-server-sessions-a.test.ts
  • node scripts/run-vitest.mjs run src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.suppresses-message-end-block-replies-message-tool.test.ts

Notes:

  • I added coverage in src/gateway/sessions-history-http.test.ts, but I did not get a clean standalone run of that file because it is routed through a heavier e2e-scoped Vitest config in this repo.
  • A full pnpm exec tsc -p tsconfig.json --noEmit --pretty false attempt previously hit a local Node memory ceiling on this machine, so I am not claiming a full repository typecheck from that command.

Scope Notes

This does not attempt to solve the broader replay and idempotency family by itself.

It is a focused fix for one tractable slice of the umbrella issue:

  • transcript-only delivery-mirror leakage
  • transcript-only gateway-injected leakage

Follow-up work is still needed for the broader replay and duplicate context cluster tracked in #69208.

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation channel: discord Channel integration: discord channel: mattermost Channel integration: mattermost channel: msteams Channel integration: msteams channel: telegram Channel integration: telegram app: web-ui App: web-ui gateway Gateway runtime scripts Repository scripts agents Agent runtime and tooling extensions: openai extensions: qa-lab size: XL maintainer Maintainer-authored PR labels Apr 20, 2026
@BradGroux BradGroux changed the title [codex] Hide transcript-only OpenClaw history artifacts Hide transcript-only OpenClaw history artifacts Apr 20, 2026
@BradGroux BradGroux marked this pull request as ready for review April 20, 2026 06:06
Copilot AI review requested due to automatic review settings April 20, 2026 06:06

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0a5c0025d1

ℹ️ 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".

Comment on lines +27 to +28
if (countChannels(activeRegistry) > 0) {
return activeRegistry;

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 Align channel-registry fallback with version tracking

getActivePluginChannelRegistryFromState now falls back to activeRegistry when the pinned channel registry is empty, but getActivePluginChannelRegistryVersionFromState still keys off whether state.channel.registry is non-null. That can pair an active registry reference with a pinned-registry version, so cache users like src/channels/plugins/registry-loaded.ts (which compare both ref and version) can miss invalidation when activeVersion changes while an empty pinned registry remains present.

Useful? React with 👍 / 👎.

@aisle-research-bot

aisle-research-bot Bot commented Apr 20, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

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

# Severity Title
1 🟠 High SSRF and bot token exfiltration via unvalidated Teams conversation reference serviceUrl
2 🟡 Medium Debug proxy capture stores sensitive headers and URL query parameters (Discord/Telegram fetch wrappers)
3 🟡 Medium Audit/log bypass: gateway-injected assistant transcript entries are filtered out of history APIs/UI
1. 🟠 SSRF and bot token exfiltration via unvalidated Teams conversation reference `serviceUrl`
Property Value
Severity High
CWE CWE-918
Location extensions/msteams/src/sdk.ts:478-496

Description

continueConversation() and the REST helpers build outbound Bot Framework URLs directly from reference.serviceUrl and attach the bot Authorization: Bearer <token> header.

  • reference.serviceUrl is not validated/allowlisted to Microsoft/Bot Framework domains.
  • If an attacker can influence a stored/replayed conversation reference (or any path that calls continueConversation() with untrusted data), the adapter will send authenticated requests to an attacker-controlled host.
  • fetchWithSsrFGuard mitigates private-network SSRF and strips some headers on cross-origin redirects, but it does not prevent sending the initial request (and Authorization header) to an attacker-controlled public origin.

Vulnerable code (token-bearing request built from serviceUrl):

const baseUrl = serviceUrl.replace(/\/+
$/, "");
const url = `${baseUrl}/v3/conversations/${encodeURIComponent(conversationId)}/activities/${encodeURIComponent(activityId)}`;

const headers: Record<string, string> = {
  "User-Agent": buildUserAgent(),
};
if (token) {
  headers.Authorization = `Bearer ${token}`;
}

await fetchWithSsrFGuard({ url, init: { method: "DELETE", headers } });

Recommendation

Treat reference.serviceUrl as untrusted input.

  1. Allowlist/validate the origin before using it:

    • Require https.
    • Require hostname to match known Bot Framework/Teams endpoints (e.g. *.trafficmanager.net, *.botframework.com, regional smba.*), or derive the connector base URL from trusted configuration instead of the reference.
  2. Enforce no redirects (or ensure Authorization is never sent off-origin):

    • Prefer maxRedirects: 0 when sending bearer tokens, or explicitly set allowCrossOriginUnsafeRedirectReplay: false and ensure Authorization is stripped for redirects.

Example (sketch):

function assertAllowedServiceUrl(serviceUrl: string) {
  const u = new URL(serviceUrl);
  if (u.protocol !== "https:") throw new Error("Invalid serviceUrl protocol");
  const allowed = [/\.trafficmanager\.net$/i, /\.botframework\.com$/i];
  if (!allowed.some((re) => re.test(u.hostname))) {
    throw new Error("Untrusted serviceUrl host");
  }
}

assertAllowedServiceUrl(reference.serviceUrl);

Also apply the same validation to updateActivityViaRest() and any other outbound call path that uses serviceUrl.

2. 🟡 Debug proxy capture stores sensitive headers and URL query parameters (Discord/Telegram fetch wrappers)
Property Value
Severity Medium
CWE CWE-532
Location extensions/discord/src/monitor/rest-fetch.ts:26-34

Description

captureHttpExchange() persists outbound request metadata to the debug-proxy SQLite/blob store. The updated Discord and Telegram fetch wrappers now pass a fully-resolved URL via resolveRequestUrl(input) and forward request headers/bodies.

This is sensitive because:

  • resolveRequestUrl() returns Request.url/string/URL verbatim and does not sanitize embedded credentials (https://user:pass@​host/...) or signed/tokenized query strings.
  • captureHttpExchange() records headersJson as Object.fromEntries(headers.entries()) and stores path as pathname + search, so Authorization/Cookie headers and query-string tokens are written to disk when debug proxy capture is enabled.
  • The new change increases exposure versus the prior String(input) behavior for Request objects (often "[object Request]"), by ensuring the real URL (including query) is stored.

Vulnerable callsite (Discord):

captureHttpExchange({
  url: resolveRequestUrl(input),
  method: init?.method ?? "GET",
  requestHeaders: init?.headers as Headers | Record<string, string> | undefined,
  requestBody: (init as RequestInit & { body?: BodyInit | null })?.body ?? null,
  response,
  flowId: randomUUID(),
  meta: { subsystem: "discord-rest" },
});

Data flow context:

  • inputs: input/init.headers/init.body come from API clients and may contain OAuth/Bot tokens, cookies, signed URLs, etc.
  • sink: captureHttpExchange() writes headers and URL path+query to SQLite (capture_events.headers_json, capture_events.path) and may persist bodies to blob storage.

Recommendation

Redact secrets before persisting captures (even in debug mode), and avoid storing full query strings by default.

Suggested changes:

  1. Redact sensitive headers (Authorization, Cookie, Set-Cookie, X-Api-Key, etc.) before calling captureHttpExchange().
  2. Strip credentials and optionally query from URLs before capture (or allowlist safe query params).
  3. Consider requiring explicit user consent per session, and clearly document that enabling capture writes potentially sensitive data to disk.

Example (sanitizing at callsite):

function sanitizeHeaders(h?: Headers | Record<string,string>) {
  const obj = h instanceof Headers ? Object.fromEntries(h.entries()) : { ...(h ?? {}) };
  for (const k of Object.keys(obj)) {
    if (["authorization","cookie","set-cookie","x-api-key"].includes(k.toLowerCase())) {
      obj[k] = "[REDACTED]";
    }
  }
  return obj;
}

function sanitizeUrl(raw: string) {
  const u = new URL(raw);
  u.username = "";
  u.password = "";// Option A: drop query entirely
  u.search = "";
  return u.toString();
}

captureHttpExchange({
  url: sanitizeUrl(resolveRequestUrl(input)),
  method: init?.method ?? "GET",
  requestHeaders: sanitizeHeaders(init?.headers as any),
  requestBody: null, // or only persist for non-sensitive content-types
  response,
  meta: { subsystem: "discord-rest" },
});
3. 🟡 Audit/log bypass: gateway-injected assistant transcript entries are filtered out of history APIs/UI
Property Value
Severity Medium
CWE CWE-778
Location src/gateway/server-methods/chat.ts:895-938

Description

chat.inject writes an assistant message to the session transcript with provider: "openclaw" and model: "gateway-injected". A new predicate, isTranscriptOnlyOpenClawAssistantMessage, causes these messages (and delivery-mirror) to be dropped from chat history and session message retrieval.

Impact:

  • Messages appended to the transcript can exist on disk and potentially influence downstream processing that reads the raw transcript, while being invisible to operators/auditors relying on history endpoints/UI.
  • This creates an audit/logging gap: an actor with access to chat.inject (admin scope) can inject content into a session transcript that will not appear in chat.history / session history outputs.

Vulnerable behavior (filtering):

if (isTranscriptOnlyOpenClawAssistantMessage(message)) {
  return true;
}

Message classification:

return provider === "openclaw" && (model === "delivery-mirror" || model === "gateway-injected");

Injection path setting the hidden markers:

provider: "openclaw",
model: "gateway-injected",

This combination enables transcript entries to be persisted but omitted from history responses.

Recommendation

Do not fully omit persisted transcript messages from audit/history surfaces based solely on {provider, model}.

Safer options:

  1. Expose but clearly label these entries in history responses (e.g., __openclaw.kind: "gateway-injected") and let UIs choose to collapse them.
  2. If they must be hidden from default UI, add a separate privileged audit endpoint / flag (e.g., includeInternal=true) that returns them for auditors.
  3. Ensure model-context construction uses the same filtering as the UI/history, or explicitly include/exclude these entries with a documented policy.

Example: keep entries but tag them

if (isTranscriptOnlyOpenClawAssistantMessage(message)) {
  next.push({ ...(message as any), __openclaw: { ...(message as any).__openclaw, internal: true } });
  continue;
}

Analyzed PR: #69217 at commit a4a2492

Last updated on: 2026-04-20T06:32:21Z

@BradGroux

Copy link
Copy Markdown
Contributor Author

Follow-up pushed in a4a2492d64 to address the session history HTTP test issue that came back in review.

What I changed:

  • switched src/gateway/sessions-history-http.test.ts to use a normal visible assistant fixture for baseline history instead of seeding visible history through the delivery-mirror helper
  • kept delivery-mirror and gateway-injected fixtures only where the test is explicitly validating transcript-only filtering
  • updated the phased-content assertion to check visible assistant text resolution instead of assuming the first raw content block is the final answer

Re-run after the fix:

  • node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts src/gateway/sessions-history-http.test.ts
  • node scripts/run-vitest.mjs run src/gateway/server.chat.gateway-server-chat.test.ts src/gateway/server.sessions.gateway-server-sessions-a.test.ts

Current result:

  • the HTTP history file now passes under its actual e2e-scoped config
  • the gateway history tests still pass

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a4a2492d64

ℹ️ 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".

id: "discord-rest",
label: "Discord REST monitor fetch",
modulePath: "extensions/discord/src/monitor/rest-fetch.ts",
modulePath: "extensions/discord/monitor/rest-fetch.ts",

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 Restore real module paths in proxy coverage report

The modulePath values for several extension seams now point to files that do not exist (for example extensions/discord/monitor/rest-fetch.ts, extensions/telegram/fetch.ts, and extensions/mattermost/mattermost/monitor-websocket.ts all dropped /src). This makes openclaw proxy coverage output inaccurate and breaks any workflow that uses modulePath to jump to the implemented seam; these entries should keep repo-real paths like extensions/.../src/....

Useful? React with 👍 / 👎.

@steipete

Copy link
Copy Markdown
Contributor

Closing this as duplicate/superseded by #40716 after the dedupe pass.

The useful slice here is the transcript-only OpenClaw assistant artifact filter (delivery-mirror / gateway-injected) for history/consumer paths. #40716 is the cleaner main PR for that slice: it is focused on the relevant files and avoids the unrelated Microsoft/channel/fetch/generated changes present in this branch.

The broader replay/idempotency work remains tracked in #69208 and #66443; this closure is only PR dedupe, not a claim that the umbrella is fixed.

@steipete steipete added the duplicate This issue or pull request already exists label Apr 26, 2026
@steipete steipete closed this Apr 26, 2026
lukeboyett added a commit to lukeboyett/openclaw that referenced this pull request May 6, 2026
…ents

Adds an optional `audience: 'internal' | 'user-facing'` field to SystemEvent,
defaulting to 'user-facing' (full backward-compat). Internal-audience events
are wrapped at the consumer integration point (drainFormattedSystemEvents)
in the existing INTERNAL_RUNTIME_CONTEXT_BEGIN/END delimiters with the
canonical header lines from formatAgentInternalEventsForPrompt — the agent
runtime sees the content as runtime context but every user-facing surface
strips it via the already-installed stripInternalRuntimeContext consumers
(sanitize-user-facing-text.ts, memory-host-sdk session-files, history
readers, Control UI extract).

This mirrors the producer-side runtime-context delimiter pattern landed
in e918e5f (openclaw#71761) and 6e985a4 (webchat runtime context) — no new
persistence shape, no consumer-side filter (avoids the audit-bypass smell
flagged on openclaw#69217), no display-layer per-surface patch (avoids the
rejection pattern from openclaw#69366).

Scope is intentionally tight. This primitive is the hidden
runtime-context lane only; it is NOT a delivery-routing primitive. Events
with a positive user delivery contract (exec completion via notifyOnExit,
cron payloads, heartbeat acks) are not migrated — those have
heartbeat-driven explicit-relay paths (buildExecEventPrompt /
buildCronEventPrompt) plus tactical producer-side skips like bd60df3.

Migrated (one caller):
- queueCronAwarenessSystemEvent in src/cron/isolated-agent/delivery-dispatch.ts.
  Main-session reflection of an isolated agent run that already delivered
  to the user via its own channel (gated on `delivered === true`). Pure
  awareness, no user delivery contract.

Tests cover: default audience, round-trip preservation, audience equality
(consumeSystemEventEntries prefix-match respects audience), wrap-on-drain
shape with canonical header, mixed user-facing+internal ordering, the
user-facing-only and internal-only edge cases, end-to-end strip via
stripInternalRuntimeContext, and adversarial delimiter-token escape.

Addresses part of openclaw#69492 (system-event-shape consumer
leakage). Leaves the user-deliverable noise question (exec completion,
cron payloads, heartbeat acks) open for case-by-case follow-ups matching
the maintainer's revealed pattern (bd60df3, 3f63ba8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lukeboyett added a commit to lukeboyett/openclaw that referenced this pull request May 9, 2026
…ents

Adds an optional `audience: 'internal' | 'user-facing'` field to SystemEvent,
defaulting to 'user-facing' (full backward-compat). Internal-audience events
are wrapped at the consumer integration point (drainFormattedSystemEvents)
in the existing INTERNAL_RUNTIME_CONTEXT_BEGIN/END delimiters with the
canonical header lines from formatAgentInternalEventsForPrompt — the agent
runtime sees the content as runtime context but every user-facing surface
strips it via the already-installed stripInternalRuntimeContext consumers
(sanitize-user-facing-text.ts, memory-host-sdk session-files, history
readers, Control UI extract).

This mirrors the producer-side runtime-context delimiter pattern landed
in e918e5f (openclaw#71761) and 6e985a4 (webchat runtime context) — no new
persistence shape, no consumer-side filter (avoids the audit-bypass smell
flagged on openclaw#69217), no display-layer per-surface patch (avoids the
rejection pattern from openclaw#69366).

Scope is intentionally tight. This primitive is the hidden
runtime-context lane only; it is NOT a delivery-routing primitive. Events
with a positive user delivery contract (exec completion via notifyOnExit,
cron payloads, heartbeat acks) are not migrated — those have
heartbeat-driven explicit-relay paths (buildExecEventPrompt /
buildCronEventPrompt) plus tactical producer-side skips like bd60df3.

Migrated (one caller):
- queueCronAwarenessSystemEvent in src/cron/isolated-agent/delivery-dispatch.ts.
  Main-session reflection of an isolated agent run that already delivered
  to the user via its own channel (gated on `delivered === true`). Pure
  awareness, no user delivery contract.

Tests cover: default audience, round-trip preservation, audience equality
(consumeSystemEventEntries prefix-match respects audience), wrap-on-drain
shape with canonical header, mixed user-facing+internal ordering, the
user-facing-only and internal-only edge cases, end-to-end strip via
stripInternalRuntimeContext, and adversarial delimiter-token escape.

Addresses part of openclaw#69492 (system-event-shape consumer
leakage). Leaves the user-deliverable noise question (exec completion,
cron payloads, heartbeat acks) open for case-by-case follow-ups matching
the maintainer's revealed pattern (bd60df3, 3f63ba8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lukeboyett added a commit to lukeboyett/openclaw that referenced this pull request May 11, 2026
…ents

Adds an optional `audience: 'internal' | 'user-facing'` field to SystemEvent,
defaulting to 'user-facing' (full backward-compat). Internal-audience events
are wrapped at the consumer integration point (drainFormattedSystemEvents)
in the existing INTERNAL_RUNTIME_CONTEXT_BEGIN/END delimiters with the
canonical header lines from formatAgentInternalEventsForPrompt — the agent
runtime sees the content as runtime context but every user-facing surface
strips it via the already-installed stripInternalRuntimeContext consumers
(sanitize-user-facing-text.ts, memory-host-sdk session-files, history
readers, Control UI extract).

This mirrors the producer-side runtime-context delimiter pattern landed
in e918e5f (openclaw#71761) and 6e985a4 (webchat runtime context) — no new
persistence shape, no consumer-side filter (avoids the audit-bypass smell
flagged on openclaw#69217), no display-layer per-surface patch (avoids the
rejection pattern from openclaw#69366).

Scope is intentionally tight. This primitive is the hidden
runtime-context lane only; it is NOT a delivery-routing primitive. Events
with a positive user delivery contract (exec completion via notifyOnExit,
cron payloads, heartbeat acks) are not migrated — those have
heartbeat-driven explicit-relay paths (buildExecEventPrompt /
buildCronEventPrompt) plus tactical producer-side skips like bd60df3.

Migrated (one caller):
- queueCronAwarenessSystemEvent in src/cron/isolated-agent/delivery-dispatch.ts.
  Main-session reflection of an isolated agent run that already delivered
  to the user via its own channel (gated on `delivered === true`). Pure
  awareness, no user delivery contract.

Tests cover: default audience, round-trip preservation, audience equality
(consumeSystemEventEntries prefix-match respects audience), wrap-on-drain
shape with canonical header, mixed user-facing+internal ordering, the
user-facing-only and internal-only edge cases, end-to-end strip via
stripInternalRuntimeContext, and adversarial delimiter-token escape.

Addresses part of openclaw#69492 (system-event-shape consumer
leakage). Leaves the user-deliverable noise question (exec completion,
cron payloads, heartbeat acks) open for case-by-case follow-ups matching
the maintainer's revealed pattern (bd60df3, 3f63ba8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lukeboyett added a commit to lukeboyett/openclaw that referenced this pull request May 16, 2026
…ents

Adds an optional `audience: 'internal' | 'user-facing'` field to SystemEvent,
defaulting to 'user-facing' (full backward-compat). Internal-audience events
are wrapped at the consumer integration point (drainFormattedSystemEvents)
in the existing INTERNAL_RUNTIME_CONTEXT_BEGIN/END delimiters with the
canonical header lines from formatAgentInternalEventsForPrompt — the agent
runtime sees the content as runtime context but every user-facing surface
strips it via the already-installed stripInternalRuntimeContext consumers
(sanitize-user-facing-text.ts, memory-host-sdk session-files, history
readers, Control UI extract).

This mirrors the producer-side runtime-context delimiter pattern landed
in e918e5f (openclaw#71761) and 6e985a4 (webchat runtime context) — no new
persistence shape, no consumer-side filter (avoids the audit-bypass smell
flagged on openclaw#69217), no display-layer per-surface patch (avoids the
rejection pattern from openclaw#69366).

Scope is intentionally tight. This primitive is the hidden
runtime-context lane only; it is NOT a delivery-routing primitive. Events
with a positive user delivery contract (exec completion via notifyOnExit,
cron payloads, heartbeat acks) are not migrated — those have
heartbeat-driven explicit-relay paths (buildExecEventPrompt /
buildCronEventPrompt) plus tactical producer-side skips like bd60df3.

Migrated (one caller):
- queueCronAwarenessSystemEvent in src/cron/isolated-agent/delivery-dispatch.ts.
  Main-session reflection of an isolated agent run that already delivered
  to the user via its own channel (gated on `delivered === true`). Pure
  awareness, no user delivery contract.

Tests cover: default audience, round-trip preservation, audience equality
(consumeSystemEventEntries prefix-match respects audience), wrap-on-drain
shape with canonical header, mixed user-facing+internal ordering, the
user-facing-only and internal-only edge cases, end-to-end strip via
stripInternalRuntimeContext, and adversarial delimiter-token escape.

Addresses part of openclaw#69492 (system-event-shape consumer
leakage). Leaves the user-deliverable noise question (exec completion,
cron payloads, heartbeat acks) open for case-by-case follow-ups matching
the maintainer's revealed pattern (bd60df3, 3f63ba8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lukeboyett added a commit to lukeboyett/openclaw that referenced this pull request May 20, 2026
…ents

Adds an optional `audience: 'internal' | 'user-facing'` field to SystemEvent,
defaulting to 'user-facing' (full backward-compat). Internal-audience events
are wrapped at the consumer integration point (drainFormattedSystemEvents)
in the existing INTERNAL_RUNTIME_CONTEXT_BEGIN/END delimiters with the
canonical header lines from formatAgentInternalEventsForPrompt — the agent
runtime sees the content as runtime context but every user-facing surface
strips it via the already-installed stripInternalRuntimeContext consumers
(sanitize-user-facing-text.ts, memory-host-sdk session-files, history
readers, Control UI extract).

This mirrors the producer-side runtime-context delimiter pattern landed
in e918e5f (openclaw#71761) and 6e985a4 (webchat runtime context) — no new
persistence shape, no consumer-side filter (avoids the audit-bypass smell
flagged on openclaw#69217), no display-layer per-surface patch (avoids the
rejection pattern from openclaw#69366).

Scope is intentionally tight. This primitive is the hidden
runtime-context lane only; it is NOT a delivery-routing primitive. Events
with a positive user delivery contract (exec completion via notifyOnExit,
cron payloads, heartbeat acks) are not migrated — those have
heartbeat-driven explicit-relay paths (buildExecEventPrompt /
buildCronEventPrompt) plus tactical producer-side skips like bd60df3.

Migrated (one caller):
- queueCronAwarenessSystemEvent in src/cron/isolated-agent/delivery-dispatch.ts.
  Main-session reflection of an isolated agent run that already delivered
  to the user via its own channel (gated on `delivered === true`). Pure
  awareness, no user delivery contract.

Tests cover: default audience, round-trip preservation, audience equality
(consumeSystemEventEntries prefix-match respects audience), wrap-on-drain
shape with canonical header, mixed user-facing+internal ordering, the
user-facing-only and internal-only edge cases, end-to-end strip via
stripInternalRuntimeContext, and adversarial delimiter-token escape.

Addresses part of openclaw#69492 (system-event-shape consumer
leakage). Leaves the user-deliverable noise question (exec completion,
cron payloads, heartbeat acks) open for case-by-case follow-ups matching
the maintainer's revealed pattern (bd60df3, 3f63ba8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lukeboyett added a commit to lukeboyett/openclaw that referenced this pull request May 26, 2026
…ents

Adds an optional `audience: 'internal' | 'user-facing'` field to
`SystemEvent`, defaulting to `'user-facing'` (full backward-compat for
every existing caller). Internal-audience events are wrapped at the
consumer integration point (`drainFormattedSystemEvents` in
session-system-events.ts) in the existing
`INTERNAL_RUNTIME_CONTEXT_BEGIN/END` delimiters with the canonical
header lines from `formatAgentInternalEventsForPrompt`. The agent
runtime sees the content as runtime context; user-facing surfaces
strip it via the already-installed `stripInternalRuntimeContext`
consumers (`sanitize-user-facing-text.ts`,
`memory-host-sdk/host/session-files.ts`, `agents/internal-events.ts`,
history readers, Control UI extract).

Mirrors the producer-side runtime-context delimiter pattern landed in
`e918e5f75c` (openclaw#71761) and `6e985a421d` (webchat runtime context). No
new persistence shape, no consumer-side filter (avoids the
audit-bypass concern flagged on openclaw#69217), no display-layer per-surface
patch (avoids the rejection pattern from openclaw#69366).

Heartbeat-path additions: `selectGenericSystemEvents` keeps
audience: "internal" events queued when the drain is invoked from a
heartbeat reply (whose prompt envelope discards
`systemEventBlocks`), so the wrapped runtime-context block is not
silently consumed before the next regular reply turn that actually
carries it through to the model. The heartbeat exec/cron-prompt
selectors also skip internal events so they don't get classified as
user-facing relay payloads.

`compactSystemEvent` bypasses heartbeat-noise filters
("reason periodic", "Read HEARTBEAT.md", "heartbeat poll/wake") for
audience: "internal" events — those go through the wrap-on-drain path
and never reach a user-facing surface, so filtering them after
consumption would silently drop the event (same no-consumer hole
class as the exec-shape filter).

Migrated one producer (the canonical justification for the lane):
- `queueCronAwarenessSystemEvent` in
  `src/cron/isolated-agent/delivery-dispatch.ts`. This event is
  queued onto the main session only after the isolated agent run has
  already delivered to the user via its own channel (gated on
  `delivered === true` and `shouldQueueCronAwareness`). The main
  session needs hidden runtime context so the next turn knows the
  cron landed, not a user-visible `System: ...` bubble.

Scope is intentionally tight. This primitive is the hidden
runtime-context lane only; it is NOT a delivery-routing primitive.
Events with a positive user delivery contract (exec completion via
`notifyOnExit`, cron payloads, heartbeat acks) are not migrated —
those have heartbeat-driven explicit-relay paths
(`buildExecEventPrompt` / `buildCronEventPrompt`) plus tactical
producer-side skips like `bd60df3e53`. Marking them internal would
suppress delivery on regular reply turns where the model is
instructed to keep wrapped content private.

Tests cover: default audience, round-trip preservation, audience
equality (consumeSystemEventEntries prefix-match respects audience),
wrap-on-drain shape with canonical header, mixed
user-facing+internal ordering, the user-facing-only and
internal-only edge cases, end-to-end strip via
stripInternalRuntimeContext, adversarial delimiter-token escape,
heartbeat-path audience: "internal" queueing semantics, and
exec-shaped internal events bypassing the text-shape filter.

Addresses part of openclaw#69492 (system-event-shape
consumer leakage; umbrella openclaw#69208 Track B). Leaves the
user-deliverable noise question (exec completion, cron payloads,
heartbeat acks) open for case-by-case follow-ups matching the
maintainer's revealed pattern (`bd60df3e53`, `3f63ba8fd808`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling app: web-ui App: web-ui channel: discord Channel integration: discord channel: mattermost Channel integration: mattermost channel: msteams Channel integration: msteams channel: telegram Channel integration: telegram docs Improvements or additions to documentation duplicate This issue or pull request already exists extensions: openai extensions: qa-lab gateway Gateway runtime maintainer Maintainer-authored PR scripts Repository scripts size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants