Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2141b2a
fix(core): clarify Git requirement for public extensions
yiliang114 Aug 21, 2026
2bc440b
test(core): preserve secure Git version boundary
yiliang114 Aug 21, 2026
f94cb19
fix(core): support public GitHub extensions with older Git
yiliang114 Aug 21, 2026
71432f8
Merge remote-tracking branch 'origin/main' into fix/issue-8993-git-pr…
yiliang114 Aug 21, 2026
ead1b7d
Merge remote-tracking branch 'origin/main' into fix/issue-8993-git-pr…
yiliang114 Aug 21, 2026
9f406bd
fix(core): harden old-Git fallback archive validation against export-…
yiliang114 Aug 21, 2026
8c80e09
test(core): cover archive entry-count and expanded-size limits
yiliang114 Aug 21, 2026
6cc4cb2
Merge branch 'main' into fix/issue-8993-git-prerequisite
yiliang114 Aug 22, 2026
712a817
fix(core): address old-Git fallback review feedback
yiliang114 Aug 22, 2026
fc83415
Merge branch 'main' sync from remote into fix/issue-8993-git-prerequi…
yiliang114 Aug 22, 2026
1d79591
test(core): cover invalid commit SHA rejection in old-Git fallback
yiliang114 Aug 22, 2026
8bead67
fix(test): add missing createReadStream and pipeline mocks in npm test
yiliang114 Aug 22, 2026
34fa64b
perf(core): memoize the local Git version probe
yiliang114 Aug 22, 2026
681d39d
test(core): cover early abort of the tar safety scan
yiliang114 Aug 22, 2026
febbe6f
fix(core): open the tar safety scan stream after the abort check
yiliang114 Aug 22, 2026
1b15bf0
test(core): cover fetchJson redirects and fallback resource limits
yiliang114 Aug 22, 2026
d22605d
fix(test): return a destroyable stream from the npm test fs mock
yiliang114 Aug 22, 2026
b84aa22
fix(test): make fallback anonymity assertions header-case-insensitive
yiliang114 Aug 22, 2026
8104dae
test(core): abort the old-Git fallback through an AbortSignal
yiliang114 Aug 22, 2026
168b8d9
fix(test): pin the manager's fallback call arguments
yiliang114 Aug 22, 2026
3790e3a
test(core): pin fallback symlink rejection, lookup passthrough, per-h…
yiliang114 Aug 22, 2026
502552d
test(core): import archive limit constants instead of redeclaring them
yiliang114 Aug 22, 2026
8f6c247
fix(core): detect export-ignore-hidden submodules via the commit tree
yiliang114 Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/users/extension/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,9 @@ Only scoped packages (`@scope/package-name`) are supported to avoid ambiguity wi

#### From Git Repository

Public Git repository installs and update checks require Git 2.37 or newer. Qwen Code uses the `http.curloptResolve` setting introduced in Git 2.37 to pin public network connections to validated DNS results. If your distribution ships an older Git version, upgrade Git or install a local/archive release instead.
Git 2.37 or newer is required for credentialed, non-GitHub, nested marketplace, submodule, and Git LFS sources because Qwen Code uses `http.curloptResolve` to pin Git connections to validated DNS results. On older Git versions, Qwen Code supports only anonymous public `https://github.com/{owner}/{repo}[.git]` root repositories by resolving the requested ref to a commit and downloading GitHub's source archive with the same public-network and archive-safety checks.
Comment thread
yiliang114 marked this conversation as resolved.

Because the older-Git fallback installs from a source archive rather than a clone, it cannot install repositories that rely on symlinks, submodules, or Git LFS, and it caps downloads at 100 MiB compressed and archives at 100,000 entries / 1 GiB expanded. Release-based installs are still preferred when a repository publishes releases.

```bash
qwen extensions install https://github.com/github/github-mcp-server
Expand Down
228 changes: 226 additions & 2 deletions packages/core/src/extension/archive-safety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,85 @@
* SPDX-License-Identifier: Apache-2.0
Comment thread
yiliang114 marked this conversation as resolved.
*/

import { randomBytes } from 'node:crypto';
import { promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import * as tar from 'tar';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { assertTarArchiveHasNoLinks } from './archive-safety.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
MAX_ARCHIVE_ENTRIES,
MAX_ARCHIVE_EXPANDED_BYTES,
assertTarArchiveHasNoLinks,
} from './archive-safety.js';

// Passthrough wrapper around `fs.createReadStream` that tests can hook to
// observe how much of the archive the scan actually reads.
const streamProbe = vi.hoisted(() => ({
onReadStream: undefined as
| ((
filePath: unknown,
options: unknown,
original: (
filePath: unknown,
options: unknown,
) => NodeJS.ReadableStream,
) => NodeJS.ReadableStream)
| undefined,
}));

vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
createReadStream: (filePath: unknown, options: unknown) => {
const original = (
actual.createReadStream as (
filePath: unknown,
options: unknown,
) => NodeJS.ReadableStream
).bind(actual);
if (streamProbe.onReadStream) {
return streamProbe.onReadStream(filePath, options, original);
}
return original(filePath, options);
},
};
});

// Builds a ustar header for a zero-content regular file. `tar.t` parses
// headers via `onReadEntry` without requiring entry content, so these
// crafted headers are enough to exercise the entry-count and expanded-size
// limits without writing gigabytes of data or hundreds of thousands of
// files to disk.
function createTarFileHeader(name: string, size: number): Buffer {
const header = Buffer.alloc(512);
header.write(name, 0, 100, 'utf8');
header.write('0000644\0', 100, 8); // mode
header.write('0000000\0', 108, 8); // uid
header.write('0000000\0', 116, 8); // gid
header.write(`${size.toString(8).padStart(11, '0')}\0`, 124, 12);
header.write('14763423360\0', 136, 12); // mtime
header.write(' ', 148, 8); // checksum placeholder (spaces)
header.write('0', 156, 1); // typeflag: regular file
header.write('ustar\0', 257, 6);
header.write('00', 263, 2);
let checksum = 0;
for (const byte of header) {
checksum += byte;
}
header.write(`${checksum.toString(8).padStart(6, '0')}\0 `, 148, 8);
return header;
}

const TAR_TRAILER = Buffer.alloc(1024);

async function writeCraftedTar(
archive: string,
headers: Buffer[],
): Promise<void> {
await fs.writeFile(archive, Buffer.concat([...headers, TAR_TRAILER]));
}

describe('assertTarArchiveHasNoLinks', () => {
let root: string;
Expand Down Expand Up @@ -39,4 +112,155 @@ describe('assertTarArchiveHasNoLinks', () => {
);
},
);

it.runIf(process.platform !== 'win32')(
'stops reading the archive as soon as validation fails',
async () => {
const links = Array.from({ length: 101 }, (_, index) => `link-${index}`);
await Promise.all(
links.map(async (link) => {
await fs.symlink('missing-target', path.join(root, link));
}),
);
// A large trailing entry that a scan-to-end implementation would still
// consume after the link limit trips; an early abort never reaches it.
const tailBytes = 20 * 1024 * 1024;
await fs.writeFile(path.join(root, 'tail.bin'), randomBytes(tailBytes));
const archive = path.join(root, 'abort-links.tar');
await tar.c({ cwd: root, file: archive }, [...links, 'tail.bin']);

let bytesRead = 0;
streamProbe.onReadStream = (filePath, options, original) => {
const stream = original(filePath, options);
stream.on('data', (chunk) => {
bytesRead += chunk.length;
});
return stream;
};

try {
await expect(assertTarArchiveHasNoLinks(archive)).rejects.toThrow(
'more than 100 unsupported link entries',
);
} finally {
streamProbe.onReadStream = undefined;
}

// Without the early abort the scan would read the whole ~20 MB tail.
expect(bytesRead).toBeLessThan(tailBytes / 2);
},
);

it('rejects a pre-aborted signal without opening the archive stream', async () => {
const controller = new AbortController();
const abortReason = new Error('install cancelled');
controller.abort(abortReason);
let createReadStreamCalls = 0;
streamProbe.onReadStream = (filePath, options, original) => {
createReadStreamCalls += 1;
const stream = original(filePath, options);
// If the regression returns, the abandoned stream would emit an
// unhandled ENOENT 'error' event; swallow it so the assertion below
// fails the test cleanly instead of crashing the worker.
stream.on('error', () => {});
return stream;
};

try {
await expect(
assertTarArchiveHasNoLinks(
path.join(root, 'missing.tar'),
controller.signal,
),
).rejects.toBe(abortReason);
} finally {
streamProbe.onReadStream = undefined;
}

expect(createReadStreamCalls).toBe(0);
});

const resourceLimits = { enforceResourceLimits: true };

it('accepts an archive with exactly the entry-count limit', async () => {
const archive = path.join(root, 'exact-entries.tar');
const header = createTarFileHeader('file', 0);
await writeCraftedTar(
archive,
Array.from({ length: MAX_ARCHIVE_ENTRIES }, () => header),
);

await expect(
assertTarArchiveHasNoLinks(archive, undefined, resourceLimits),
).resolves.toBeUndefined();
});

it('rejects an archive just over the entry-count limit', async () => {
const archive = path.join(root, 'too-many-entries.tar');
const header = createTarFileHeader('file', 0);
await writeCraftedTar(
archive,
Array.from({ length: MAX_ARCHIVE_ENTRIES + 1 }, () => header),
);

await expect(
assertTarArchiveHasNoLinks(archive, undefined, resourceLimits),
).rejects.toThrow(
`Tar archive contains more than ${MAX_ARCHIVE_ENTRIES} entries.`,
);
});

it('skips resource limits for trusted archives by default', async () => {
const archive = path.join(root, 'huge-but-trusted.tar');
await fs.writeFile(
archive,
Buffer.concat([
createTarFileHeader('big.bin', MAX_ARCHIVE_EXPANDED_BYTES + 1),
TAR_TRAILER,
]),
);

await expect(assertTarArchiveHasNoLinks(archive)).resolves.toBeUndefined();
});

// The parser skips `size` content bytes after each header, so every entry
// except the last must carry its (padded) content; the final entry declares
// a huge size without backing bytes, which `tar.t` tolerates as a trailing
// truncation. The first entry's real content makes the two-entry sum an
// actual accumulation check.
async function writeByteLimitTar(
archive: string,
secondEntrySize: number,
): Promise<void> {
const firstContent = Buffer.alloc(512);
await fs.writeFile(
archive,
Buffer.concat([
createTarFileHeader('first.bin', firstContent.length),
firstContent,
createTarFileHeader('second.bin', secondEntrySize),
TAR_TRAILER,
]),
);
}

it('accepts an archive whose declared sizes sum exactly to the byte limit', async () => {
const archive = path.join(root, 'exact-bytes.tar');
await writeByteLimitTar(archive, MAX_ARCHIVE_EXPANDED_BYTES - 512);

await expect(
assertTarArchiveHasNoLinks(archive, undefined, resourceLimits),
).resolves.toBeUndefined();
});

it('rejects an archive whose declared sizes sum just over the byte limit', async () => {
const archive = path.join(root, 'too-many-bytes.tar');
await writeByteLimitTar(archive, MAX_ARCHIVE_EXPANDED_BYTES - 512 + 1);

await expect(
assertTarArchiveHasNoLinks(archive, undefined, resourceLimits),
).rejects.toThrow(
`Tar archive expands beyond ${MAX_ARCHIVE_EXPANDED_BYTES} bytes.`,
);
});
});
82 changes: 62 additions & 20 deletions packages/core/src/extension/archive-safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ import { stripAnsiAndControl } from '../utils/textUtils.js';
const MAX_REPORTED_ENTRY_PATH_LENGTH = 200;
const MAX_REPORTED_LINK_ENTRIES = 10;
const MAX_LINK_ENTRIES = 100;
export const MAX_ARCHIVE_ENTRIES = 100_000;
export const MAX_ARCHIVE_EXPANDED_BYTES = 1024 * 1024 * 1024;

export interface TarArchiveSafetyOptions {
/**
* Enforce the entry-count and expanded-size ceilings. Kept off by default
* so local, npm, and release archives keep their pre-existing behavior;
* enable it only for untrusted network archives such as the older-Git
* public GitHub archive fallback.
*/
enforceResourceLimits?: boolean;
}

function formatEntryPath(entryPath: string): string {
const sanitized = stripAnsiAndControl(entryPath);
Expand All @@ -22,43 +34,73 @@ function formatEntryPath(entryPath: string): string {
export async function assertTarArchiveHasNoLinks(
file: string,
signal?: AbortSignal,
options: TarArchiveSafetyOptions = {},
): Promise<void> {
const enforceResourceLimits = options.enforceResourceLimits === true;
const unsupportedLinkPaths: string[] = [];
let unsupportedLinkCount = 0;
let linkLimitError: Error | undefined;
let entryCount = 0;
let expandedBytes = 0;
let validationError: Error | undefined;
// Stop reading as soon as validation fails instead of walking the rest of
// a potentially hostile archive.
const failValidation = (error: Error) => {
if (validationError) return;
validationError = error;
stream.destroy();
};
const onReadEntry = (entry: tar.ReadEntry) => {
if (validationError) return;
if (enforceResourceLimits) {
entryCount += 1;
expandedBytes += entry.size;
if (entryCount > MAX_ARCHIVE_ENTRIES) {
failValidation(
new Error(
`Tar archive contains more than ${MAX_ARCHIVE_ENTRIES} entries.`,
),
);
return;
}
if (expandedBytes > MAX_ARCHIVE_EXPANDED_BYTES) {
failValidation(
new Error(
`Tar archive expands beyond ${MAX_ARCHIVE_EXPANDED_BYTES} bytes.`,
),
);
return;
}
}
if (entry.type === 'SymbolicLink' || entry.type === 'Link') {
unsupportedLinkCount += 1;
const unsupportedLinkPath =
formatEntryPath(entry.path) || '<sanitized empty path>';
if (unsupportedLinkPaths.length < MAX_REPORTED_LINK_ENTRIES) {
unsupportedLinkPaths.push(unsupportedLinkPath);
}
if (
unsupportedLinkCount > MAX_LINK_ENTRIES &&
linkLimitError === undefined
) {
linkLimitError = new Error(
`Tar archive contains more than ${MAX_LINK_ENTRIES} unsupported link entries: ${unsupportedLinkPaths.join(', ')}`,
if (unsupportedLinkCount > MAX_LINK_ENTRIES) {
failValidation(
new Error(
`Tar archive contains more than ${MAX_LINK_ENTRIES} unsupported link entries: ${unsupportedLinkPaths.join(', ')}`,
),
);
}
}
};
signal?.throwIfAborted();
if (signal) {
try {
await pipeline(fs.createReadStream(file), tar.t({ onReadEntry }), {
signal,
});
} catch (error) {
signal.throwIfAborted();
throw error;
}
signal.throwIfAborted();
} else {
await tar.t({ file, onReadEntry });
// Open the stream only after the abort check: entering with a pre-aborted
// signal must not leave a live ReadStream behind (an unhandled ENOENT
// 'error' event for a missing file, or a leaked fd otherwise).
const stream = fs.createReadStream(file);
try {
await pipeline(stream, tar.t({ onReadEntry }), { signal });
} catch (error) {
signal?.throwIfAborted();
if (validationError) throw validationError;
throw error;
}
if (linkLimitError) throw linkLimitError;
signal?.throwIfAborted();
if (validationError) throw validationError;
if (unsupportedLinkCount > 0) {
const entryLabel =
unsupportedLinkCount === 1
Expand Down
Loading
Loading