Skip to content

[codex] fix(onboard): normalize channel setup metadata#66706

Merged
vincentkoc merged 2 commits intomainfrom
fix/channel-setup-metadata-normalization
Apr 14, 2026
Merged

[codex] fix(onboard): normalize channel setup metadata#66706
vincentkoc merged 2 commits intomainfrom
fix/channel-setup-metadata-normalization

Conversation

@darkamenosa
Copy link
Copy Markdown
Member

Summary

Describe the problem and fix in 2–5 bullets:

  • Problem: channel setup/onboarding could render malformed channel metadata as undefined: undefined and degrade picker labels when a setup/runtime plugin registered incomplete meta.
  • Why it matters: onboarding is supposed to stay metadata-driven and resilient even when plugin metadata is partial; the regression could block users in config channels and make Telegram/external channel setup look broken.
  • What changed: channel registrations now normalize and repair required channel display metadata at registry time, discovery/setup uses the same shared normalization logic, and onboarding primer/picker now consume one normalized discovery source.
  • What did NOT change (scope boundary): this PR does not change channel runtime behavior, auth/token handling, or plugin install policy; it only hardens channel metadata handling on registration and setup surfaces.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • 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

Root Cause (if applicable)

  • Root cause: src/commands/channel-setup/discovery.ts let incomplete plugin meta overwrite the canonical bundled channel metadata for the same channel id, and src/flows/channel-setup.ts built the primer from a separate raw metadata path instead of the resolved setup entries.
  • Missing detection / guardrail: channel registration accepted malformed display metadata without a generic normalization/repair seam, and setup coverage did not lock in malformed-plugin metadata behavior across primer and picker surfaces.
  • Contributing context (if known): setup-only/setup-runtime plugin flows can expose partial metadata earlier than other runtime surfaces, so a weak display contract there leaked directly into onboarding.

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: src/plugins/channel-validation.test.ts, src/commands/channel-setup/discovery.test.ts, src/plugins/loader.test.ts, src/commands/onboard-channels.e2e.test.ts
  • Scenario the test should lock in: incomplete channel meta must be repaired to sane labels/docs/blurb, bundled channels must preserve canonical bundled metadata, and setup primer/picker must never render undefined labels for malformed external plugins.
  • Why this is the smallest reliable guardrail: the bug crossed three seams: registration, discovery merge, and onboarding rendering. Covering only one seam would leave the other two free to regress.
  • Existing test that already covers this (if any): the explicit dmScope primer test still covers the shared primer rendering path.
  • If no new test is added, why not: N/A

User-visible / Behavior Changes

Users no longer see undefined: undefined in the “How channels work” note or broken picker labels when a plugin registers incomplete channel metadata. Bundled channels keep their canonical labels/docs in setup even if a setup plugin omits those fields.

Diagram (if applicable)

Before:
[plugin registers partial channel meta]
-> [discovery/raw onboarding paths consume incomplete meta]
-> [primer/picker render undefined labels]

After:
[plugin registers partial channel meta]
-> [registry normalizes/repairs required display fields]
-> [discovery preserves bundled canonical meta when applicable]
-> [primer/picker render stable labels and blurbs]

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: macOS
  • Runtime/container: local Node/Bun dev tree
  • Model/provider: N/A
  • Integration/channel (if any): onboarding / channel setup, including Telegram-like and external channel entries
  • Relevant config (redacted): empty config plus malformed test plugin metadata (meta: { id: "external-chat" } / meta: { id: "telegram" })

Steps

  1. Register or discover a channel setup plugin whose meta omits label, selectionLabel, docsPath, or blurb.
  2. Run the onboarding/setup flow until config channels shows the “How channels work” note or channel picker.
  3. Inspect the primer lines and picker labels.

Expected

  • Setup should show stable labels and blurbs, and built-in channels should retain their canonical bundled metadata.

