Skip to content

feat(gateway): allow chat.inject to override provider attribution via originAgent#79959

Closed
wherewolf87 wants to merge 5 commits into
openclaw:mainfrom
wherewolf87:feat/chat-inject-origin-agent
Closed

feat(gateway): allow chat.inject to override provider attribution via originAgent#79959
wherewolf87 wants to merge 5 commits into
openclaw:mainfrom
wherewolf87:feat/chat-inject-origin-agent

Conversation

@wherewolf87

@wherewolf87 wherewolf87 commented May 9, 2026

Copy link
Copy Markdown

Summary

Adds an optional originAgent field to ChatInjectParamsSchema and persists it as a separate, dedicated attribution field on the injected assistant transcript entry. The pre-existing (provider: "openclaw", model: "gateway-injected") sentinel pair is preserved unchanged, so the runtime replay filter at src/agents/pi-embedded-runner/replay-history.ts:271 continues to correctly drop these rows from being replayed back to the next model call.

UI avatar/handle resolvers can key off the new persisted originAgent field to render the true source of a cross-agent inject (e.g. Hermes, Codex) instead of always showing the OpenClaw avatar.

Why

chat.inject is the canonical RPC for cross-agent transcript writes. Today the persisted entry has provider: "openclaw" hard-coded, with additionalProperties: false on the schema closing the door on any caller-supplied attribution. When external agents (Hermes, Codex, Claude Code, etc.) inject via this RPC, the message renders with OpenClaw's avatar and the only visible attribution is whatever the caller embeds in body text. This breaks the "who said what" guarantee in long transcripts.

Discovered in production by an external agent (Hermes) attempting a cross-agent verify ping into a webchat session. The body-tag workaround ("FROM: Hermes —") works at-a-glance but degrades attribution clarity over time and across many entries.

Behavior

  • With originAgent set: persisted entry gets a new originAgent: "<value>" field. provider and model are unchanged.
  • Without originAgent: behavior is byte-identical to current main. No new field is written. Fully backward compatible.
  • Empty / whitespace-only input: schema rejects literal "" (minLength: 1); persistence helper additionally trims whitespace and treats blanks as absent, so a malformed value never lands on disk even if a future schema change loosens validation.

Why a separate field, not provider override

The Codex review on the prior commit caught a real correctness defect: provider is part of the runtime replay contract, not just display. normalizeAssistantReplayContent (src/agents/pi-embedded-runner/replay-history.ts:271) drops gateway-injected rows from model replay only when provider === "openclaw" and the model is one of { "delivery-mirror", "gateway-injected" }. Overwriting provider would have caused transcript-only injects to leak into model context, duplicating content and risking provider ordering rejections.

The redesigned patch keeps the sentinel pair untouched and adds attribution as a dedicated parallel field. UI consumers read the new field; the replay filter is unaffected.

Security

chat.inject is already gated at operator-privilege scope (src/gateway/method-scopes.ts). Anyone who can call it can already write arbitrary assistant content into a session transcript. originAgent is purely presentational and introduces no new impersonation surface beyond what current authorized callers already possess. Because the sentinel pair is preserved, the previously-flagged security risk (UI-only injects entering model replay) does not apply to this revision.

Files

  • src/gateway/protocol/schema/logs-chat.ts — schema field with minLength: 1, maxLength: 100
  • src/gateway/server-methods/chat.ts — handler param + helper threading
  • src/gateway/server-methods/chat-transcript-inject.ts — new persisted field, sentinel preserved, trim+empty normalization
  • apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift — regenerated via pnpm protocol:gen:swift

Real behavior proof

Behavior or issue addressed: Cross-agent chat.inject calls render with the OpenClaw avatar in the operator's webchat UI because the persisted assistant entry has no caller-controlled attribution field. External agents currently have no way to mark the inject as their own without putting "FROM: Hermes —" inline in body text, which degrades transcript clarity. This patch adds a dedicated originAgent persisted field while preserving the (provider: "openclaw", model: "gateway-injected") sentinel that the replay-history filter relies on.

Real environment tested: Sandboxed checkout of openclaw main at /tmp/openclaw-pr/openclaw-fresh with the patched files and the regenerated protocol bindings. Used the actual openclaw runtime — appendInjectedAssistantMessageToTranscript and validateChatInjectParams are invoked via node through tsx against synthetic transcript fixtures created with the project's own createTranscriptFixtureSync helper. No mocks, no stubs, no real session keys, no production data.

Exact steps or command run after this patch:

node node_modules/.bin/tsx scripts/proof-chat-inject-origin-agent.mts
node node_modules/.bin/tsx scripts/proof-chat-inject-schema.mts

Both scripts are committed under scripts/ in this PR so a maintainer can rerun them.

Evidence after fix: Live stdout captured from the runtime invocations above. Persistence-layer output shows the actual provider, model, and originAgent fields read back from the jsonl transcript file on disk after the patched code wrote to it. Schema-layer output is the AJV validator's verdict on real input shapes.

Persistence-layer terminal output (live runtime, the patched code path actually executed):

OpenClaw chat.inject originAgent attribution — real-behavior proof
Patched modules under test:
  src/gateway/server-methods/chat-transcript-inject.ts
  src/gateway/protocol/schema/logs-chat.ts (schema field)
  src/gateway/server-methods/chat.ts (handler threading)

Design:
  provider stays 'openclaw' (replay-filter sentinel preserved)
  originAgent is a NEW persisted field for display-only attribution
  empty/whitespace originAgent is normalized to absent

=================================================
CASE                  : baseline-no-origin
input.originAgent     : <unset>
append.ok             : true
messageId             : 09641925-2611-4401-86c6-d369a045b518
stamped.provider      : openclaw
stamped.model         : gateway-injected
stamped.originAgent   : <absent>
expected.originAgent  : absent
result                : PASS

=================================================
CASE                  : hermes
input.originAgent     : "hermes"
append.ok             : true
messageId             : fcd260be-a790-489b-82e5-f393d136e321
stamped.provider      : openclaw
stamped.model         : gateway-injected
stamped.originAgent   : hermes
expected.originAgent  : hermes
result                : PASS

=================================================
CASE                  : codex
input.originAgent     : "codex"
append.ok             : true
messageId             : ef02a786-0e71-48d3-aafb-b861049d8301
stamped.provider      : openclaw
stamped.model         : gateway-injected
stamped.originAgent   : codex
expected.originAgent  : codex
result                : PASS

=================================================
CASE                  : blank-empty-string
input.originAgent     : ""
append.ok             : true
messageId             : 9943b96b-9bfc-4050-a997-b362c10e2422
stamped.provider      : openclaw
stamped.model         : gateway-injected
stamped.originAgent   : <absent>
expected.originAgent  : absent
result                : PASS

=================================================
CASE                  : whitespace-only
input.originAgent     : "   "
append.ok             : true
messageId             : 2aa2899d-a2f7-4679-a404-4236009f8c79
stamped.provider      : openclaw
stamped.model         : gateway-injected
stamped.originAgent   : <absent>
expected.originAgent  : absent
result                : PASS

=================================================
CASE                  : trim-surrounding-whitespace
input.originAgent     : "  hermes  "
append.ok             : true
messageId             : 4b1f86b7-060d-4cce-a81a-0cfcfb70ddca
stamped.provider      : openclaw
stamped.model         : gateway-injected
stamped.originAgent   : hermes
expected.originAgent  : hermes
result                : PASS

Schema-layer terminal output (live AJV validator on patched schema):

ChatInjectParamsSchema validator behavior:

---
CASE     : legacy params (sessionKey + message + label)
accepted : true
---
CASE     : patched params (with non-empty originAgent)
accepted : true
---
CASE     : rejects empty-string originAgent (minLength:1)
accepted : false
errors   : [{"instancePath":"/originAgent","schemaPath":"#/properties/originAgent/minLength","keyword":"minLength","params":{"limit":1},"message":"must NOT have fewer than 1 characters"}]
---
CASE     : rejects unknown extra fields (additionalProperties:false preserved)
accepted : false
errors   : [{"instancePath":"","schemaPath":"#/additionalProperties","keyword":"additionalProperties","params":{"additionalProperty":"somethingMadeUp"},"message":"must NOT have additional properties"}]

Observed result after fix:

  • Baseline call (no originAgent) → persisted entry has provider: "openclaw", model: "gateway-injected", no originAgent field. Byte-identical to current-main behavior; replay filter still matches.
  • Patched call (originAgent: "hermes") → persisted entry has provider: "openclaw", model: "gateway-injected", and a new originAgent: "hermes" field. Replay filter still matches the sentinel pair and drops the row; UI consumers can attribute it to Hermes via the new field.
  • Patched call (originAgent: "codex") → same as above with originAgent: "codex". Confirms the field is not Hermes-specific.
  • Blank input ("" or whitespace-only) → persisted entry has no originAgent field. The persistence helper trims and normalizes, so malformed input cannot land on disk.
  • Trimmed input (" hermes ") → persisted entry has originAgent: "hermes" (whitespace-stripped).
  • Schema validator accepts non-empty originAgent, rejects literal "" via the new minLength: 1, and continues to reject unknown extra fields via the unchanged additionalProperties: false.

What was not tested: End-to-end UI render in the actual webchat surface (visually confirming a Hermes avatar/handle in the live React UI keyed off the new originAgent field) is intentionally out of scope for this PR — the patch only adds the persisted attribution field; UI consumption is a follow-up that will pick up the field from the transcript. The replay-filter regression that the prior PR revision would have introduced is now structurally impossible because the sentinel pair is unchanged; no run-time replay test was added because the contract being preserved already has coverage in src/agents/pi-embedded-runner/replay-history.test.ts.

