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 <li.long15@xydigit.com>

* test(signal): prove normalized install version

Co-authored-by: Alix-007 <li.long15@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Alix-007
2026-07-20 05:19:34 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent 2f967bc8f3
commit 756de70e84
2 changed files with 81 additions and 14 deletions
@@ -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();
});
});
+46 -13
View File
@@ -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<void> {
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<string, unknown>): 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<ReleaseResponse>(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,
};
},
);