From 756de70e84d151ee5bd004e0f89087afd9453fb7 Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Mon, 20 Jul 2026 20:19:34 +0800 Subject: [PATCH] fix(signal): report malformed release metadata cleanly (#110824) * fix(signal): validate release metadata before install * refactor(signal): normalize release metadata once Co-authored-by: Alix-007 * test(signal): prove normalized install version Co-authored-by: Alix-007 --------- Co-authored-by: Peter Steinberger --- .../signal/src/install-signal-cli.test.ts | 36 ++++++++++- extensions/signal/src/install-signal-cli.ts | 59 +++++++++++++++---- 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/extensions/signal/src/install-signal-cli.test.ts b/extensions/signal/src/install-signal-cli.test.ts index de552933323..5881869f1ff 100644 --- a/extensions/signal/src/install-signal-cli.test.ts +++ b/extensions/signal/src/install-signal-cli.test.ts @@ -390,6 +390,31 @@ describe("installSignalCliFromRelease", () => { expect(fetchResult.release).toHaveBeenCalledTimes(1); }); + it.each([ + ["null", "null"], + ["array", "[]"], + ["missing tag_name", JSON.stringify({ assets: [] })], + ["blank tag_name", JSON.stringify({ tag_name: " ", assets: [] })], + ["empty version tag", JSON.stringify({ tag_name: "v", assets: [] })], + ["non-string tag_name", JSON.stringify({ tag_name: 123, assets: [] })], + ["missing assets", JSON.stringify({ tag_name: "v0.14.6" })], + ["non-array assets", JSON.stringify({ tag_name: "v0.14.6", assets: {} })], + ])("returns an installer error for a valid JSON %s payload", async (_kind, body) => { + const fetchResult = okDownloadResponse(body, { + headers: { "content-type": "application/json" }, + }); + fetchWithSsrFGuardMock.mockResolvedValue(fetchResult); + + const result = await installSignalCliFromRelease({ log: vi.fn() } as unknown as RuntimeEnv); + + expect(result).toEqual({ + ok: false, + error: "Failed to parse signal-cli release info.", + }); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1); + expect(fetchResult.release).toHaveBeenCalledTimes(1); + }); + it("bounds oversized GitHub release metadata and cancels the stream", async () => { const chunkSize = 1024 * 1024; const chunkCount = 20; // 20 MiB — over the 16 MiB cap @@ -505,9 +530,11 @@ describe("installSignalCliFromRelease", () => { const result = await installSignalCliFromRelease({ log: vi.fn() } as unknown as RuntimeEnv); expect(result.ok).toBe(true); + expect(result.version).toBe("0.0.0-success-test"); if (!result.cliPath) { throw new Error("expected the installed signal-cli path"); } + expect(result.cliPath).toContain(`${path.sep}0.0.0-success-test${path.sep}`); const installedStat = await fs.stat(result.cliPath); expect(installedStat.isFile()).toBe(true); expect(installedStat.mode & 0o111).not.toBe(0); @@ -517,13 +544,16 @@ describe("installSignalCliFromRelease", () => { } }); - it("removes the download temp dir when the download throws", async () => { + it("skips malformed asset rows while retaining a valid download", async () => { setProcessPlatform("linux", "x64"); fetchWithSsrFGuardMock.mockResolvedValueOnce( okDownloadResponse( JSON.stringify({ tag_name: "v0.0.0-download-failure-test", assets: [ + null, + { name: 42, browser_download_url: "https://example.com/wrong-name.tar.gz" }, + { name: "signal-cli-wrong-url.tar.gz", browser_download_url: false }, { name: "signal-cli-0.0.0-Linux-native.tar.gz", browser_download_url: "https://example.com/linux-native.tar.gz", @@ -539,6 +569,10 @@ describe("installSignalCliFromRelease", () => { installSignalCliFromRelease({ log: vi.fn() } as unknown as RuntimeEnv), ).rejects.toThrow("download failed"); + expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ url: "https://example.com/linux-native.tar.gz" }), + ); await expectTempDownloadDirMissing(); }); }); diff --git a/extensions/signal/src/install-signal-cli.ts b/extensions/signal/src/install-signal-cli.ts index 96d5cd87cd3..26ecf050a53 100644 --- a/extensions/signal/src/install-signal-cli.ts +++ b/extensions/signal/src/install-signal-cli.ts @@ -5,12 +5,16 @@ import path from "node:path"; import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; +import { readProviderJsonObjectResponse } from "openclaw/plugin-sdk/provider-http"; import { runPluginCommandWithTimeout } from "openclaw/plugin-sdk/run-command"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { CONFIG_DIR, extractArchive, resolveBrewExecutable } from "openclaw/plugin-sdk/setup-tools"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + isRecord, + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { withTempDownloadPath } from "openclaw/plugin-sdk/temp-path"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; @@ -24,9 +28,9 @@ export type NamedAsset = { browser_download_url: string; }; -type ReleaseResponse = { - tag_name?: string; - assets?: ReleaseAsset[]; +type SignalCliRelease = { + version: string; + assets: NamedAsset[]; }; const MAX_SIGNAL_CLI_ARCHIVE_BYTES = 256 * 1024 * 1024; @@ -87,6 +91,32 @@ async function cancelUnusedResponseBody(response: Response): Promise { await response.body?.cancel().catch(() => undefined); } +function normalizeReleaseAsset(value: unknown): NamedAsset | undefined { + if (!isRecord(value)) { + return undefined; + } + const name = normalizeOptionalString(value.name); + const browserDownloadUrl = normalizeOptionalString(value.browser_download_url); + return name && browserDownloadUrl + ? { name, browser_download_url: browserDownloadUrl } + : undefined; +} + +function normalizeSignalCliRelease(value: Record): SignalCliRelease | undefined { + const tagName = normalizeOptionalString(value.tag_name); + const version = normalizeOptionalString(tagName?.replace(/^v/, "")); + if (!version || !Array.isArray(value.assets)) { + return undefined; + } + return { + version, + assets: value.assets.flatMap((asset) => { + const normalized = normalizeReleaseAsset(asset); + return normalized ? [normalized] : []; + }), + }; +} + /** * Pick a native release asset from the official GitHub releases. * @@ -315,7 +345,7 @@ export async function installSignalCliFromRelease( }, }); - let payload: ReleaseResponse; + let releaseInfo: SignalCliRelease; try { if (!response.ok) { await cancelUnusedResponseBody(response); @@ -325,7 +355,12 @@ export async function installSignalCliFromRelease( }; } try { - payload = await readProviderJsonResponse(response, "signal.release-info"); + const payload = await readProviderJsonObjectResponse(response, "signal.release-info"); + const normalized = normalizeSignalCliRelease(payload); + if (!normalized) { + throw new Error("Unexpected signal-cli release info"); + } + releaseInfo = normalized; } catch { return { ok: false, @@ -335,9 +370,7 @@ export async function installSignalCliFromRelease( } finally { await release(); } - const version = payload.tag_name?.replace(/^v/, "") ?? "unknown"; - const assets = payload.assets ?? []; - const asset = pickAsset(assets, process.platform, process.arch); + const asset = pickAsset(releaseInfo.assets, process.platform, process.arch); if (!asset) { return { @@ -351,10 +384,10 @@ export async function installSignalCliFromRelease( return await withTempDownloadPath( { prefix: "openclaw-signal", fileName: asset.name }, async (archivePath) => { - runtime.log(`Downloading signal-cli ${version} (${asset.name})…`); + runtime.log(`Downloading signal-cli ${releaseInfo.version} (${asset.name})…`); await downloadToFile(asset.browser_download_url, archivePath); - const installRoot = path.join(CONFIG_DIR, "tools", "signal-cli", version); + const installRoot = path.join(CONFIG_DIR, "tools", "signal-cli", releaseInfo.version); await fs.mkdir(installRoot, { recursive: true }); if (!looksLikeArchive(normalizeLowercaseStringOrEmpty(asset.name))) { @@ -383,7 +416,7 @@ export async function installSignalCliFromRelease( return { ok: true, cliPath, - version, + version: releaseInfo.version, }; }, );