Actual

  • Setup could render undefined: undefined in the primer and degrade picker labels/hints for the malformed channel entry.

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: malformed external setup plugin no longer renders undefined in primer; malformed external setup plugin remains selectable with sane picker text; bundled Telegram metadata is preserved when an installed plugin omits display fields; registry-time normalization repairs incomplete channel metadata and emits a warning diagnostic.
  • Edge cases checked: bundled channel id with partial metadata, external channel id with partial metadata, mismatched meta.id, import-cycle regressions after adding the new normalization seam.
  • What you did not verify: full manual interactive onboarding in the production CLI binary against a live plugin install.

Review Conversations

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

If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers.

Compatibility / Migration

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

Risks and Mitigations

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

  • Risk: future plugin authors may rely on silent metadata repair instead of providing complete ChannelMeta.
    • Mitigation: the registry now emits a warning diagnostic for incomplete metadata, and the new direct normalization test locks the repair contract in one place.

@openclaw-barnacle openclaw-barnacle bot added commands Command implementations size: L maintainer Maintainer-authored PR labels Apr 14, 2026
@darkamenosa darkamenosa marked this pull request as ready for review April 14, 2026 17:29
@darkamenosa darkamenosa requested a review from vincentkoc April 14, 2026 17:30
Copy link
Copy Markdown

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

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2943ca6651

ℹ️ 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 +29 to +31
const selectionLabel =
normalizeOptionalString(next?.selectionLabel) ??
normalizeOptionalString(existing?.selectionLabel) ??
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 Use updated label when repairing missing selection labels

When a bundled channel registration provides meta.label but omits selectionLabel, this fallback order picks existing.selectionLabel before the newly resolved label, so setup pickers can keep showing the old canonical name even though other surfaces now use the new label. This inconsistency is introduced by the new normalization path and is visible because selection UIs prefer selectionLabel when present; falling back to label before existing.selectionLabel avoids mismatched channel names.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 14, 2026

Greptile Summary

This PR fixes the undefined: undefined rendering bug in channel setup by introducing normalizeChannelMeta as a shared repair utility and wiring it into registry registration, catalog discovery, and the onboarding primer, replacing three divergent raw-metadata paths with a single normalized source. All remaining findings are P2 style observations.

Confidence Score: 5/5

  • Safe to merge; the fix is well-scoped, correctly structured, and covered by unit, integration, and e2e tests.
  • No P0 or P1 issues found. The two P2 observations (inconsistent blurb empty-string check in collectMissingChannelMetaFields, and redundant double-normalization of catalog entries in discovery) are non-blocking style notes that do not affect correctness or user-visible behavior.
  • No files require special attention.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/plugins/channel-validation.ts
Line: 41-43

Comment:
**Inconsistent missing-field check for `blurb`**

The `blurb` check uses `typeof meta?.blurb !== "string"`, while every other field uses `!normalizeOptionalString(...)`. `normalizeOptionalString("")` returns `undefined`, so `normalizeChannelMeta` will silently fall back to the existing bundled blurb when a plugin supplies `blurb: ""` — but this check won't add `"blurb"` to the missing list, so no warning is emitted. The diagnostic under-reports repair for that case. Using the same predicate as the other fields would keep the two in sync:

```suggestion
  if (!normalizeOptionalString(meta?.blurb)) {
    missing.push("blurb");
  }
```

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/commands/channel-setup/discovery.ts
Line: 100-106

Comment:
**Redundant double-normalization for catalog entries**

`installedCatalogEntries` and `installableCatalogEntries` are already normalized in the `.map()` step above, so the `normalizeChannelMeta` calls inside the later `for` loops (lines ~144–152 and ~155–163) re-normalize an already-normalized value with `existing: undefined` (the `!metaById.has` guard ensures the slot is empty). The second pass is idempotent but wasteful. Since the catalog entries are only written into `metaById` when they have no prior slot, the `.map()` normalization is redundant instead — dropping it there and keeping the loop normalization (where `existing` could eventually be populated by an earlier source) would be cleaner.

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

Reviews (1): Last reviewed commit: "fix(onboard): normalize channel setup me..." | Re-trigger Greptile

Comment on lines +41 to +43
if (typeof meta?.blurb !== "string") {
missing.push("blurb");
}
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 Inconsistent missing-field check for blurb

