From 2585edbc9ab3ce723eb41676facbe8f2d425774f Mon Sep 17 00:00:00 2001 From: Alexandre Pereira Date: Wed, 23 Sep 2026 17:57:14 +0100 Subject: [PATCH] fix(install): send a default User-Agent with app-source downloads (#2842) api.github.com answers 403 to requests without a User-Agent, so GitHub artifact URLs failed since the transport stopped sending one. Downloads now send 'agent-device' unless the caller sets their own. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/install-source-download.test.ts | 37 +++++++++++++++++++ .../src/install-source-download.ts | 8 +++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/provision-kit/src/install-source-download.test.ts b/packages/provision-kit/src/install-source-download.test.ts index d71ae77bef..513dc9c6b1 100644 --- a/packages/provision-kit/src/install-source-download.test.ts +++ b/packages/provision-kit/src/install-source-download.test.ts @@ -51,6 +51,43 @@ test('download redirects revalidate destinations and strip sensitive cross-origi } }); +test('download sends a default user-agent that a caller header replaces', async () => { + const tempRoot = await mkdtempForTest('agent-device-download-user-agent-'); + const lookup = vi + .spyOn(dns, 'lookup') + .mockImplementation( + async () => + [{ address: '93.184.216.34', family: 4 }] as unknown as Awaited< + ReturnType + >, + ); + const requestMock = vi + .spyOn(networkTransport, 'requestApprovedUrl') + .mockImplementation(async () => response(200, Buffer.from('apk'))); + try { + await downloadInstallSource({ + tempDir: tempRoot, + url: 'https://example.com/default.apk', + signal: new AbortController().signal, + }); + await downloadInstallSource({ + tempDir: tempRoot, + url: 'https://example.com/custom.apk', + headers: { 'User-Agent': 'caller-agent' }, + signal: new AbortController().signal, + }); + const defaultHeaders = requestMock.mock.calls[0]![0].headers; + const customHeaders = requestMock.mock.calls[1]![0].headers; + assert.equal(defaultHeaders['user-agent'], 'agent-device'); + assert.equal(customHeaders['User-Agent'], 'caller-agent'); + assert.equal(customHeaders['user-agent'], undefined); + } finally { + requestMock.mockRestore(); + lookup.mockRestore(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } +}); + test('download errors do not disclose URL credentials or query values', async () => { const tempRoot = await mkdtempForTest('agent-device-download-redaction-'); const lookup = vi diff --git a/packages/provision-kit/src/install-source-download.ts b/packages/provision-kit/src/install-source-download.ts index 25277c9ad5..e31e179493 100644 --- a/packages/provision-kit/src/install-source-download.ts +++ b/packages/provision-kit/src/install-source-download.ts @@ -11,6 +11,7 @@ import { approveDownloadSourceUrl } from './install-source-network.ts'; import * as networkTransport from './install-source-network-transport.ts'; const MAX_REDIRECTS = 5; +const DEFAULT_USER_AGENT = 'agent-device'; const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); const FORBIDDEN_HEADERS = new Set([ 'accept-encoding', @@ -33,7 +34,7 @@ export async function downloadInstallSource(params: { signal: AbortSignal; }): Promise { let currentUrl = parseSourceUrl(params.url); - let headers = sanitizeHeaders(params.headers); + let headers = withDefaultUserAgent(sanitizeHeaders(params.headers)); for (let redirectCount = 0; ; redirectCount += 1) { const response = await requestHop(currentUrl, headers, params.signal); try { @@ -159,6 +160,11 @@ function sanitizeHeaders(input: Record | undefined): Record): Record { + if (Object.keys(headers).some((name) => name.toLowerCase() === 'user-agent')) return headers; + return { ...headers, 'user-agent': DEFAULT_USER_AGENT }; +} + function crossOriginHeaders(headers: Record): Record { return Object.fromEntries( Object.entries(headers).filter(([name]) =>