From be7f4a23425826b7192c46a7bb147b8b4e39d873 Mon Sep 17 00:00:00 2001 From: hcl Date: Thu, 16 Apr 2026 02:10:52 +0800 Subject: [PATCH] fix(terminal): tolerate undefined path in formatDocsLink (#67076, #67074) (#67086) formatDocsLink called path.trim() unconditionally. The typed contract says 'docsPath: string' (required on ChannelMeta), but a handful of channel plugins and catalog rows leave it unset at runtime, so onboarding flows that call formatChannelSelectionLine(entry.meta, ...) hit a TypeError on the first meta without a docsPath: TypeError: Cannot read properties of undefined (reading 'trim') Symptom: 'openclaw onboard --install-daemon' and the 'Select channel (QuickStart)' -> 'Skip for now' path both crash on 2026.4.12 and 2026.4.14. Fix: widen formatDocsLink's path parameter to 'string | undefined | null' and fall back to the docs root when path is missing. The single call site that guards with 'if (params.docsPath)' stays fine; the unguarded channel-selection path now degrades gracefully. Fixes #67076 Fixes #67074 --- src/terminal/links.test.ts | 31 +++++++++++++++++++++++++++++++ src/terminal/links.ts | 16 +++++++++++----- 2 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 src/terminal/links.test.ts diff --git a/src/terminal/links.test.ts b/src/terminal/links.test.ts new file mode 100644 index 00000000000..7c070f7200d --- /dev/null +++ b/src/terminal/links.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { formatDocsLink } from "./links.js"; + +describe("formatDocsLink", () => { + it("prepends the docs root when given a relative path", () => { + const out = formatDocsLink("/channels/telegram", "telegram"); + expect(out).toContain("https://docs.openclaw.ai/channels/telegram"); + }); + + it("preserves an absolute http url", () => { + const out = formatDocsLink("https://example.com/page", "page"); + expect(out).toContain("https://example.com/page"); + }); + + it("treats whitespace-only path like an empty path and falls back to docs root", () => { + const out = formatDocsLink(" ", "root"); + expect(out).toContain("https://docs.openclaw.ai"); + }); + + it("does not crash when path is undefined (regression: #67076, #67074)", () => { + expect(() => + formatDocsLink(undefined as unknown as string, "label"), + ).not.toThrow(); + const out = formatDocsLink(undefined as unknown as string, "label"); + expect(out).toContain("https://docs.openclaw.ai"); + }); + + it("does not crash when path is null", () => { + expect(() => formatDocsLink(null as unknown as string)).not.toThrow(); + }); +}); diff --git a/src/terminal/links.ts b/src/terminal/links.ts index f65ad959019..98787f4f497 100644 --- a/src/terminal/links.ts +++ b/src/terminal/links.ts @@ -5,15 +5,21 @@ function resolveDocsRoot(): string { } export function formatDocsLink( - path: string, + path: string | undefined | null, label?: string, opts?: { fallback?: string; force?: boolean }, ): string { - const trimmed = path.trim(); const docsRoot = resolveDocsRoot(); - const url = trimmed.startsWith("http") - ? trimmed - : `${docsRoot}${trimmed.startsWith("/") ? trimmed : `/${trimmed}`}`; + const trimmed = typeof path === "string" ? path.trim() : ""; + // When a caller has no docsPath, link to the docs root rather than crashing + // the onboarding/channel-selection flows that pass meta.docsPath through + // here unguarded. The typed contract says docsPath is required, but a + // handful of channel plugins and catalog rows leave it unset at runtime. + const url = trimmed + ? trimmed.startsWith("http") + ? trimmed + : `${docsRoot}${trimmed.startsWith("/") ? trimmed : `/${trimmed}`}` + : docsRoot; return formatTerminalLink(label ?? url, url, { fallback: opts?.fallback ?? url, force: opts?.force,