The blurb check uses typeof meta?.blurb !== "string", while every other field uses !normalizeOptionalString(...). normalizeOptionalString("") returns undefined, so normalizeChannelMeta will silently fall back to the existing bundled blurb when a plugin supplies blurb: "" — but this check won't add "blurb" to the missing list, so no warning is emitted. The diagnostic under-reports repair for that case. Using the same predicate as the other fields would keep the two in sync:

Suggested change
if (typeof meta?.blurb !== "string") {
missing.push("blurb");
}
if (!normalizeOptionalString(meta?.blurb)) {
missing.push("blurb");
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/channel-validation.ts
Line: 41-43

Comment:
**Inconsistent missing-field check for `blurb`**

The `blurb` check uses `typeof meta?.blurb !== "string"`, while every other field uses `!normalizeOptionalString(...)`. `normalizeOptionalString("")` returns `undefined`, so `normalizeChannelMeta` will silently fall back to the existing bundled blurb when a plugin supplies `blurb: ""` — but this check won't add `"blurb"` to the missing list, so no warning is emitted. The diagnostic under-reports repair for that case. Using the same predicate as the other fields would keep the two in sync:

```suggestion
  if (!normalizeOptionalString(meta?.blurb)) {
    missing.push("blurb");
  }
```

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

Comment on lines +100 to +106
.map((entry) => ({
...entry,
meta: normalizeChannelMeta({
id: entry.id as ChannelChoice,
meta: entry.meta,
}),
}));
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 Redundant double-normalization for catalog entries

installedCatalogEntries and installableCatalogEntries are already normalized in the .map() step above, so the normalizeChannelMeta calls inside the later for loops (lines ~144–152 and ~155–163) re-normalize an already-normalized value with existing: undefined (the !metaById.has guard ensures the slot is empty). The second pass is idempotent but wasteful. Since the catalog entries are only written into metaById when they have no prior slot, the .map() normalization is redundant instead — dropping it there and keeping the loop normalization (where existing could eventually be populated by an earlier source) would be cleaner.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/commands/channel-setup/discovery.ts
Line: 100-106

Comment:
**Redundant double-normalization for catalog entries**

`installedCatalogEntries` and `installableCatalogEntries` are already normalized in the `.map()` step above, so the `normalizeChannelMeta` calls inside the later `for` loops (lines ~144–152 and ~155–163) re-normalize an already-normalized value with `existing: undefined` (the `!metaById.has` guard ensures the slot is empty). The second pass is idempotent but wasteful. Since the catalog entries are only written into `metaById` when they have no prior slot, the `.map()` normalization is redundant instead — dropping it there and keeping the loop normalization (where `existing` could eventually be populated by an earlier source) would be cleaner.

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

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@vincentkoc vincentkoc self-assigned this Apr 14, 2026
@vincentkoc vincentkoc force-pushed the fix/channel-setup-metadata-normalization branch from 2943ca6 to 4d83843 Compare April 14, 2026 18:11
@vincentkoc vincentkoc merged commit 58a9905 into main Apr 14, 2026
3 checks passed
@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot bot commented Apr 14, 2026

🔒 Aisle Security Analysis

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

# Severity Title
1 🟠 High External plugin can spoof bundled channel identity by reusing core channel id (e.g., "telegram") and inheriting canonical metadata
2 🟡 Medium Terminal/log escape injection via unsanitized channel/meta IDs in plugin diagnostics
3 🟡 Medium Terminal escape injection via unsanitized channel plugin metadata in setup output
4 🟡 Medium Terminal hyperlink injection via untrusted channel/plugin docsPath (OSC 8 control chars not sanitized)
1. 🟠 External plugin can spoof bundled channel identity by reusing core channel id (e.g., "telegram") and inheriting canonical metadata
Property Value
Severity High
CWE CWE-290
Location src/plugins/channel-validation.ts:91-99

Description

normalizeRegisteredChannelPlugin() merges bundled/core channel metadata into any registered channel plugin when the plugin's id matches a bundled channel id.

Because the manifest registry allows non-bundled plugins to override bundled plugin ids (see comment: “Bundled plugin ids are reserved unless the operator explicitly overrides them.”), an attacker who can get a plugin loaded from config/global with an id matching a core channel (e.g., telegram) can:

  • Register a channel plugin with id: "telegram"
  • Provide incomplete/blank meta
  • Have the system silently fill in core Telegram label/docs/blurb via resolveBundledChannelMeta(id)
  • Appear in setup/discovery UI as the trusted built-in channel while actually executing attacker-controlled plugin code

This creates a phishing/trust-boundary bypass risk: users may enter channel credentials/tokens believing they are configuring the bundled channel, enabling credential exfiltration.

Vulnerable code:

meta: normalizeChannelMeta({
  id,
  meta: rawMeta,
  existing: resolveBundledChannelMeta(id),
})

Recommendation

Do not allow non-bundled plugins to inherit bundled channel metadata (or reuse bundled ids) without an explicit, high-friction opt-in.

Options:

  1. Enforce reserved IDs: if id matches a bundled channel id and the registering plugin is not the bundled implementation, reject registration.

  2. Remove bundled-meta fallback for external plugins: only apply existing: resolveBundledChannelMeta(id) when you can prove the plugin is the bundled one (e.g., by origin/owner/trust level), otherwise treat it as external and require full metadata.

Example (conceptual):

const bundled = resolveBundledChannelMeta(id);
const canInheritBundledMeta = params.source.startsWith(BUNDLED_PLUGINS_ROOT); // or use manifest origin

return {
  ...params.plugin,
  id,
  meta: normalizeChannelMeta({
    id,
    meta: rawMeta,
    existing: canInheritBundledMeta ? bundled : undefined,
  }),
};

Also consider surfacing a clear UI warning when a plugin overrides a bundled channel id (and show the plugin publisher/source) so users cannot be tricked by canonical labels/docs.

2. 🟡 Terminal/log escape injection via unsanitized channel/meta IDs in plugin diagnostics
Property Value
Severity Medium
CWE CWE-117
Location src/plugins/channel-validation.ts:68-88

Description

Plugin/channel IDs taken from plugin registrations are interpolated directly into diagnostic messages, and later printed to the terminal without sanitization.

  • normalizeRegisteredChannelPlugin builds PluginDiagnostic.message using attacker-controlled id / rawMetaId.
  • noteWorkspaceStatus prints diag.message to the terminal via note() (which does not sanitize ANSI/control characters).
  • If a malicious plugin provides an ID containing ANSI escape sequences or control characters (e.g. \x1b[2J, OSC-8 hyperlinks, newlines), this can manipulate terminal output / forge log lines (CWE-117 / terminal escape injection).

Vulnerable code (message creation):

message: `channel "${id}" meta.id mismatch ("${rawMetaId}"); using registered channel id`,

Vulnerable sink (terminal output):

return `- ${prefix}${plugin}: ${diag.message}${source}`;

Recommendation

Sanitize untrusted identifiers before placing them into human-facing diagnostic strings (or at the final output sink).

Use the existing sanitizeForLog() helper (in src/terminal/ansi.ts) to strip ANSI escapes and control characters.

Example fix at diagnostic creation:

import { sanitizeForLog } from "../terminal/ansi.js";

const safeId = sanitizeForLog(id);
const safeRawMetaId = rawMetaId ? sanitizeForLog(rawMetaId) : rawMetaId;

message: `channel "${safeId}" meta.id mismatch ("${safeRawMetaId}"); using registered channel id`,

Alternatively, enforce a strict allowlist for plugin/channel IDs (e.g., /^[a-z0-9][a-z0-9._-]*$/i), rejecting/diagnosing invalid IDs, and sanitize at the output sink in noteWorkspaceStatus before printing.

3. 🟡 Terminal escape injection via unsanitized channel plugin metadata in setup output
Property Value
Severity Medium
CWE CWE-150
Location src/channels/registry.ts:97-112

Description

Channel plugin metadata fields are interpolated directly into terminal-facing strings without sanitization.

  • External/bundled plugins can provide meta.label, meta.blurb, and other selection fields.
  • These values are concatenated into lines shown to users during onboarding/setup (e.g., primer and selection note lines).
  • Because no escaping/stripping is applied, a malicious plugin can include ANSI escape sequences (e.g., \u001b[...m, cursor movement, screen clearing) or control characters/newlines to spoof UI prompts, hide warnings, or inject misleading content (terminal injection).

Vulnerable code:

export function formatChannelPrimerLine(meta: ChannelMeta): string {
  return `${meta.label}: ${meta.blurb}`;
}

export function formatChannelSelectionLine(...): string {
  ...
  return `${meta.label}${meta.blurb} ... ${docs}${extras ? ` ${extras}` : ""}`;
}

Although formatTerminalLink() strips the ESC character for hyperlinks, these other output paths do not, and they also do not remove other control characters (e.g., \n, \r, \t, \u0007).

Recommendation

Sanitize/escape any plugin-controlled strings before rendering them to a terminal UI.

A practical approach is to strip ANSI escape sequences and other control characters (including newlines) from label, blurb, and related fields at render time (or during normalization/validation).

Example (render-time sanitization):

import stripAnsi from "strip-ansi";

function sanitizeForTerminal(s: string): string {// Remove ANSI sequences, then remove remaining C0 controls except common whitespace.
  const noAnsi = stripAnsi(s);
  return noAnsi
    .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
    .replace(/[\r\n]/g, " ");
}

export function formatChannelPrimerLine(meta: ChannelMeta): string {
  return `${sanitizeForTerminal(meta.label)}: ${sanitizeForTerminal(meta.blurb)}`;
}

Additionally, consider validating docsPath to a safe URL/path format (no control characters) before passing it into link formatting.

4. 🟡 Terminal hyperlink injection via untrusted channel/plugin docsPath (OSC 8 control chars not sanitized)
Property Value
Severity Medium
CWE CWE-74
Location src/terminal/terminal-link.ts:6-13

Description

ChannelMeta.docsPath is sourced from plugin-controlled metadata (or catalog entries) and is later rendered into terminal hyperlinks.

Data flow:

  • Input (untrusted): docsPath from external channel plugin metadata via normalizeChannelMeta().
  • Propagation: formatChannelSelectionLine() passes meta.docsPath to formatDocsLink().
  • Sink: formatDocsLink() builds a URL and calls formatTerminalLink(), which embeds the URL/label inside an OSC 8 hyperlink sequence.

formatTerminalLink() only strips the ESC character (\u001b) but does not remove other control characters such as BEL (\u0007), ST, newline, etc. A malicious plugin can include \u0007 in docsPath (or docsLabel) to prematurely terminate the OSC 8 sequence and inject arbitrary terminal text/link sequences (terminal hyperlink injection / output spoofing / phishing).

Vulnerable code:

return `\u001b]8;;${safeUrl}\u0007${safeLabel}\u001b]8;;\u0007`;

This becomes exploitable because safeUrl/safeLabel may still contain \u0007 and other control characters.

Recommendation

Sanitize all C0 control characters (and ideally DEL) from both label and url before embedding into OSC 8 sequences, not just ESC.

Example fix:

function stripControlChars(value: string): string {// Remove C0 controls (0x00-0x1F) and DEL (0x7F)
  return value.replace(/[\x00-\x1F\x7F]/g, "");
}

export function formatTerminalLink(label: string, url: string, opts?: { fallback?: string; force?: boolean }): string {
  const safeLabel = stripControlChars(label);
  const safeUrl = stripControlChars(url);
  const allow = opts?.force === true ? true : opts?.force === false ? false : process.stdout.isTTY;
  if (!allow) {
    return opts?.fallback ?? `${safeLabel} (${safeUrl})`;
  }
  return `\u001b]8;;${safeUrl}\u0007${safeLabel}\u001b]8;;\u0007`;
}

Optionally also constrain docsPath at normalization time (e.g., allow only relative paths like /channels/... and reject any values containing control characters).


Analyzed PR: #66706 at commit 4d83843

Last updated on: 2026-04-14T18:14:50Z

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commands Command implementations maintainer Maintainer-authored PR size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants