Skip to content

refactor: canonicalize chat.send/cron route derivation#35451

Closed
mcaxtr wants to merge 7 commits into
openclaw:mainfrom
mcaxtr:refactor/chat-send-cron-route-canonicalization
Closed

refactor: canonicalize chat.send/cron route derivation#35451
mcaxtr wants to merge 7 commits into
openclaw:mainfrom
mcaxtr:refactor/chat-send-cron-route-canonicalization

Conversation

@mcaxtr

@mcaxtr mcaxtr commented Mar 5, 2026

Copy link
Copy Markdown
Member

Summary

Describe the problem and fix in 2–5 bullets:

  • Problem: route derivation policy was duplicated across chat.send, cron delivery target resolution, and cron announce dispatch, with different fallback rules.
  • Why it matters: policy drift caused stale/cross-channel routing behavior and inconsistent strict vs best-effort delivery handling.
  • What changed: canonical route decisions now go through shared helpers in src/infra/outbound/targets.ts; chat.send and cron paths were migrated to that logic; explicit delivery intent semantics were applied; and AGENTS.md was updated with canonicalization guardrails and required parity-test coverage.
  • What did NOT change (scope boundary): no command-auth canonicalization changes, no plugin-sdk/root-alias changes, no channel adapter protocol rewrites.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

List user-visible changes (including defaults/config).
If none, write None.

  • chat.send now accepts explicit turn-source routing fields:
    • turnSourceChannel
    • turnSourceTo
    • turnSourceAccountId
    • turnSourceThreadId
  • chat.send delivery intent behavior is explicit:
    • deliver=false => internal only
    • deliver unset => external preferred (fallback to internal when unresolved)
    • deliver=true => external required (returns INVALID_REQUEST if unresolved)
  • Main/shared session aliases are fail-closed for mutable last* route inheritance unless explicit source/target is provided.
  • Cron announce route resolution no longer silently falls back to main session in strict mode; strict mode fails, best-effort mode continues without delivery.

Security Impact (required)

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

Repro + Verification

Environment

  • OS: Linux mcaxtr-ubuntu 6.17.0-14-generic x86_64
  • Runtime/container: local workspace shell
  • Model/provider: N/A
  • Integration/channel (if any): gateway chat routing + cron isolated-agent delivery routing (telegram/feishu fixtures in tests)
  • Relevant config (redacted): OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1, Node v24.13.1, pnpm 10.23.0

Steps

  1. Run:
    OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test src/infra/outbound/targets.test.ts src/gateway/server-methods/chat.directive-tags.test.ts src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts
  2. Validate fail-closed main-alias behavior and channel-scoped inheritance assertions.
  3. Validate strict vs best-effort cron routing assertions and explicit turn-source precedence assertions.

Expected

  • Canonical route policy is applied consistently across chat and cron call-sites.
  • Main aliases do not inherit mutable last* routing by default.
  • Turn-source metadata has precedence.
  • Strict delivery fails unresolved routes; best-effort degrades safely.

Actual

  • Targeted test suites passed:
    • src/infra/outbound/targets.test.ts
    • src/gateway/server-methods/chat.directive-tags.test.ts
    • src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts

Evidence

Attach at least one:

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

Human Verification (required)

What you personally verified (not just CI), and how:

  • Verified scenarios:
    • main-alias fail-closed route behavior
    • channel-scoped route inheritance behavior
    • explicit turn-source precedence behavior
    • deliver=true unresolved route error behavior
    • cron strict vs best-effort route-resolution behavior
  • Edge cases checked:
    • incomplete turn-source does not reuse stale route
    • cron delivery.mode=none does not attempt outbound delivery
  • What you did not verify:
    • full repository-wide test matrix

Compatibility / Migration

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

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly:
    • Revert this PR (or revert commits 0c87950cc, 41bd2e151, 9dd3c6893, 546bac75c).
  • Files/config to restore:
    • src/infra/outbound/targets.ts
    • src/gateway/server-methods/chat.ts
    • src/cron/isolated-agent/delivery-target.ts
    • src/cron/isolated-agent/delivery-dispatch.ts
    • src/cron/isolated-agent/run.ts
    • AGENTS.md
  • Known bad symptoms reviewers should watch for:
    • external delivery unavailable:* errors when callers set deliver=true without resolvable external route
    • strict cron jobs returning delivery errors where fallback behavior was previously expected

Risks and Mitigations