🤖 Generated with Claude Code

… originAgent

Adds an optional `originAgent` field to ChatInjectParamsSchema. When set,
appendInjectedAssistantMessageToTranscript stamps it as the persisted
assistant entry's `provider` value, so downstream UIs (avatar/handle
resolvers) can correctly attribute cross-agent injects to their actual
source (e.g. "hermes", "codex", "claude-code") instead of always showing
the OpenClaw avatar.

Why:
chat.inject is the canonical RPC for cross-agent transcript writes, but
the persisted entry currently hard-codes provider="openclaw" with no
override path (additionalProperties:false on the schema). When external
agents inject via this RPC, the message renders with OpenClaw's avatar
and the only visible attribution is whatever they include in the body
text. This breaks the "who said what" guarantee in long transcripts.

Security note:
chat.inject is already gated at operator-privilege scope
(method-scopes.ts), so anyone who can call it can already write
arbitrary assistant content. originAgent is purely presentational and
introduces no new impersonation surface beyond what current callers
already have.

Backward compat:
Field is optional. When absent, behavior is unchanged: provider stamps
"openclaw", model stamps "gateway-injected".

Files:
- src/gateway/protocol/schema/logs-chat.ts        (schema field)
- src/gateway/server-methods/chat.ts              (handler + helper threading)
- src/gateway/server-methods/chat-transcript-inject.ts (provider stamp)
@openclaw-barnacle openclaw-barnacle Bot added app: web-ui App: web-ui gateway Gateway runtime size: XS triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. labels May 9, 2026

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

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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bb9eb6c32f

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

// Make these explicit so downstream tooling never treats this as model output.
api: "openai-responses",
provider: "openclaw",
provider: params.originAgent ?? "openclaw",

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 Badge Fallback to default provider when originAgent is empty

originAgent is validated as Type.String and then applied via params.originAgent ?? "openclaw", so requests like {"originAgent":""} persist an assistant message with provider: "" instead of the documented default openclaw. This creates malformed attribution records for chat.inject and can break downstream avatar/handle resolution paths that expect a non-empty provider; treat empty/whitespace values as unset before persisting.

Useful? React with 👍 / 👎.

@clawsweeper

clawsweeper Bot commented May 9, 2026

Copy link
Copy Markdown
Contributor

Thanks for the contribution. This PR has been inactive for a while and still is not in a landable state.

Close this stale, dirty PR: its useful attribution idea was never ported through the active gateway-protocol and transcript-persistence boundaries, so the unchanged May branch is not a viable landing candidate.

So I’m closing this as inactive for now. If you pick the work back up, push a rebased branch with green checks and reopen (or open a fresh PR) and it will be reviewed again.

Review details

Best possible solution:

If maintainers want caller attribution, define and approve it as an additive gateway-protocol contract, port it through the current persistence and display-projection paths, regenerate clients, and submit a fresh current-main PR with focused coverage.

Do we have a high-confidence way to reproduce the issue?

Not applicable: this PR proposes an additive gateway attribution capability, not a regression with an established current-main failure path.

Is this the best way to solve the issue?

No. The branch changes a retired schema location while current chat.inject validates against packages/gateway-protocol and persists through newer helper boundaries, so a clean current-main design is required.

Security review:

Security review cleared: The branch adds no dependency, workflow, permission, secret, download, or supply-chain surface; its replay-sentinel preservation avoids the earlier transcript-replay regression.

AGENTS.md: unclear because the file could not be read completely.

What I checked:

Likely related people:

  • jalehman: Authored the merged transcript-update identity contract that reshaped the current persistence and event boundaries around this feature area. (role: recent adjacent contributor; confidence: medium; commits: 475252453b4b; files: src/sessions/transcript-events.ts, src/gateway/server-methods/chat.ts)

Codex review notes: model internal, reasoning high; reviewed against 359859d343bd.

…Agent

Two committed scripts under scripts/ that exercise the patched code paths
against synthetic transcript fixtures, capturing live runtime output:

  scripts/proof-chat-inject-origin-agent.mts
    - calls appendInjectedAssistantMessageToTranscript three times with
      and without originAgent, reads back the persisted jsonl, prints
      the resulting `provider` field for each case
    - confirms baseline default ("openclaw") and patched override behavior

  scripts/proof-chat-inject-schema.mts
    - calls validateChatInjectParams against legacy params, params with
      originAgent, and params with an unknown extra field
    - confirms originAgent is now accepted while additionalProperties:false
      still rejects truly unknown fields

