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,
};
},
);