List only real risks for this PR. Add/remove entries as needed. If none, write None.

  • Risk:
    • Workflows relying on implicit fallback behavior may now fail explicitly in strict mode.
    • Mitigation:
      • Explicit delivery-intent contract and regression tests covering strict and best-effort paths.
  • Risk:
    • Future call-sites could bypass canonical helper logic and reintroduce drift.
    • Mitigation:
      • AGENTS.md guardrails + parity-test requirements for route-path changes.

@aisle-research-bot

aisle-research-bot Bot commented Mar 5, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

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

# Severity Title
1 🟡 Medium Cron outbound delivery can leak outputs by inheriting mutable main-session last route (fail-open recipient selection)
2 🔵 Low Unbounded turnSource* routing metadata allows resource exhaustion and session/prompt poisoning

1. 🟡 Cron outbound delivery can leak outputs by inheriting mutable main-session last route (fail-open recipient selection)

Property Value
Severity Medium
CWE CWE-284
Location src/cron/isolated-agent/delivery-target.ts:63-77

Description

resolveDeliveryTarget() for cron jobs can implicitly select the outbound recipient from the agent main session last* routing metadata, and it explicitly does not enable the new failClosedMainSessionLastRoute protection.

In multi-user/shared deployments (where multiple users can update the shared main session’s lastTo/lastChannel), this creates a misdelivery / information-disclosure risk:

  • Input (attacker influence): an arbitrary user interacts with the agent and updates the shared main session’s lastTo/lastChannel.
  • Routing decision: cron delivery resolution reads store[mainSessionKey] and allows inheriting last* fields.
  • Sink: dispatchCronDelivery() uses the resolved {channel,to} to send externally (announce/direct), potentially sending cron results to the attacker’s recipient.

This is especially risky for cron jobs that omit an explicit delivery.to and rely on delivery.channel=last (the default), because the recipient becomes whichever user last touched the shared main session.

Vulnerable code:

// defaults to main session entry and does not enable fail-closed last-route blocking
const main = threadEntry ?? store[mainSessionKey];// Intentionally do not set `failClosedMainSessionLastRoute` here...
const preliminary = resolveSessionDeliveryTarget({
  entry: main,
  requestedChannel,
  explicitTo,
  allowMismatchedLastTo,
  sessionKey: sessionRouteKey,
  mainKey: sessionMainKey,
});

Recommendation

Avoid inheriting last* route metadata from shared main sessions for cron external delivery.

Options (prefer combining 1 + 2):

  1. Require explicit recipient for cron external delivery (recommended for external_required): if delivery.to is missing, fail the run (or skip for best-effort).

  2. Enable fail-closed last-route blocking when resolving cron delivery targets, using the newly added controls:

// compute intent before resolving target
const deliveryBestEffort = resolveCronDeliveryBestEffort(job);
const deliveryIntent: DeliveryIntent = !deliveryRequested || deliveryPlan.mode === "none"
  ? "internal_only"
  : deliveryBestEffort ? "external_preferred" : "external_required";

const failClosed = deliveryIntent !== "internal_only";

const preliminary = resolveSessionDeliveryTarget({
  entry: main,
  requestedChannel,
  explicitTo,
  allowMismatchedLastTo,
  sessionKey: sessionRouteKey,
  mainKey: sessionMainKey,
  failClosedMainSessionLastRoute: failClosed,
});
  1. For best-effort delivery, consider using resolveSessionRouteDecision({ intent, ..., failClosedMainSessionLastRoute: true }) and only deliver when status === "resolved"; otherwise skip with a clear warning.

These changes prevent an untrusted user from steering cron deliveries by racing/updating the shared main session’s lastTo.


2. 🔵 Unbounded turnSource* routing metadata allows resource exhaustion and session/prompt poisoning

Property Value
Severity Low
CWE CWE-400
Location src/gateway/server-methods/chat.ts:860-873

Description

The new turnSource* override fields (turnSourceChannel, turnSourceTo, turnSourceAccountId, turnSourceThreadId) are accepted with minimal validation and no size/character constraints.

Even though overrides are gated to node role or operator.admin scope, the gateway:

  • Trims but does not enforce max length for turnSourceTo/turnSourceAccountId, allowing very large strings.
  • Accepts turnSourceThreadId as string | number with no finiteness/integer bound checks, so extremely large numeric values (e.g. 1e309Infinity in JS) or huge strings can flow through.
  • Uses the resolved values as authoritative routing metadata in MsgContext (OriginatingTo, AccountId, MessageThreadId), which is subsequently used by session state and prompt building (e.g., included in the “trusted metadata” JSON block via buildInboundMetaSystemPrompt). This can bloat session storage and model prompts and can destabilize downstream delivery logic.