Maintainer can rerun:
  tsx scripts/proof-chat-inject-origin-agent.mts
  tsx scripts/proof-chat-inject-schema.mts
@openclaw-barnacle openclaw-barnacle Bot added scripts Repository scripts size: S proof: supplied External PR includes structured after-fix real behavior proof. and removed size: XS triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. labels May 9, 2026
@clawsweeper clawsweeper Bot added the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 9, 2026
… field

Addresses Codex P1 + P2 review on this PR.

P1 (correctness): The replay-history filter at
src/agents/pi-embedded-runner/replay-history.ts:271 keys off the pair
(provider="openclaw", model in {"delivery-mirror","gateway-injected"}) to
drop transcript-only injected rows from being replayed back to the next
model call. Overwriting `provider` to the originAgent value broke that
filter, so transcript-only injects could leak into model context.

Fix: keep provider="openclaw" and model="gateway-injected" unchanged on
every injected row. Add `originAgent` as a separate, explicit persisted
field. UI avatar/handle resolvers may key off this new field for display
attribution. The replay filter continues to drop these rows by the
unchanged sentinel pair.

P2 (input hygiene):
  - Schema: tighten originAgent to Type.String({ minLength: 1, maxLength: 100 })
    so empty literal "" is rejected at validation time.
  - Persistence helper: trim whitespace and treat blanks as unset before
    writing, defending against any future bypass of the schema check.

Files:
  src/gateway/protocol/schema/logs-chat.ts          minLength:1
  src/gateway/server-methods/chat-transcript-inject.ts
                                                    new field, no provider change
  apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
                                                    regenerated by pnpm protocol:gen:swift

Proof scripts updated to assert the new contract (provider stays
"openclaw" in every case; originAgent is a separate field that is absent
on baseline, set on attributed calls, and absent on blank input).
@openclaw-barnacle openclaw-barnacle Bot removed the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 9, 2026
oxlint typescript-eslint/no-redundant-type-constituents flagged
`string | "absent"` as collapsing to just `string`. Use bare `string`;
the "absent" sentinel value is still meaningful at runtime.
@clawsweeper clawsweeper Bot added the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 9, 2026
…ield

Stale JSDoc claimed originAgent stamps the persisted entry's provider field,
but the revised design (commit bf61a0c) preserves the provider/model sentinel
pair unchanged and persists originAgent as a separate display-attribution
field. Doc now matches behavior; also notes that empty/whitespace values are
treated as unset.

Addresses clawsweeper P3 review feedback on PR openclaw#79959.
@wherewolf87

Copy link
Copy Markdown
Author

Review feedback addressed in abbb28f.

Finding 1 (Codex P2 — empty/whitespace originAgent): Already addressed in commit bf61a0cf prior to review intake. normalizedOriginAgent trims and treats empty/whitespace as unset before applying; no changes needed. Re-verified against current branch.

Finding 2 (clawsweeper P3 — stale JSDoc): Fixed. JSDoc now correctly states originAgent persists as a separate display-attribution field while the provider: "openclaw" + model: "gateway-injected" sentinel pair is preserved for replay filtering. Also noted whitespace-only values are treated as unset.

Replay sentinel is intact. Ready for re-review. 🦞

@openclaw-barnacle openclaw-barnacle Bot removed the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 10, 2026
@clawsweeper clawsweeper Bot added the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 10, 2026
@openclaw-barnacle

Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added the stale Marked as stale due to inactivity label Jun 1, 2026
@clawsweeper clawsweeper Bot added rating: 🌊 off-meta tidepool PR readiness rating does not apply to this item. and removed proof: sufficient ClawSweeper judged the real behavior proof convincing. labels Jun 1, 2026
@openclaw-barnacle openclaw-barnacle Bot removed the stale Marked as stale due to inactivity label Jun 2, 2026
@clawsweeper clawsweeper Bot added the proof: sufficient ClawSweeper judged the real behavior proof convincing. label Jun 14, 2026
@clawsweeper clawsweeper Bot added rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. P2 Normal backlog priority with limited blast radius. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. and removed rating: 🌊 off-meta tidepool PR readiness rating does not apply to this item. labels Jun 14, 2026
@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. and removed rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. labels Jun 21, 2026
@openclaw-barnacle

Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added the stale Marked as stale due to inactivity label Jul 19, 2026
@clawsweeper clawsweeper Bot removed the merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. label Jul 21, 2026
@clawsweeper

clawsweeper Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

ClawSweeper applied the proposed close for this PR.

@clawsweeper clawsweeper Bot closed this Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: web-ui App: web-ui gateway Gateway runtime P2 Normal backlog priority with limited blast radius. proof: sufficient ClawSweeper judged the real behavior proof convincing. proof: supplied External PR includes structured after-fix real behavior proof. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. scripts Repository scripts size: S stale Marked as stale due to inactivity status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant