From 94de5ac043f9a51cd10bf4cf9905bd36b4348060 Mon Sep 17 00:00:00 2001 From: amankansal-lt Date: Fri, 2 Oct 2026 23:01:22 +0530 Subject: [PATCH 1/3] fix(cli): refuse a non-URL install-from-source source up front install-from-source treats its positional as a URL. A local path is only rejected later, when the download layer fails to parse it as a source URL, and the error does not say what to run instead. The CLI now refuses a positional that is not an http(s) URL with INVALID_ARGS before any work and points at `install ` for local builds. The deprecated SDK `isTrustedInstallSourceUrl` reports an unparsable source as INVALID_ARGS instead of throwing a TypeError. Co-Authored-By: Claude Opus 5.5 --- packages/provision-kit/src/install-source.ts | 3 +- src/__tests__/cli-install-from-source.test.ts | 30 +++++++++++++++++++ src/__tests__/install-source.test.ts | 8 +++++ src/commands/management/install.ts | 5 ++++ 4 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/cli-install-from-source.test.ts diff --git a/packages/provision-kit/src/install-source.ts b/packages/provision-kit/src/install-source.ts index 1cbe44931c..0a1dbfc267 100644 --- a/packages/provision-kit/src/install-source.ts +++ b/packages/provision-kit/src/install-source.ts @@ -158,7 +158,8 @@ export async function validateDownloadSourceUrl(parsedUrl: URL): Promise { * whether a URL names a GitHub Actions or EAS artifact, which says nothing about who built it. */ export function isTrustedInstallSourceUrl(sourceUrl: string | URL): boolean { - const parsed = sourceUrl instanceof URL ? sourceUrl : new URL(sourceUrl); + const parsed = sourceUrl instanceof URL ? sourceUrl : URL.parse(sourceUrl); + if (!parsed) throw new AppError('INVALID_ARGS', 'Invalid source URL'); const hostname = parsed.hostname.toLowerCase(); if (!hostname) return false; const pathname = parsed.pathname; diff --git a/src/__tests__/cli-install-from-source.test.ts b/src/__tests__/cli-install-from-source.test.ts new file mode 100644 index 0000000000..f99e8b3705 --- /dev/null +++ b/src/__tests__/cli-install-from-source.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import type { AgentDeviceClient } from '../agent-device-client.ts'; +import { tryRunClientBackedCommand } from '../cli/commands/router.ts'; + +test('install-from-source refuses a local path with a typed error', async () => { + const client = { + apps: { + installFromSource: async () => { + throw new Error('unexpected call'); + }, + }, + } as unknown as AgentDeviceClient; + + await assert.rejects( + () => + tryRunClientBackedCommand({ + command: 'install-from-source', + positionals: ['/abs/path/app.zip'], + flags: { json: false, help: false, version: false }, + client, + }), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message === 'install-from-source must be an http(s) URL: /abs/path/app.zip' && + String(error.details?.hint).includes('install '), + ); +}); diff --git a/src/__tests__/install-source.test.ts b/src/__tests__/install-source.test.ts index 082ed328d1..467ff74683 100644 --- a/src/__tests__/install-source.test.ts +++ b/src/__tests__/install-source.test.ts @@ -24,6 +24,7 @@ import { withAppleToolProvider, } from '@agent-device/platform-apple/tool-provider'; import { prepareIosInstallArtifact } from '@agent-device/platform-apple/install-artifact'; +import { AppError } from '@agent-device/kernel/errors'; import { ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE } from './install-source.coverage.ts'; import { mkdtempForTest } from './test-utils/tmp-dir.ts'; import * as networkTransport from '@agent-device/provision-kit/install-source-network-transport'; @@ -104,6 +105,13 @@ test('isTrustedInstallSourceUrl recognizes supported artifact services', () => { false, ); assert.equal(isTrustedInstallSourceUrl('https://expo.dev/pricing'), false); + assert.throws( + () => isTrustedInstallSourceUrl('/abs/path/app.zip'), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message === 'Invalid source URL', + ); }); test.sequential('materializeInstallablePath extracts zip archives without ditto', async () => { diff --git a/src/commands/management/install.ts b/src/commands/management/install.ts index 7155d10e09..015858f4fd 100644 --- a/src/commands/management/install.ts +++ b/src/commands/management/install.ts @@ -204,6 +204,11 @@ function resolveInstallSource(positionals: string[], flags: CliFlags) { } if (githubArtifactSource) return githubArtifactSource; if (configuredSource) return configuredSource; + if (!/^https?:\/\//i.test(url!)) { + throw new AppError('INVALID_ARGS', `install-from-source must be an http(s) URL: ${url}`, { + hint: 'Install a local build with install .', + }); + } return { kind: 'url' as const, url: url!, From ea045da2a67cad3dfc88f33a25a112109eadcbc4 Mon Sep 17 00:00:00 2001 From: amankansal-lt Date: Sat, 3 Oct 2026 18:22:17 +0530 Subject: [PATCH 2/3] fix(cli): parse the install-from-source URL instead of matching its prefix The prefix check let `http://`, `https://` and `http://exa mple.com` through, so they still failed later in the download layer without the `install ` hint. Parse the source with URL.parse and require an http or https protocol, so every non-URL source is refused up front. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/cli-install-from-source.test.ts | 47 +++++++++++++++---- src/commands/management/install.ts | 7 ++- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/__tests__/cli-install-from-source.test.ts b/src/__tests__/cli-install-from-source.test.ts index f99e8b3705..bc103e7649 100644 --- a/src/__tests__/cli-install-from-source.test.ts +++ b/src/__tests__/cli-install-from-source.test.ts @@ -4,23 +4,27 @@ import { AppError } from '@agent-device/kernel/errors'; import type { AgentDeviceClient } from '../agent-device-client.ts'; import { tryRunClientBackedCommand } from '../cli/commands/router.ts'; -test('install-from-source refuses a local path with a typed error', async () => { +const REACHED_CLIENT = new Error('reached the client'); + +async function runInstallFromSource(source: string) { const client = { apps: { installFromSource: async () => { - throw new Error('unexpected call'); + throw REACHED_CLIENT; }, }, } as unknown as AgentDeviceClient; + return await tryRunClientBackedCommand({ + command: 'install-from-source', + positionals: [source], + flags: { json: false, help: false, version: false }, + client, + }); +} +test('install-from-source refuses a local path with a typed error', async () => { await assert.rejects( - () => - tryRunClientBackedCommand({ - command: 'install-from-source', - positionals: ['/abs/path/app.zip'], - flags: { json: false, help: false, version: false }, - client, - }), + () => runInstallFromSource('/abs/path/app.zip'), (error) => error instanceof AppError && error.code === 'INVALID_ARGS' && @@ -28,3 +32,28 @@ test('install-from-source refuses a local path with a typed error', async () => String(error.details?.hint).includes('install '), ); }); + +for (const source of [ + 'http://', + 'https://', + 'http://exa mple.com/app.zip', + 'ftp://example.com/app.zip', + 'example.com/app.zip', +]) { + test(`install-from-source refuses ${JSON.stringify(source)} before any work`, async () => { + await assert.rejects( + () => runInstallFromSource(source), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + String(error.details?.hint).includes('install '), + ); + }); +} + +test('install-from-source passes an http(s) URL to the client', async () => { + await assert.rejects( + () => runInstallFromSource('HTTPS://example.com/app.zip'), + (error) => error === REACHED_CLIENT, + ); +}); diff --git a/src/commands/management/install.ts b/src/commands/management/install.ts index 015858f4fd..6258fa58e7 100644 --- a/src/commands/management/install.ts +++ b/src/commands/management/install.ts @@ -204,7 +204,7 @@ function resolveInstallSource(positionals: string[], flags: CliFlags) { } if (githubArtifactSource) return githubArtifactSource; if (configuredSource) return configuredSource; - if (!/^https?:\/\//i.test(url!)) { + if (!isHttpUrl(url!)) { throw new AppError('INVALID_ARGS', `install-from-source must be an http(s) URL: ${url}`, { hint: 'Install a local build with install .', }); @@ -216,6 +216,11 @@ function resolveInstallSource(positionals: string[], flags: CliFlags) { }; } +function isHttpUrl(value: string): boolean { + const protocol = URL.parse(value)?.protocol; + return protocol === 'http:' || protocol === 'https:'; +} + function parseInstallSourceHeaders( headerFlags: CliFlags['header'], ): Record | undefined { From f27001da111eac72d60ec09f3e3b04e6b6996bd3 Mon Sep 17 00:00:00 2001 From: amankansal-lt Date: Sat, 3 Oct 2026 18:22:56 +0530 Subject: [PATCH 3/3] refactor(provision-kit): share the invalid source URL refusal The download layer and isTrustedInstallSourceUrl each built the same INVALID_ARGS "Invalid source URL" error. Build it in one place so the two refusals cannot drift, and test that both reject an unparsable source with the same code and message. Co-Authored-By: Claude Opus 5.5 --- .../src/install-source-download.test.ts | 15 +++++++++++++++ .../provision-kit/src/install-source-download.ts | 6 +++++- packages/provision-kit/src/install-source.ts | 4 ++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/provision-kit/src/install-source-download.test.ts b/packages/provision-kit/src/install-source-download.test.ts index dbd6e4c742..119a18684a 100644 --- a/packages/provision-kit/src/install-source-download.test.ts +++ b/packages/provision-kit/src/install-source-download.test.ts @@ -5,6 +5,7 @@ import { Readable } from 'node:stream'; import { test, vi } from 'vitest'; import { mkdtempForTest } from './tmp-dir.fixtures.ts'; import { downloadInstallSource } from './install-source-download.ts'; +import { isTrustedInstallSourceUrl } from './install-source.ts'; import * as networkTransport from './install-source-network-transport.ts'; test('download redirects revalidate destinations and strip sensitive cross-origin headers', async () => { @@ -242,3 +243,17 @@ function response( close: async () => {}, }; } + +test('download and the trusted-source check refuse an unparsable source alike', async () => { + const source = '/abs/path/app.zip'; + const expected = { code: 'INVALID_ARGS', message: 'Invalid source URL' }; + await assert.rejects( + downloadInstallSource({ + tempDir: '/unused', + url: source, + signal: new AbortController().signal, + }), + expected, + ); + assert.throws(() => isTrustedInstallSourceUrl(source), expected); +}); diff --git a/packages/provision-kit/src/install-source-download.ts b/packages/provision-kit/src/install-source-download.ts index ac6bc6b362..9fd53df554 100644 --- a/packages/provision-kit/src/install-source-download.ts +++ b/packages/provision-kit/src/install-source-download.ts @@ -208,7 +208,7 @@ function parseSourceUrl(raw: string): URL { try { parsed = new URL(raw); } catch { - throw new AppError('INVALID_ARGS', 'Invalid source URL'); + throw invalidSourceUrlError(); } if (parsed.username || parsed.password) { @@ -216,3 +216,7 @@ function parseSourceUrl(raw: string): URL { } return parsed; } + +export function invalidSourceUrlError(): AppError { + return new AppError('INVALID_ARGS', 'Invalid source URL'); +} diff --git a/packages/provision-kit/src/install-source.ts b/packages/provision-kit/src/install-source.ts index 0a1dbfc267..53e2391727 100644 --- a/packages/provision-kit/src/install-source.ts +++ b/packages/provision-kit/src/install-source.ts @@ -12,7 +12,7 @@ import { noteInstallArtifactArchiveDepth, } from './install-artifact-archive-context.ts'; import { approveDownloadSourceUrl } from './install-source-network.ts'; -import { downloadInstallSource } from './install-source-download.ts'; +import { downloadInstallSource, invalidSourceUrlError } from './install-source-download.ts'; type MaterializeLocalSourceResult = { localPath: string; @@ -159,7 +159,7 @@ export async function validateDownloadSourceUrl(parsedUrl: URL): Promise { */ export function isTrustedInstallSourceUrl(sourceUrl: string | URL): boolean { const parsed = sourceUrl instanceof URL ? sourceUrl : URL.parse(sourceUrl); - if (!parsed) throw new AppError('INVALID_ARGS', 'Invalid source URL'); + if (!parsed) throw invalidSourceUrlError(); const hostname = parsed.hostname.toLowerCase(); if (!hostname) return false; const pathname = parsed.pathname;