Vulnerable code (no length/bounds validation before using attacker-provided values):

const turnSourceTo =
  allowTurnSourceOverride && typeof p.turnSourceTo === "string" && p.turnSourceTo.trim()
    ? p.turnSourceTo.trim()
    : undefined;
...
const turnSourceThreadId =
  allowTurnSourceOverride && p.turnSourceThreadId != null && p.turnSourceThreadId !== ""
    ? p.turnSourceThreadId
    : undefined;

Recommendation

Harden validation at the schema and handler layer:

  1. Add max-length and character constraints in ChatSendParamsSchema for all turnSource* fields.
  2. Normalize and bound turnSourceThreadId to a safe integer/string range.
  3. Optionally reject control characters (CR/LF, NUL, etc.) in these metadata fields to avoid downstream log/prompt issues.

Example (TypeBox schema hardening):

const TurnSourceChannel = Type.Optional(Type.String({ maxLength: 32 }));
const TurnSourceTo = Type.Optional(Type.String({ maxLength: 256 }));
const TurnSourceAccountId = Type.Optional(Type.String({ maxLength: 128 }));
const TurnSourceThreadId = Type.Optional(
  Type.Union([
    Type.String({ maxLength: 128 }),
    Type.Integer({ minimum: 0, maximum: 9_007_199_254_740_991 }), // JS safe int
  ]),
);

Example (runtime validation in chat.send):

function assertMaxLen(name: string, v: string, max: number) {
  if (v.length > max) throw new Error(`${name} too long`);
  return v;
}

const turnSourceTo = allowTurnSourceOverride && typeof p.turnSourceTo === "string"
  ? assertMaxLen("turnSourceTo", p.turnSourceTo.trim(), 256)
  : undefined;

const turnSourceThreadId = (() => {
  if (!allowTurnSourceOverride || p.turnSourceThreadId == null || p.turnSourceThreadId === "") return undefined;
  if (typeof p.turnSourceThreadId === "number") {
    if (!Number.isFinite(p.turnSourceThreadId)) throw new Error("turnSourceThreadId must be finite");
    return Math.trunc(p.turnSourceThreadId);
  }
  return assertMaxLen("turnSourceThreadId", p.turnSourceThreadId.trim(), 128);
})();

Analyzed PR: #35451 at commit d3f0c3c

Last updated on: 2026-03-05T07:14:19Z

@greptile-apps

greptile-apps Bot commented Mar 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR canonicalizes route derivation across chat.send and cron delivery paths by introducing shared helpers (resolveSessionDeliveryTarget extensions and the new resolveSessionRouteDecision) in src/infra/outbound/targets.ts. It replaces duplicated, divergent routing logic that caused stale and cross-channel routing behavior with a single explicit DeliveryIntent contract (internal_only / external_preferred / external_required) and a fail-closed main-alias guard.

Key changes:

  • chat.send now accepts explicit turnSource* routing fields and maps the deliver boolean to a typed DeliveryIntent; deliver=true with an unresolvable external route now fails explicitly with INVALID_REQUEST rather than silently degrading.
  • resolveSessionDeliveryTarget gains sessionKey, mainKey, and failClosedMainSessionLastRoute parameters; when enabled, main/shared-session aliases no longer inherit mutable last* route fields, and non-channel-scoped sessions (e.g. cron, run) are equally blocked from inheriting external routes.
  • Cron announce session key resolution is refactored into a typed result union (CronAnnounceSessionRouteResolution) that eliminates the silent main-session fallback; strict mode now fails explicitly, best-effort mode degrades safely without delivery.
  • AGENTS.md codifies the canonicalization contract and required parity-test coverage for future route-path changes.

One subtle ordering detail worth being aware of: when both turnSourceChannel (without turnSourceTo) and a main-alias session key with failClosedMainSessionLastRoute apply simultaneously, resolveSessionRouteDecision returns missing_turn_source_to (status "missing") rather than main_alias_blocked (status "blocked"). This is the correct and more actionable result, but is currently undocumented in the function body.

Additionally, delivery-target.ts intentionally omits failClosedMainSessionLastRoute from the cron delivery target resolveSessionDeliveryTarget calls — the fail-closed behaviour for cron is enforced downstream in delivery-dispatch.ts. A short inline comment clarifying this design decision would reduce the risk of a future contributor inadvertently adding it here and breaking cron jobs that legitimately rely on main-session route inheritance.

