Skip to content

Commit 756de70

Browse files
Alix-007steipete
andauthored
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 <[email protected]> * test(signal): prove normalized install version Co-authored-by: Alix-007 <[email protected]> --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 2f967bc commit 756de70

2 files changed

Lines changed: 81 additions & 14 deletions

File tree

extensions/signal/src/install-signal-cli.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,31 @@ describe("installSignalCliFromRelease", () => {
390390
expect(fetchResult.release).toHaveBeenCalledTimes(1);
391391
});
392392

393+
it.each([
394+
["null", "null"],
395+
["array", "[]"],
396+
["missing tag_name", JSON.stringify({ assets: [] })],
397+
["blank tag_name", JSON.stringify({ tag_name: " ", assets: [] })],
398+
["empty version tag", JSON.stringify({ tag_name: "v", assets: [] })],
399+
["non-string tag_name", JSON.stringify({ tag_name: 123, assets: [] })],
400+
["missing assets", JSON.stringify({ tag_name: "v0.14.6" })],
401+
["non-array assets", JSON.stringify({ tag_name: "v0.14.6", assets: {} })],
402+
])("returns an installer error for a valid JSON %s payload", async (_kind, body) => {
403+
const fetchResult = okDownloadResponse(body, {
404+
headers: { "content-type": "application/json" },
405+
});
406+
fetchWithSsrFGuardMock.mockResolvedValue(fetchResult);
407+
408+
const result = await installSignalCliFromRelease({ log: vi.fn() } as unknown as RuntimeEnv);
409+
410+
expect(result).toEqual({
411+
ok: false,
412+
error: "Failed to parse signal-cli release info.",
413+
});
414+
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1);
415+
expect(fetchResult.release).toHaveBeenCalledTimes(1);
416+
});
417+
393418
it("bounds oversized GitHub release metadata and cancels the stream", async () => {
394419
const chunkSize = 1024 * 1024;
395420
const chunkCount = 20; // 20 MiB — over the 16 MiB cap
@@ -505,9 +530,11 @@ describe("installSignalCliFromRelease", () => {
505530
const result = await installSignalCliFromRelease({ log: vi.fn() } as unknown as RuntimeEnv);
506531

507532
expect(result.ok).toBe(true);
533+
expect(result.version).toBe("0.0.0-success-test");
508534
if (!result.cliPath) {
509535
throw new Error("expected the installed signal-cli path");
510536
}
537+
expect(result.cliPath).toContain(`${path.sep}0.0.0-success-test${path.sep}`);
511538
const installedStat = await fs.stat(result.cliPath);
512539
expect(installedStat.isFile()).toBe(true);
513540
expect(installedStat.mode & 0o111).not.toBe(0);
@@ -517,13 +544,16 @@ describe("installSignalCliFromRelease", () => {
517544
}
518545
});
519546

520-
it("removes the download temp dir when the download throws", async () => {
547+
it("skips malformed asset rows while retaining a valid download", async () => {
521548
setProcessPlatform("linux", "x64");
522549
fetchWithSsrFGuardMock.mockResolvedValueOnce(
523550
okDownloadResponse(
524551
JSON.stringify({
525552
tag_name: "v0.0.0-download-failure-test",
526553
assets: [
554+
null,
555+
{ name: 42, browser_download_url: "https://example.com/wrong-name.tar.gz" },
556+
{ name: "signal-cli-wrong-url.tar.gz", browser_download_url: false },
527557
{
528558
name: "signal-cli-0.0.0-Linux-native.tar.gz",
529559
browser_download_url: "https://example.com/linux-native.tar.gz",
@@ -539,6 +569,10 @@ describe("installSignalCliFromRelease", () => {
539569
installSignalCliFromRelease({ log: vi.fn() } as unknown as RuntimeEnv),
540570
).rejects.toThrow("download failed");
541571

572+
expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith(
573+
2,
574+
expect.objectContaining({ url: "https://example.com/linux-native.tar.gz" }),
575+
);
542576
await expectTempDownloadDirMissing();
543577
});
544578
});

extensions/signal/src/install-signal-cli.ts

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@ import path from "node:path";
55
import { Readable, Transform } from "node:stream";
66
import { pipeline } from "node:stream/promises";
77
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
8-
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
8+
import { readProviderJsonObjectResponse } from "openclaw/plugin-sdk/provider-http";
99
import { runPluginCommandWithTimeout } from "openclaw/plugin-sdk/run-command";
1010
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
1111
import { CONFIG_DIR, extractArchive, resolveBrewExecutable } from "openclaw/plugin-sdk/setup-tools";
1212
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
13-
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
13+
import {
14+
isRecord,
15+
normalizeLowercaseStringOrEmpty,
16+
normalizeOptionalString,
17+
} from "openclaw/plugin-sdk/string-coerce-runtime";
1418
import { withTempDownloadPath } from "openclaw/plugin-sdk/temp-path";
1519
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
1620

@@ -24,9 +28,9 @@ export type NamedAsset = {
2428
browser_download_url: string;
2529
};
2630

27-
type ReleaseResponse = {
28-
tag_name?: string;
29-
assets?: ReleaseAsset[];
31+
type SignalCliRelease = {
32+
version: string;
33+
assets: NamedAsset[];
3034
};
3135

3236
const MAX_SIGNAL_CLI_ARCHIVE_BYTES = 256 * 1024 * 1024;
@@ -87,6 +91,32 @@ async function cancelUnusedResponseBody(response: Response): Promise<void> {
8791
await response.body?.cancel().catch(() => undefined);
8892
}
8993

94+
function normalizeReleaseAsset(value: unknown): NamedAsset | undefined {
95+
if (!isRecord(value)) {
96+
return undefined;
97+
}
98+
const name = normalizeOptionalString(value.name);
99+
const browserDownloadUrl = normalizeOptionalString(value.browser_download_url);
100+
return name && browserDownloadUrl
101+
? { name, browser_download_url: browserDownloadUrl }
102+
: undefined;
103+
}
104+
105+
function normalizeSignalCliRelease(value: Record<string, unknown>): SignalCliRelease | undefined {
106+
const tagName = normalizeOptionalString(value.tag_name);
107+
const version = normalizeOptionalString(tagName?.replace(/^v/, ""));
108+
if (!version || !Array.isArray(value.assets)) {
109+
return undefined;
110+
}
111+
return {
112+
version,
113+
assets: value.assets.flatMap((asset) => {
114+
const normalized = normalizeReleaseAsset(asset);
115+
return normalized ? [normalized] : [];
116+
}),
117+
};
118+
}
119+
90120
/**
91121
* Pick a native release asset from the official GitHub releases.
92122
*
@@ -315,7 +345,7 @@ export async function installSignalCliFromRelease(
315345
},
316346
});
317347

318-
let payload: ReleaseResponse;
348+
let releaseInfo: SignalCliRelease;
319349
try {
320350
if (!response.ok) {
321351
await cancelUnusedResponseBody(response);
@@ -325,7 +355,12 @@ export async function installSignalCliFromRelease(
325355
};
326356
}
327357
try {
328-
payload = await readProviderJsonResponse<ReleaseResponse>(response, "signal.release-info");
358+
const payload = await readProviderJsonObjectResponse(response, "signal.release-info");
359+
const normalized = normalizeSignalCliRelease(payload);
360+
if (!normalized) {
361+
throw new Error("Unexpected signal-cli release info");
362+
}
363+
releaseInfo = normalized;
329364
} catch {
330365
return {
331366
ok: false,
@@ -335,9 +370,7 @@ export async function installSignalCliFromRelease(
335370
} finally {
336371
await release();
337372
}
338-
const version = payload.tag_name?.replace(/^v/, "") ?? "unknown";
339-
const assets = payload.assets ?? [];
340-
const asset = pickAsset(assets, process.platform, process.arch);
373+
const asset = pickAsset(releaseInfo.assets, process.platform, process.arch);
341374

342375
if (!asset) {
343376
return {
@@ -351,10 +384,10 @@ export async function installSignalCliFromRelease(
351384
return await withTempDownloadPath(
352385
{ prefix: "openclaw-signal", fileName: asset.name },
353386
async (archivePath) => {
354-
runtime.log(`Downloading signal-cli ${version} (${asset.name})…`);
387+
runtime.log(`Downloading signal-cli ${releaseInfo.version} (${asset.name})…`);
355388
await downloadToFile(asset.browser_download_url, archivePath);
356389

357-
const installRoot = path.join(CONFIG_DIR, "tools", "signal-cli", version);
390+
const installRoot = path.join(CONFIG_DIR, "tools", "signal-cli", releaseInfo.version);
358391
await fs.mkdir(installRoot, { recursive: true });
359392

360393
if (!looksLikeArchive(normalizeLowercaseStringOrEmpty(asset.name))) {
@@ -383,7 +416,7 @@ export async function installSignalCliFromRelease(
383416
return {
384417
ok: true,
385418
cliPath,
386-
version,
419+
version: releaseInfo.version,
387420
};
388421
},
389422
);

0 commit comments

Comments
 (0)