mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
co-authored by
Peter Steinberger
parent
2f967bc8f3
commit
756de70e84
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user