Confidence Score: 4/5

  • Safe to merge with low risk; the refactor is well-scoped, tested, and backward-compatible, with one minor documentation gap.
  • The core routing logic in resolveSessionRouteDecision and resolveSessionDeliveryTarget is correct and covered by dedicated tests for all four key scenarios (main alias fail-closed, turn-source precedence, channel-scoped inheritance, internal-only blocking). The intent contract is applied consistently across chat.send and cron paths. No security or data-access surface changes. One point deducted for the undocumented asymmetry between chat.send (fail-closed enabled) and delivery-target.ts (fail-closed not enabled), which increases the risk of future drift — the very problem this PR is trying to solve.
  • src/cron/isolated-agent/delivery-target.ts — the intentional absence of failClosedMainSessionLastRoute is undocumented and could be misread as an omission in a future audit.

Last reviewed commit: 212ebe2

@mcaxtr
mcaxtr force-pushed the refactor/chat-send-cron-route-canonicalization branch from 546bac7 to 79f7791 Compare March 5, 2026 04:58
@mcaxtr

mcaxtr commented Mar 5, 2026

Copy link
Copy Markdown
Member Author

@greptile review
@codex review

@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: 79f7791b07

ℹ️ 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 thread src/infra/outbound/targets.ts Outdated
Comment thread src/cron/isolated-agent/delivery-dispatch.ts Outdated
@mcaxtr

mcaxtr commented Mar 5, 2026

Copy link
Copy Markdown
Member Author

@greptile review
@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown
Contributor

Codex Review: Didn't find any major issues. Another round soon, please!

ℹ️ 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 thread src/infra/outbound/targets.ts
Comment thread src/cron/isolated-agent/delivery-target.ts
@mcaxtr

mcaxtr commented Mar 5, 2026

Copy link
Copy Markdown
Member Author

Addressed Aisle findings in commit 72af51d.

  1. High: chat.send turnSource confused-deputy risk
  • Added authorization gate in src/gateway/server-methods/chat.ts.
  • turnSource overrides are now rejected unless caller is trusted (node role or operator.admin).
  • Untrusted override attempts now return INVALID_REQUEST.
  • Added regression coverage:
    • src/gateway/server-methods/chat.directive-tags.test.ts
    • src/gateway/server.chat.gateway-server-chat.test.ts
  1. Low: cron announce route error leakage
  • Sanitized announce route-resolution error propagation in src/cron/isolated-agent/delivery-dispatch.ts.
  • Removed recipient-id/raw exception leakage from user-facing/run error paths.
  • Strict mode now returns generic route-resolution failure text; best-effort logs generic message.
  • Updated tests:
    • src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts

Validation:

  • OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test src/gateway/server-methods/chat.directive-tags.test.ts src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts src/infra/outbound/targets.test.ts
  • Result: 3 files passed, 90 tests passed.

@mcaxtr

mcaxtr commented Mar 5, 2026

Copy link
Copy Markdown
Member Author

@greptile codex

@greptile-apps

greptile-apps Bot commented Mar 5, 2026

Copy link
Copy Markdown
Contributor

I need more context about your question. Are you asking about:

  • A specific file or module named "codex" in this PR?
  • A coding standard or documentation guideline?
  • Something else related to this route canonicalization refactor?

@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: d3f0c3c3a7

ℹ️ 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 +331 to +334
if (resolved.channel && resolved.to) {
return {
status: "resolved",
reason: "ok",

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 Validate turn-source completeness before resolving fallback route

resolveSessionRouteDecision returns a resolved target immediately when resolveSessionDeliveryTarget finds any session last* route, before checking whether the caller supplied an incomplete turn-source override. If an admin chat.send request includes turnSourceTo but no valid turnSourceChannel (for example a typo that normalizes to undefined) and the session has stale channel metadata, this path silently reuses the stale recipient instead of surfacing a missing-turn-source error, which can misroute external replies to the wrong chat.

Useful? React with 👍 / 👎.

@thewilloftheshadow thewilloftheshadow added r: too-many-prs Auto-close: author has more than twenty active PRs. and removed experienced-contributor labels Mar 6, 2026
@openclaw-barnacle

Copy link
Copy Markdown

Closing this PR because the author has more than 10 active PRs in this repo. Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit.

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 r: too-many-prs Auto-close: author has more than twenty active PRs. size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants