From e02d908fde3ec80c468d8c59f6cdf589e70e5a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Apr 2026 11:58:57 +0200 Subject: [PATCH 1/4] fix: optimize remote install artifact uploads --- .../references/bootstrap-install.md | 13 + .../agent-device/references/remote-tenancy.md | 16 + src/__tests__/upload-client.test.ts | 192 ++++++++- src/daemon-client.ts | 8 + .../__tests__/install-source.test.ts | 265 ++++++++++++- src/upload-client.ts | 371 ++++++++++++++---- website/docs/docs/client-api.md | 5 +- website/docs/docs/commands.md | 6 + website/docs/docs/quick-start.md | 3 + 9 files changed, 796 insertions(+), 83 deletions(-) diff --git a/skills/agent-device/references/bootstrap-install.md b/skills/agent-device/references/bootstrap-install.md index 2e876fced..eebd7a05c 100644 --- a/skills/agent-device/references/bootstrap-install.md +++ b/skills/agent-device/references/bootstrap-install.md @@ -17,6 +17,7 @@ Use this exact order when you are not sure about the installed app identifier. O ## Install path - `install` or `reinstall` +- `install-from-source` when the artifact already exists at a URL the daemon can reach ## Most common mistake to avoid @@ -60,14 +61,26 @@ agent-device install com.example.app ./build/app.apk --platform android --serial agent-device install com.example.app ./build/MyApp.app --platform ios --device "iPhone 17 Pro" ``` +```bash +agent-device install-from-source https://example.com/builds/app.aab --platform android +agent-device install-from-source https://api.github.com/repos/acme/app/actions/artifacts/123/zip --platform ios --header "authorization: Bearer TOKEN" +``` + ## Install guidance - Use `install ` when the app may already be installed and you do not need a fresh-state reset. - Use `reinstall ` when you explicitly need uninstall plus install as one deterministic step. +- Use `install-from-source ` when an existing artifact URL is already reachable by the daemon. +- Local `.apk`, `.aab`, `.app`, and `.ipa` paths go through `install` or `reinstall`; existing reachable URLs go through `install-from-source`. +- Do not download, re-zip, publish temporary GitHub releases, or move CI artifacts elsewhere just to make an install command work. - Keep install and open as separate phases. Do not turn them into one default command flow. - Supported binary formats: - Android: `.apk` and `.aab` - iOS: `.app` and `.ipa` +- Android URL sources can be direct `.apk` or `.aab` files. +- Trusted artifact service URLs, currently GitHub Actions and EAS, may point at archive-backed downloads that contain one installable artifact. This includes GitHub Actions artifact ZIPs whose URL path does not end in `.zip` and ZIPs containing one nested `.apk`, `.aab`, `.ipa`, or iOS `.app` tar archive. +- If a trusted artifact archive contains multiple installables, stop and ask for the intended artifact instead of guessing. +- `.aab` still requires `bundletool` in `PATH`, or `AGENT_DEVICE_BUNDLETOOL_JAR=` with `java` in `PATH`, when the daemon installs the materialized artifact. - For iOS `.ipa` files, `` is used as the bundle id or bundle name hint when the archive contains multiple app bundles. - After install or reinstall, later use `open ` with the exact discovered or known package/bundle identifier, not the artifact path. diff --git a/skills/agent-device/references/remote-tenancy.md b/skills/agent-device/references/remote-tenancy.md index cc15b8ee6..ce8458d3d 100644 --- a/skills/agent-device/references/remote-tenancy.md +++ b/skills/agent-device/references/remote-tenancy.md @@ -27,6 +27,7 @@ agent-device connect \ --remote-config ./remote-config.json agent-device install com.example.app ./app.apk +agent-device install-from-source https://example.com/builds/app.apk --platform android agent-device open com.example.app --relaunch agent-device snapshot -i agent-device fill @e3 "test@example.com" @@ -35,6 +36,21 @@ agent-device disconnect `connect` resolves the remote profile, verifies daemon reachability through the normal client path, allocates or refreshes the tenant lease, prepares local Metro when the profile has Metro fields, starts the local Metro companion when the bridge needs it, and writes local non-secret connection state for later commands. `disconnect` closes the session when possible, stops the Metro companion owned by that connection, releases the lease, and removes local connection state. +After `connect`, normal `agent-device` commands use the active remote connection. Do not repeat `--remote-config` on every command. + +Remote install examples: + +```bash +agent-device install com.example.app ./app.apk +agent-device install-from-source https://example.com/builds/app.aab --platform android +agent-device install-from-source https://example.com/builds/MyApp.ipa --platform ios +agent-device install-from-source https://api.github.com/repos/acme/app/actions/artifacts/123/zip --platform ios --header "authorization: Bearer TOKEN" +``` + +- Use `install` or `reinstall` for local paths; remote daemons upload local artifacts automatically. +- Use `install-from-source` for artifact URLs the remote daemon can reach. +- Do not download CI artifacts locally, repackage them, or publish temporary release assets just to install them. + Use `agent-device connection status --session adc-android` to inspect the active connection without reading JSON state manually. Status output must not include auth tokens. ## Remote config shape diff --git a/src/__tests__/upload-client.test.ts b/src/__tests__/upload-client.test.ts index 74a8d9a28..60e8a655e 100644 --- a/src/__tests__/upload-client.test.ts +++ b/src/__tests__/upload-client.test.ts @@ -7,6 +7,7 @@ import os from 'node:os'; import path from 'node:path'; import { once } from 'node:events'; import { uploadArtifact } from '../upload-client.ts'; +import { runCmdSync } from '../utils/exec.ts'; const TEST_TOKEN = 'agent-device-upload-test-token'; const tempDirs: string[] = []; @@ -29,17 +30,19 @@ test('uploadArtifact returns preflight uploadId without uploading bytes on cache assert.equal(req.headers.authorization, `Bearer ${TEST_TOKEN}`); assert.equal(req.headers['x-agent-device-token'], TEST_TOKEN); const body = JSON.parse((await readRequestBody(req)).toString('utf8')) as { - hash: string; - hashAlgorithm: string; + sha256: string; fileName: string; sizeBytes: number; artifactType: string; + platform: string; + contentType: string; }; - assert.equal(body.hash, expectedHash); - assert.equal(body.hashAlgorithm, 'sha256'); + assert.equal(body.sha256, expectedHash); assert.equal(body.fileName, 'app.apk'); assert.equal(body.sizeBytes, Buffer.byteLength(content)); assert.equal(body.artifactType, 'file'); + assert.equal(body.platform, 'android'); + assert.equal(body.contentType, 'application/octet-stream'); sendJson(res, { ok: true, cacheHit: true, uploadId: 'upload-cached' }); return; } @@ -76,9 +79,9 @@ test('uploadArtifact uploads with hash headers after preflight cache miss', asyn requests.push(`${req.method} ${req.url}`); if (req.method === 'POST' && req.url === '/upload/preflight') { const body = JSON.parse((await readRequestBody(req)).toString('utf8')) as { - hash: string; + sha256: string; }; - assert.equal(body.hash, expectedHash); + assert.equal(body.sha256, expectedHash); sendJson(res, { ok: true, cacheHit: false }); return; } @@ -87,6 +90,7 @@ test('uploadArtifact uploads with hash headers after preflight cache miss', asyn assert.equal(req.headers['x-artifact-filename'], 'app.apk'); assert.equal(req.headers['x-artifact-hash'], expectedHash); assert.equal(req.headers['x-artifact-hash-algorithm'], 'sha256'); + assert.equal(req.headers['content-type'], 'application/octet-stream'); assert.equal((await readRequestBody(req)).toString('utf8'), content); sendJson(res, { ok: true, uploadId: 'upload-miss' }); return; @@ -184,27 +188,126 @@ test('uploadArtifact falls back to upload when preflight fails', async () => { } }); -test('uploadArtifact skips preflight and hash headers for app bundle directories', async () => { +test('uploadArtifact uses direct upload ticket and finalize flow', async () => { + const content = 'direct-upload-apk'; + const artifactPath = createTempFile('app.apk', content); + const expectedHash = sha256(content); + const requests: string[] = []; + let directUploadBody = ''; + + const server = await startServer(async (req, res) => { + requests.push(`${req.method} ${req.url}`); + if (req.method === 'POST' && req.url === '/upload/preflight') { + const body = JSON.parse((await readRequestBody(req)).toString('utf8')) as { + sha256: string; + fileName: string; + artifactType: string; + platform: string; + contentType: string; + }; + assert.equal(body.sha256, expectedHash); + assert.equal(body.fileName, 'app.apk'); + assert.equal(body.artifactType, 'file'); + assert.equal(body.platform, 'android'); + assert.equal(body.contentType, 'application/octet-stream'); + sendJson(res, { + ok: true, + cacheHit: false, + uploadId: 'direct-ticket', + upload: { + url: `${server.baseUrl}/signed-upload`, + headers: { + 'x-signed-ticket': 'ticket-header', + }, + }, + }); + return; + } + if (req.method === 'PUT' && req.url === '/signed-upload') { + assert.equal(req.headers.authorization, undefined); + assert.equal(req.headers['x-agent-device-token'], undefined); + assert.equal(req.headers['x-signed-ticket'], 'ticket-header'); + directUploadBody = (await readRequestBody(req)).toString('utf8'); + res.statusCode = 200; + res.end('ok'); + return; + } + if (req.method === 'POST' && req.url === '/upload/finalize') { + assert.equal(req.headers.authorization, `Bearer ${TEST_TOKEN}`); + const body = JSON.parse((await readRequestBody(req)).toString('utf8')) as { + uploadId: string; + }; + assert.equal(body.uploadId, 'direct-ticket'); + sendJson(res, { ok: true, uploadId: 'upload-finalized' }); + return; + } + res.statusCode = 404; + res.end('not found'); + }); + + try { + const uploadId = await uploadArtifact({ + localPath: artifactPath, + baseUrl: server.baseUrl, + token: TEST_TOKEN, + }); + assert.equal(uploadId, 'upload-finalized'); + assert.equal(directUploadBody, content); + assert.deepEqual(requests, [ + 'POST /upload/preflight', + 'PUT /signed-upload', + 'POST /upload/finalize', + ]); + } finally { + await server.close(); + } +}); + +test('uploadArtifact preflights and legacy-uploads compressed app bundle directories', async () => { const tempRoot = createTempDir(); const appPath = path.join(tempRoot, 'Sample.app'); fs.mkdirSync(appPath, { recursive: true }); fs.writeFileSync(path.join(appPath, 'payload.txt'), 'app-bundle-payload'); const requests: string[] = []; + let preflightSha = ''; + let preflightSize = 0; const server = await startServer(async (req, res) => { requests.push(`${req.method} ${req.url}`); + if (req.method === 'POST' && req.url === '/upload/preflight') { + const body = JSON.parse((await readRequestBody(req)).toString('utf8')) as { + sha256: string; + fileName: string; + sizeBytes: number; + artifactType: string; + platform: string; + contentType: string; + }; + preflightSha = body.sha256; + preflightSize = body.sizeBytes; + assert.equal(body.fileName, 'Sample.app'); + assert.equal(body.artifactType, 'app-bundle'); + assert.equal(body.platform, 'ios'); + assert.equal(body.contentType, 'application/gzip'); + sendJson(res, { ok: true, cacheHit: false }); + return; + } if (req.method === 'POST' && req.url === '/upload') { assert.equal(req.headers['x-artifact-type'], 'app-bundle'); assert.equal(req.headers['x-artifact-filename'], 'Sample.app'); - assert.equal(req.headers['x-artifact-hash'], undefined); - assert.equal(req.headers['x-artifact-hash-algorithm'], undefined); + assert.equal(req.headers['x-artifact-hash'], preflightSha); + assert.equal(req.headers['x-artifact-hash-algorithm'], 'sha256'); + assert.equal(req.headers['content-type'], 'application/gzip'); const body = await readRequestBody(req); + assert.equal(body.length, preflightSize); + assert.equal(sha256(body), preflightSha); assert.ok(body.length > 0); + assert.deepEqual(listTarGzipEntries(body), ['Sample.app/', 'Sample.app/payload.txt']); sendJson(res, { ok: true, uploadId: 'upload-app-bundle' }); return; } - res.statusCode = 500; - res.end('unexpected request'); + res.statusCode = 404; + res.end('not found'); }); try { @@ -214,12 +317,64 @@ test('uploadArtifact skips preflight and hash headers for app bundle directories token: TEST_TOKEN, }); assert.equal(uploadId, 'upload-app-bundle'); - assert.deepEqual(requests, ['POST /upload']); + assert.deepEqual(requests, ['POST /upload/preflight', 'POST /upload']); } finally { await server.close(); } }); +test('uploadArtifact uploads APK, AAB, and IPA files without wrapping them', async () => { + const cases = [ + { filename: 'app.apk', platform: 'android' }, + { filename: 'app.aab', platform: 'android' }, + { filename: 'App.ipa', platform: 'ios' }, + ] as const; + + for (const testCase of cases) { + const content = `${testCase.filename}-payload`; + const artifactPath = createTempFile(testCase.filename, content); + const requests: string[] = []; + + const server = await startServer(async (req, res) => { + requests.push(`${req.method} ${req.url}`); + if (req.method === 'POST' && req.url === '/upload/preflight') { + const body = JSON.parse((await readRequestBody(req)).toString('utf8')) as { + fileName: string; + artifactType: string; + platform: string; + contentType: string; + }; + assert.equal(body.fileName, testCase.filename); + assert.equal(body.artifactType, 'file'); + assert.equal(body.platform, testCase.platform); + assert.equal(body.contentType, 'application/octet-stream'); + sendJson(res, { ok: true, cacheHit: false }); + return; + } + if (req.method === 'POST' && req.url === '/upload') { + assert.equal(req.headers['x-artifact-type'], 'file'); + assert.equal(req.headers['x-artifact-filename'], testCase.filename); + assert.equal((await readRequestBody(req)).toString('utf8'), content); + sendJson(res, { ok: true, uploadId: `upload-${testCase.platform}` }); + return; + } + res.statusCode = 404; + res.end('not found'); + }); + + try { + await uploadArtifact({ + localPath: artifactPath, + baseUrl: server.baseUrl, + token: TEST_TOKEN, + }); + assert.deepEqual(requests, ['POST /upload/preflight', 'POST /upload']); + } finally { + await server.close(); + } + } +}); + function createTempDir(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), `agent-device-upload-client-${randomUUID()}-`)); tempDirs.push(dir); @@ -233,10 +388,21 @@ function createTempFile(filename: string, content: string): string { return filePath; } -function sha256(content: string): string { +function sha256(content: string | Buffer): string { return createHash('sha256').update(content).digest('hex'); } +function listTarGzipEntries(archive: Buffer): string[] { + const dir = createTempDir(); + const archivePath = path.join(dir, 'archive.tar.gz'); + fs.writeFileSync(archivePath, archive); + const result = runCmdSync('tar', ['-tzf', archivePath]); + return result.stdout + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter(Boolean); +} + async function startServer( handler: (req: IncomingMessage, res: ServerResponse) => Promise, ): Promise<{ baseUrl: string; close: () => Promise }> { diff --git a/src/daemon-client.ts b/src/daemon-client.ts index 3b2afbfdf..e021c0a9f 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -272,6 +272,7 @@ async function prepareRemoteRequest( localPath, baseUrl: info.baseUrl!, token: info.token, + platform: resolveUploadPlatform(req.flags?.platform), }); return { positionals, @@ -323,6 +324,7 @@ async function prepareRemoteInstallSource( localPath, baseUrl: info.baseUrl!, token: info.token, + platform: resolveUploadPlatform(req.flags?.platform), }); return { installSource: { @@ -392,6 +394,12 @@ function buildRemoteTempArtifactPath(prefix: string, extension: string): string ); } +function resolveUploadPlatform( + platform: NonNullable['platform'] | undefined, +): 'ios' | 'android' | undefined { + return platform === 'ios' || platform === 'android' ? platform : undefined; +} + function resolveClientSettings(req: Omit): DaemonClientSettings { const stateDir = req.flags?.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR; const remoteBaseUrl = resolveRemoteDaemonBaseUrl( diff --git a/src/platforms/__tests__/install-source.test.ts b/src/platforms/__tests__/install-source.test.ts index 6e310d657..6b2e0cdd8 100644 --- a/src/platforms/__tests__/install-source.test.ts +++ b/src/platforms/__tests__/install-source.test.ts @@ -1,4 +1,4 @@ -import { test, onTestFinished } from 'vitest'; +import { test, onTestFinished, vi } from 'vitest'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import http from 'node:http'; @@ -200,6 +200,227 @@ test('prepareAndroidInstallArtifact resolves package identity for direct APK URL } }); +test('prepareAndroidInstallArtifact accepts direct AAB URL sources', async () => { + const previous = process.env.AGENT_DEVICE_ALLOW_PRIVATE_SOURCE_URLS; + process.env.AGENT_DEVICE_ALLOW_PRIVATE_SOURCE_URLS = '1'; + + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-direct-aab-url-')); + const manifestDir = path.join(tempRoot, 'base', 'manifest'); + const aabPath = path.join(tempRoot, 'fixture.aab'); + await fs.mkdir(manifestDir, { recursive: true }); + await fs.writeFile( + path.join(manifestDir, 'AndroidManifest.xml'), + '', + 'utf8', + ); + await fs.writeFile(path.join(tempRoot, 'BundleConfig.pb'), 'bundle-config', 'utf8'); + execFileSync('zip', ['-qr', aabPath, 'BundleConfig.pb', 'base'], { cwd: tempRoot }); + const aabBytes = await fs.readFile(aabPath); + + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'application/octet-stream' }); + res.end(aabBytes); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + onTestFinished(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await fs.rm(tempRoot, { recursive: true, force: true }); + if (previous === undefined) delete process.env.AGENT_DEVICE_ALLOW_PRIVATE_SOURCE_URLS; + else process.env.AGENT_DEVICE_ALLOW_PRIVATE_SOURCE_URLS = previous; + }); + + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const result = await prepareAndroidInstallArtifact({ + kind: 'url', + url: `http://127.0.0.1:${address.port}/app.aab`, + }); + + try { + assert.equal(result.packageName, 'io.example.directaab'); + } finally { + await result.cleanup(); + } +}); + +test('prepareAndroidInstallArtifact extracts trusted GitHub artifact ZIP containing one APK', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-github-apk-')); + const archivePath = path.join(tempRoot, 'artifact.zip'); + const apkPath = path.join(tempRoot, 'app.apk'); + await fs.writeFile( + path.join(tempRoot, 'AndroidManifest.xml'), + '', + 'utf8', + ); + execFileSync('zip', ['-q', apkPath, 'AndroidManifest.xml'], { cwd: tempRoot }); + execFileSync('zip', ['-q', archivePath, 'app.apk'], { cwd: tempRoot }); + + await withMockedInstallSourceFetch(await fs.readFile(archivePath), async () => { + const result = await prepareAndroidInstallArtifact({ + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/123/zip', + }); + + try { + assert.equal(path.basename(result.installablePath), 'app.apk'); + assert.equal(result.packageName, 'io.example.githubapk'); + } finally { + await result.cleanup(); + } + }); + await fs.rm(tempRoot, { recursive: true, force: true }); +}); + +test('prepareAndroidInstallArtifact extracts trusted GitHub artifact ZIP containing one AAB', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-github-aab-')); + const archivePath = path.join(tempRoot, 'artifact.zip'); + const manifestDir = path.join(tempRoot, 'base', 'manifest'); + const aabPath = path.join(tempRoot, 'app.aab'); + await fs.mkdir(manifestDir, { recursive: true }); + await fs.writeFile( + path.join(manifestDir, 'AndroidManifest.xml'), + '', + 'utf8', + ); + await fs.writeFile(path.join(tempRoot, 'BundleConfig.pb'), 'bundle-config', 'utf8'); + execFileSync('zip', ['-qr', aabPath, 'BundleConfig.pb', 'base'], { cwd: tempRoot }); + execFileSync('zip', ['-q', archivePath, 'app.aab'], { cwd: tempRoot }); + + await withMockedInstallSourceFetch(await fs.readFile(archivePath), async () => { + const result = await prepareAndroidInstallArtifact({ + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/456/zip', + }); + + try { + assert.equal(path.basename(result.installablePath), 'app.aab'); + assert.equal(result.packageName, 'io.example.githubaab'); + } finally { + await result.cleanup(); + } + }); + await fs.rm(tempRoot, { recursive: true, force: true }); +}); + +test('prepareIosInstallArtifact extracts trusted GitHub artifact ZIP containing nested app tar', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-github-app-tar-')); + const payloadDir = path.join(tempRoot, 'payload'); + const appDir = path.join(payloadDir, 'Demo.app'); + const tarPath = path.join(tempRoot, 'Demo.app.tar.gz'); + const archivePath = path.join(tempRoot, 'artifact.zip'); + await fs.mkdir(appDir, { recursive: true }); + await writeIosInfoPlist(appDir, { + bundleId: 'com.example.githubtar', + appName: 'GitHub Tar', + }); + execFileSync('tar', ['-czf', tarPath, '-C', payloadDir, 'Demo.app']); + execFileSync('zip', ['-q', archivePath, 'Demo.app.tar.gz'], { cwd: tempRoot }); + + await withMockedInstallSourceFetch(await fs.readFile(archivePath), async () => { + const result = await prepareIosInstallArtifact({ + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/789/zip', + }); + + try { + assert.equal(path.basename(result.installablePath), 'Demo.app'); + assert.equal(result.bundleId, 'com.example.githubtar'); + assert.equal(result.appName, 'GitHub Tar'); + } finally { + await result.cleanup(); + } + }); + await fs.rm(tempRoot, { recursive: true, force: true }); +}); + +test('prepareIosInstallArtifact extracts trusted GitHub artifact ZIP containing one IPA', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-github-ipa-')); + const payloadAppDir = path.join(tempRoot, 'Payload', 'Demo.app'); + const ipaPath = path.join(tempRoot, 'Demo.ipa'); + const archivePath = path.join(tempRoot, 'artifact.zip'); + await fs.mkdir(payloadAppDir, { recursive: true }); + await writeIosInfoPlist(payloadAppDir, { + bundleId: 'com.example.githubipa', + appName: 'GitHub IPA', + }); + execFileSync('zip', ['-qr', ipaPath, 'Payload'], { cwd: tempRoot }); + execFileSync('zip', ['-q', archivePath, 'Demo.ipa'], { cwd: tempRoot }); + + await withMockedInstallSourceFetch(await fs.readFile(archivePath), async () => { + const result = await prepareIosInstallArtifact({ + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/987/zip', + }); + + try { + assert.equal(path.basename(result.installablePath), 'Demo.app'); + assert.equal(result.bundleId, 'com.example.githubipa'); + assert.equal(result.appName, 'GitHub IPA'); + } finally { + await result.cleanup(); + } + }); + await fs.rm(tempRoot, { recursive: true, force: true }); +}); + +test('prepareAndroidInstallArtifact rejects trusted artifact archives with multiple installables', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-github-multiple-')); + const archivePath = path.join(tempRoot, 'artifact.zip'); + await fs.writeFile(path.join(tempRoot, 'one.apk'), 'one', 'utf8'); + await fs.writeFile(path.join(tempRoot, 'two.apk'), 'two', 'utf8'); + execFileSync('zip', ['-q', archivePath, 'one.apk', 'two.apk'], { cwd: tempRoot }); + + await withMockedInstallSourceFetch(await fs.readFile(archivePath), async () => { + await assert.rejects( + async () => + await prepareAndroidInstallArtifact({ + kind: 'url', + url: 'https://api.github.com/repos/acme/app/actions/artifacts/654/zip', + }), + /multiple Android installable/i, + ); + }); + await fs.rm(tempRoot, { recursive: true, force: true }); +}); + +test('prepareAndroidInstallArtifact rejects untrusted URL archives instead of extracting them', async () => { + const previous = process.env.AGENT_DEVICE_ALLOW_PRIVATE_SOURCE_URLS; + process.env.AGENT_DEVICE_ALLOW_PRIVATE_SOURCE_URLS = '1'; + + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-untrusted-archive-')); + const archivePath = path.join(tempRoot, 'artifact.zip'); + await fs.writeFile(path.join(tempRoot, 'app.apk'), 'apk', 'utf8'); + execFileSync('zip', ['-q', archivePath, 'app.apk'], { cwd: tempRoot }); + const archiveBytes = await fs.readFile(archivePath); + + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'application/zip' }); + res.end(archiveBytes); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + onTestFinished(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await fs.rm(tempRoot, { recursive: true, force: true }); + if (previous === undefined) delete process.env.AGENT_DEVICE_ALLOW_PRIVATE_SOURCE_URLS; + else process.env.AGENT_DEVICE_ALLOW_PRIVATE_SOURCE_URLS = previous; + }); + + const address = server.address(); + assert.ok(address && typeof address === 'object'); + await assert.rejects( + async () => + await prepareAndroidInstallArtifact({ + kind: 'url', + url: `http://127.0.0.1:${address.port}/artifact.zip`, + }), + /archive extraction is not allowed/i, + ); +}); + function findExecutableInPath(command: string): string | undefined { const pathValue = process.env.PATH; if (!pathValue) return undefined; @@ -216,3 +437,45 @@ function findExecutableInPath(command: string): string | undefined { } return undefined; } + +async function withMockedInstallSourceFetch( + bytes: Buffer, + run: () => Promise, +): Promise { + const previous = process.env.AGENT_DEVICE_ALLOW_PRIVATE_SOURCE_URLS; + process.env.AGENT_DEVICE_ALLOW_PRIVATE_SOURCE_URLS = '1'; + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(new Uint8Array(bytes), { + status: 200, + headers: { + 'content-disposition': 'attachment; filename="artifact.zip"', + 'content-type': 'application/zip', + }, + }), + ); + try { + await run(); + } finally { + fetchMock.mockRestore(); + if (previous === undefined) delete process.env.AGENT_DEVICE_ALLOW_PRIVATE_SOURCE_URLS; + else process.env.AGENT_DEVICE_ALLOW_PRIVATE_SOURCE_URLS = previous; + } +} + +async function writeIosInfoPlist( + appDir: string, + params: { bundleId: string; appName: string }, +): Promise { + const plist = ` + + + + CFBundleIdentifier + ${params.bundleId} + CFBundleDisplayName + ${params.appName} + + +`; + await fs.writeFile(path.join(appDir, 'Info.plist'), plist, 'utf8'); +} diff --git a/src/upload-client.ts b/src/upload-client.ts index f42740709..a7c7930e7 100644 --- a/src/upload-client.ts +++ b/src/upload-client.ts @@ -2,18 +2,32 @@ import fs from 'node:fs'; import http from 'node:http'; import https from 'node:https'; import path from 'node:path'; -import { spawn } from 'node:child_process'; -import { createHash } from 'node:crypto'; +import os from 'node:os'; +import { createHash, randomUUID } from 'node:crypto'; import { AppError } from './utils/errors.ts'; +import { runCmd } from './utils/exec.ts'; const UPLOAD_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes const UPLOAD_PREFLIGHT_TIMEOUT_MS = 30 * 1000; const ARTIFACT_HASH_ALGORITHM = 'sha256'; +const DEFAULT_CONTENT_TYPE = 'application/octet-stream'; type UploadArtifactOptions = { localPath: string; baseUrl: string; token: string; + platform?: 'ios' | 'android'; +}; + +type PreparedUploadArtifact = { + payloadPath: string; + fileName: string; + artifactType: 'app-bundle' | 'file'; + platform?: 'ios' | 'android'; + contentType: string; + sha256: string; + sizeBytes: number; + cleanup: () => void; }; type UploadResponse = { @@ -21,48 +35,147 @@ type UploadResponse = { uploadId: string; }; -type UploadPreflightResponse = { +type UploadPreflightCacheHitResponse = { ok: boolean; cacheHit: boolean; uploadId?: string; }; -export async function uploadArtifact(options: UploadArtifactOptions): Promise { - const { localPath, baseUrl, token } = options; +type UploadPreflightDirectResponse = { + ok: boolean; + cacheHit?: boolean; + uploadId?: string; + upload?: { + url?: string; + headers?: Record; + }; +}; - const stat = fs.statSync(localPath); - const isDirectory = stat.isDirectory(); - const filename = path.basename(localPath); - const artifactType = isDirectory ? 'app-bundle' : 'file'; +type UploadPreflightResult = + | { + kind: 'cache-hit'; + uploadId: string; + } + | { + kind: 'direct-upload'; + uploadId: string; + url: string; + headers: Record; + }; + +export async function uploadArtifact(options: UploadArtifactOptions): Promise { + const prepared = await prepareUploadArtifact(options.localPath, options.platform); + const normalizedBase = options.baseUrl.endsWith('/') ? options.baseUrl : `${options.baseUrl}/`; - const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; - const artifactHash = isDirectory ? undefined : await computeFileHash(localPath); - if (artifactHash) { - const cachedUploadId = await requestUploadPreflight({ + try { + const preflight = await requestUploadPreflight({ normalizedBase, - token, - hash: artifactHash, - filename, - sizeBytes: stat.size, - artifactType, + token: options.token, + artifact: prepared, }); - if (cachedUploadId) { - return cachedUploadId; + + if (preflight?.kind === 'cache-hit') { + return preflight.uploadId; + } + if (preflight?.kind === 'direct-upload') { + await uploadDirectArtifact(prepared.payloadPath, preflight); + return await finalizeDirectUpload({ + normalizedBase, + token: options.token, + uploadId: preflight.uploadId, + }); } + + return await uploadLegacyArtifact({ + normalizedBase, + token: options.token, + artifact: prepared, + }); + } finally { + prepared.cleanup(); + } +} + +async function prepareUploadArtifact( + localPath: string, + requestedPlatform: 'ios' | 'android' | undefined, +): Promise { + const stat = fs.statSync(localPath); + const fileName = path.basename(localPath); + const isDirectory = stat.isDirectory(); + const platform = requestedPlatform ?? inferArtifactPlatform(localPath, stat); + const cleanupPaths: string[] = []; + try { + const payloadPath = isDirectory + ? await createGzipTarArchive(localPath, cleanupPaths) + : localPath; + const payloadStat = fs.statSync(payloadPath); + + return { + payloadPath, + fileName, + artifactType: isDirectory ? 'app-bundle' : 'file', + platform, + contentType: isDirectory ? 'application/gzip' : DEFAULT_CONTENT_TYPE, + sha256: await computeFileHash(payloadPath), + sizeBytes: payloadStat.size, + cleanup: () => cleanupUploadPaths(cleanupPaths), + }; + } catch (error) { + cleanupUploadPaths(cleanupPaths); + throw error; } +} + +async function createGzipTarArchive(localPath: string, cleanupPaths: string[]): Promise { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `agent-device-upload-${randomUUID()}-`)); + cleanupPaths.push(tempDir); + const archivePath = path.join(tempDir, `${path.basename(localPath)}.tar.gz`); + await runCmd('tar', [ + 'czf', + archivePath, + '-C', + path.dirname(localPath), + path.basename(localPath), + ]); + return archivePath; +} + +function inferArtifactPlatform( + localPath: string, + stat: { isDirectory(): boolean }, +): 'ios' | 'android' | undefined { + const lowered = localPath.toLowerCase(); + if (stat.isDirectory() && lowered.endsWith('.app')) return 'ios'; + if (lowered.endsWith('.ipa')) return 'ios'; + if (lowered.endsWith('.apk') || lowered.endsWith('.aab')) return 'android'; + return undefined; +} + +function cleanupUploadPaths(cleanupPaths: string[]): void { + for (const cleanupPath of cleanupPaths) { + fs.rmSync(cleanupPath, { recursive: true, force: true }); + } +} + +async function uploadLegacyArtifact(options: { + normalizedBase: string; + token: string; + artifact: PreparedUploadArtifact; +}): Promise { + const { normalizedBase, token, artifact } = options; const uploadUrl = new URL('upload', normalizedBase); const transport = uploadUrl.protocol === 'https:' ? https : http; const headers: Record = { - 'x-artifact-type': artifactType, - 'x-artifact-filename': filename, + 'content-type': artifact.contentType, + 'x-artifact-type': artifact.artifactType, + 'x-artifact-filename': artifact.fileName, + 'x-artifact-hash': artifact.sha256, + 'x-artifact-hash-algorithm': ARTIFACT_HASH_ALGORITHM, 'transfer-encoding': 'chunked', }; - if (artifactHash) { - headers['x-artifact-hash'] = artifactHash; - headers['x-artifact-hash-algorithm'] = ARTIFACT_HASH_ALGORITHM; - } if (token) { headers.authorization = `Bearer ${token}`; headers['x-agent-device-token'] = token; @@ -122,45 +235,20 @@ export async function uploadArtifact(options: UploadArtifactOptions): Promise { - req.destroy(); - reject( - new AppError('COMMAND_FAILED', 'Failed to create tar archive for app bundle', {}, err), - ); - }); - tar.on('close', (code) => { - if (code !== 0) { - req.destroy(); - reject(new AppError('COMMAND_FAILED', `tar failed with exit code ${code}`)); - } - // tar stdout end will trigger req.end() via pipe - }); - } else { - const fileStream = fs.createReadStream(localPath); - fileStream.pipe(req); - fileStream.on('error', (err) => { - req.destroy(); - reject(new AppError('COMMAND_FAILED', 'Failed to read local artifact', {}, err)); - }); - } + const fileStream = fs.createReadStream(artifact.payloadPath); + fileStream.pipe(req); + fileStream.on('error', (err) => { + req.destroy(); + reject(new AppError('COMMAND_FAILED', 'Failed to read local artifact', {}, err)); + }); }); } async function requestUploadPreflight(options: { normalizedBase: string; token: string; - hash: string; - filename: string; - sizeBytes: number; - artifactType: string; -}): Promise { + artifact: PreparedUploadArtifact; +}): Promise { const preflightUrl = new URL('upload/preflight', options.normalizedBase); const headers: Record = { 'content-type': 'application/json', @@ -175,11 +263,12 @@ async function requestUploadPreflight(options: { headers, signal: AbortSignal.timeout(UPLOAD_PREFLIGHT_TIMEOUT_MS), body: JSON.stringify({ - hash: options.hash, - hashAlgorithm: ARTIFACT_HASH_ALGORITHM, - fileName: options.filename, - sizeBytes: options.sizeBytes, - artifactType: options.artifactType, + sha256: options.artifact.sha256, + fileName: options.artifact.fileName, + sizeBytes: options.artifact.sizeBytes, + artifactType: options.artifact.artifactType, + ...(options.artifact.platform ? { platform: options.artifact.platform } : {}), + contentType: options.artifact.contentType, }), }).catch(() => undefined); @@ -188,19 +277,167 @@ async function requestUploadPreflight(options: { } const parsed = (await response.json().catch(() => undefined)) as unknown; - return isUploadPreflightHit(parsed) ? parsed.uploadId : undefined; + if (isUploadPreflightHit(parsed)) { + return { + kind: 'cache-hit', + uploadId: parsed.uploadId, + }; + } + if (isUploadPreflightDirectUpload(parsed)) { + return { + kind: 'direct-upload', + uploadId: parsed.uploadId, + url: parsed.upload.url, + headers: parsed.upload.headers, + }; + } + return undefined; } -function isUploadPreflightHit(value: unknown): value is Required { +function isUploadPreflightHit(value: unknown): value is Required { if (!value || typeof value !== 'object') { return false; } - const preflight = value as UploadPreflightResponse; + const preflight = value as UploadPreflightCacheHitResponse; return ( preflight.ok === true && preflight.cacheHit === true && typeof preflight.uploadId === 'string' ); } +function isUploadPreflightDirectUpload( + value: unknown, +): value is Required & { + upload: { url: string; headers: Record }; +} { + if (!value || typeof value !== 'object') { + return false; + } + const preflight = value as UploadPreflightDirectResponse; + if (preflight.ok !== true || typeof preflight.uploadId !== 'string') { + return false; + } + if (!preflight.upload || typeof preflight.upload.url !== 'string') { + return false; + } + const headers = preflight.upload.headers ?? {}; + if (!isStringRecord(headers)) { + return false; + } + preflight.upload.headers = headers; + return true; +} + +function isStringRecord(value: unknown): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + return Object.values(value).every((entry) => typeof entry === 'string'); +} + +async function uploadDirectArtifact( + payloadPath: string, + ticket: Extract, +): Promise { + const uploadUrl = new URL(ticket.url); + const transport = uploadUrl.protocol === 'https:' ? https : http; + + await new Promise((resolve, reject) => { + const req = transport.request( + { + protocol: uploadUrl.protocol, + host: uploadUrl.hostname, + port: uploadUrl.port, + method: 'PUT', + path: uploadUrl.pathname + uploadUrl.search, + headers: ticket.headers, + }, + (res) => { + res.resume(); + res.on('end', () => { + const statusCode = res.statusCode ?? 500; + if (statusCode < 200 || statusCode >= 300) { + reject( + new AppError('COMMAND_FAILED', 'Direct artifact upload failed', { + statusCode, + statusMessage: res.statusMessage, + }), + ); + return; + } + resolve(); + }); + }, + ); + + const timeout = setTimeout(() => { + req.destroy(); + reject( + new AppError('COMMAND_FAILED', 'Direct artifact upload timed out', { + timeoutMs: UPLOAD_TIMEOUT_MS, + hint: 'The direct upload ticket did not accept the artifact within the timeout.', + }), + ); + }, UPLOAD_TIMEOUT_MS); + + req.on('error', (err) => { + clearTimeout(timeout); + reject( + new AppError( + 'COMMAND_FAILED', + 'Failed to upload artifact with direct upload ticket', + {}, + err, + ), + ); + }); + req.on('close', () => clearTimeout(timeout)); + + const fileStream = fs.createReadStream(payloadPath); + fileStream.pipe(req); + fileStream.on('error', (err) => { + req.destroy(); + reject(new AppError('COMMAND_FAILED', 'Failed to read local artifact', {}, err)); + }); + }); +} + +async function finalizeDirectUpload(options: { + normalizedBase: string; + token: string; + uploadId: string; +}): Promise { + const finalizeUrl = new URL('upload/finalize', options.normalizedBase); + const headers: Record = { + 'content-type': 'application/json', + }; + if (options.token) { + headers.authorization = `Bearer ${options.token}`; + headers['x-agent-device-token'] = options.token; + } + + const response = await fetch(finalizeUrl, { + method: 'POST', + headers, + signal: AbortSignal.timeout(UPLOAD_PREFLIGHT_TIMEOUT_MS), + body: JSON.stringify({ uploadId: options.uploadId }), + }).catch((error) => { + throw new AppError('COMMAND_FAILED', 'Failed to finalize direct artifact upload', {}, error); + }); + + if (!response.ok) { + throw new AppError('COMMAND_FAILED', 'Direct artifact upload finalize failed', { + status: response.status, + statusText: response.statusText, + }); + } + + const parsed = (await response.json().catch(() => undefined)) as UploadResponse | undefined; + if (!parsed?.ok || !parsed.uploadId) { + throw new AppError('COMMAND_FAILED', 'Invalid upload finalize response'); + } + return parsed.uploadId; +} + async function computeFileHash(localPath: string): Promise { const hash = createHash(ARTIFACT_HASH_ALGORITHM); await new Promise((resolve, reject) => { diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index d5669c986..a9f45cbbc 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -179,9 +179,10 @@ If the daemon cannot determine installed app identity, the request fails instead - Private and loopback hosts are blocked by default. - Archive-backed URL installs are only supported for trusted artifact services, currently GitHub Actions and EAS. -- For other hosts, prefer `source: { kind: 'path', path: ... }` so the client downloads/uploads the artifact explicitly. +- For existing reachable artifact URLs, use `source: { kind: 'url', url: ... }`; do not download, repackage, or publish artifacts elsewhere just to reshape the URL. +- For local artifacts, use `source: { kind: 'path', path: ... }` or the CLI `install`/`reinstall` commands. -Direct Android `.apk` and `.aab` URL sources can still resolve package identity from the downloaded install artifact. +Direct Android `.apk` and `.aab` URL sources can still resolve package identity from the downloaded install artifact. Trusted GitHub Actions and EAS archive URLs may contain one installable `.apk`, `.aab`, `.ipa`, or iOS `.app` tar archive. ## Remote Metro helpers diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 6f31426e1..bb5b9189b 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -373,12 +373,18 @@ agent-device reinstall com.example.app ./build/MyApp.app --platform ios ```bash agent-device install-from-source https://example.com/builds/app.apk --platform android +agent-device install-from-source https://example.com/builds/app.aab --platform android agent-device install-from-source https://example.com/builds/MyApp.ipa --platform ios --header "authorization: Bearer TOKEN" +agent-device install-from-source https://api.github.com/repos/acme/app/actions/artifacts/123/zip --platform ios --header "authorization: Bearer TOKEN" ``` - `install-from-source ` installs from a URL source through the normal daemon artifact flow. - Repeat `--header ` for authenticated or signed artifact requests. - Supports the same device coverage as `install`: Android devices/emulators, iOS simulators, and iOS physical devices. +- Use `install` or `reinstall` for local `.apk`, `.aab`, `.app`, and `.ipa` paths; use `install-from-source` when the artifact already exists at a URL reachable by the daemon. +- Do not download, repackage, or publish artifacts elsewhere just to reshape a trusted CI artifact URL. +- Direct Android URL sources may be `.apk` or `.aab`. +- Trusted artifact service URLs, currently GitHub Actions and EAS, may resolve to archives containing one installable `.apk`, `.aab`, `.ipa`, or iOS `.app` tar archive. - `--retain-paths` keeps retained materialized artifact paths after install, and `--retention-ms ` sets their TTL. - URL downloads follow the same `installFromSource()` safety checks and host restrictions as the JS client API. diff --git a/website/docs/docs/quick-start.md b/website/docs/docs/quick-start.md index f086ef3b9..f50aa9b99 100644 --- a/website/docs/docs/quick-start.md +++ b/website/docs/docs/quick-start.md @@ -59,6 +59,7 @@ agent-device get text @e1 # Get text content agent-device screenshot page.png # Save to specific path agent-device install com.example.app ./build/app.apk # Install app binary in-place agent-device install-from-source https://example.com/builds/app.apk --platform android +agent-device install-from-source https://example.com/builds/app.aab --platform android agent-device reinstall com.example.app ./build/app.apk # Fresh-state uninstall + install agent-device close ``` @@ -70,6 +71,8 @@ agent-device close - Optional: `AGENT_DEVICE_ANDROID_BUNDLETOOL_MODE=` overrides bundletool `build-apks --mode` (default: `universal`). - `.ipa` installs extract `Payload/*.app`; if multiple app bundles exist, `` selects the target by bundle id or bundle name. +Use `install` or `reinstall` for local `.apk`, `.aab`, `.app`, and `.ipa` paths. Use `install-from-source` when the artifact already exists at a URL reachable by the daemon, including direct Android `.apk`/`.aab` URLs and trusted GitHub Actions or EAS artifact archives with one installable artifact. Do not create temporary releases, re-zip artifacts, or publish replacement assets just to install a CI artifact. + If `open` fails because no booted simulator/emulator/device is available, run `boot --platform ios|android` and retry. If `open` fails because the app id is wrong or missing, run `apps` and retry with the discovered package or bundle id instead of guessing. From a660b92f804f8038ab446b8404421a6bf6c0bf44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Apr 2026 12:09:33 +0200 Subject: [PATCH 2/4] refactor: clean up upload client flow --- .../agent-device/references/remote-tenancy.md | 2 +- src/daemon-client.ts | 10 +- src/upload-client.ts | 242 ++++++++---------- website/docs/docs/client-api.md | 2 +- website/docs/docs/commands.md | 1 - website/docs/docs/quick-start.md | 4 +- 6 files changed, 109 insertions(+), 152 deletions(-) diff --git a/skills/agent-device/references/remote-tenancy.md b/skills/agent-device/references/remote-tenancy.md index ce8458d3d..768fc6bf8 100644 --- a/skills/agent-device/references/remote-tenancy.md +++ b/skills/agent-device/references/remote-tenancy.md @@ -49,7 +49,7 @@ agent-device install-from-source https://api.github.com/repos/acme/app/actions/a - Use `install` or `reinstall` for local paths; remote daemons upload local artifacts automatically. - Use `install-from-source` for artifact URLs the remote daemon can reach. -- Do not download CI artifacts locally, repackage them, or publish temporary release assets just to install them. +- For local-path versus URL artifact rules, follow [bootstrap-install.md](bootstrap-install.md). Use `agent-device connection status --session adc-android` to inspect the active connection without reading JSON state manually. Status output must not include auth tokens. diff --git a/src/daemon-client.ts b/src/daemon-client.ts index e021c0a9f..1ed0087fb 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -272,7 +272,7 @@ async function prepareRemoteRequest( localPath, baseUrl: info.baseUrl!, token: info.token, - platform: resolveUploadPlatform(req.flags?.platform), + platform: req.flags?.platform, }); return { positionals, @@ -324,7 +324,7 @@ async function prepareRemoteInstallSource( localPath, baseUrl: info.baseUrl!, token: info.token, - platform: resolveUploadPlatform(req.flags?.platform), + platform: req.flags?.platform, }); return { installSource: { @@ -394,12 +394,6 @@ function buildRemoteTempArtifactPath(prefix: string, extension: string): string ); } -function resolveUploadPlatform( - platform: NonNullable['platform'] | undefined, -): 'ios' | 'android' | undefined { - return platform === 'ios' || platform === 'android' ? platform : undefined; -} - function resolveClientSettings(req: Omit): DaemonClientSettings { const stateDir = req.flags?.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR; const remoteBaseUrl = resolveRemoteDaemonBaseUrl( diff --git a/src/upload-client.ts b/src/upload-client.ts index a7c7930e7..736c6a887 100644 --- a/src/upload-client.ts +++ b/src/upload-client.ts @@ -16,7 +16,7 @@ type UploadArtifactOptions = { localPath: string; baseUrl: string; token: string; - platform?: 'ios' | 'android'; + platform?: string; }; type PreparedUploadArtifact = { @@ -35,13 +35,7 @@ type UploadResponse = { uploadId: string; }; -type UploadPreflightCacheHitResponse = { - ok: boolean; - cacheHit: boolean; - uploadId?: string; -}; - -type UploadPreflightDirectResponse = { +type UploadPreflightResponse = { ok: boolean; cacheHit?: boolean; uploadId?: string; @@ -98,12 +92,13 @@ export async function uploadArtifact(options: UploadArtifactOptions): Promise { const stat = fs.statSync(localPath); const fileName = path.basename(localPath); const isDirectory = stat.isDirectory(); - const platform = requestedPlatform ?? inferArtifactPlatform(localPath, stat); + const platform = + normalizeUploadPlatform(requestedPlatform) ?? inferArtifactPlatform(localPath, stat); const cleanupPaths: string[] = []; try { const payloadPath = isDirectory @@ -152,6 +147,10 @@ function inferArtifactPlatform( return undefined; } +function normalizeUploadPlatform(value: string | undefined): 'ios' | 'android' | undefined { + return value === 'ios' || value === 'android' ? value : undefined; +} + function cleanupUploadPaths(cleanupPaths: string[]): void { for (const cleanupPath of cleanupPaths) { fs.rmSync(cleanupPath, { recursive: true, force: true }); @@ -164,9 +163,7 @@ async function uploadLegacyArtifact(options: { artifact: PreparedUploadArtifact; }): Promise { const { normalizedBase, token, artifact } = options; - const uploadUrl = new URL('upload', normalizedBase); - const transport = uploadUrl.protocol === 'https:' ? https : http; const headers: Record = { 'content-type': artifact.contentType, @@ -181,67 +178,27 @@ async function uploadLegacyArtifact(options: { headers['x-agent-device-token'] = token; } - return new Promise((resolve, reject) => { - const req = transport.request( - { - protocol: uploadUrl.protocol, - host: uploadUrl.hostname, - port: uploadUrl.port, - method: 'POST', - path: uploadUrl.pathname + uploadUrl.search, - headers, - }, - (res) => { - let body = ''; - res.setEncoding('utf8'); - res.on('data', (chunk) => { - body += chunk; - }); - res.on('end', () => { - clearTimeout(timeout); - try { - const parsed = JSON.parse(body) as UploadResponse; - if (!parsed.ok || !parsed.uploadId) { - reject(new AppError('COMMAND_FAILED', `Upload failed: ${body}`)); - return; - } - resolve(parsed.uploadId); - } catch { - reject(new AppError('COMMAND_FAILED', `Invalid upload response: ${body}`)); - } - }); - }, - ); - - const timeout = setTimeout(() => { - req.destroy(); - reject( - new AppError('COMMAND_FAILED', 'Artifact upload timed out', { - timeoutMs: UPLOAD_TIMEOUT_MS, - hint: 'The upload to the remote daemon exceeded the 5-minute timeout.', - }), - ); - }, UPLOAD_TIMEOUT_MS); - - req.on('error', (err) => { - clearTimeout(timeout); - reject( - new AppError( - 'COMMAND_FAILED', - 'Failed to upload artifact to remote daemon', - { hint: 'Verify the remote daemon is reachable and supports artifact uploads.' }, - err, - ), - ); - }); - - const fileStream = fs.createReadStream(artifact.payloadPath); - fileStream.pipe(req); - fileStream.on('error', (err) => { - req.destroy(); - reject(new AppError('COMMAND_FAILED', 'Failed to read local artifact', {}, err)); - }); + const response = await streamFileToHttpRequest({ + url: uploadUrl, + method: 'POST', + headers, + payloadPath: artifact.payloadPath, + timeoutMessage: 'Artifact upload timed out', + timeoutHint: 'The upload to the remote daemon exceeded the 5-minute timeout.', + errorMessage: 'Failed to upload artifact to remote daemon', + errorHint: 'Verify the remote daemon is reachable and supports artifact uploads.', }); + + try { + const parsed = JSON.parse(response.body) as UploadResponse; + if (!parsed.ok || !parsed.uploadId) { + throw new AppError('COMMAND_FAILED', `Upload failed: ${response.body}`); + } + return parsed.uploadId; + } catch (error) { + if (error instanceof AppError) throw error; + throw new AppError('COMMAND_FAILED', `Invalid upload response: ${response.body}`); + } } async function requestUploadPreflight(options: { @@ -276,55 +233,38 @@ async function requestUploadPreflight(options: { return undefined; } - const parsed = (await response.json().catch(() => undefined)) as unknown; - if (isUploadPreflightHit(parsed)) { - return { - kind: 'cache-hit', - uploadId: parsed.uploadId, - }; - } - if (isUploadPreflightDirectUpload(parsed)) { - return { - kind: 'direct-upload', - uploadId: parsed.uploadId, - url: parsed.upload.url, - headers: parsed.upload.headers, - }; - } - return undefined; -} - -function isUploadPreflightHit(value: unknown): value is Required { - if (!value || typeof value !== 'object') { - return false; - } - const preflight = value as UploadPreflightCacheHitResponse; - return ( - preflight.ok === true && preflight.cacheHit === true && typeof preflight.uploadId === 'string' - ); + return parseUploadPreflightResult(await response.json().catch(() => undefined)); } -function isUploadPreflightDirectUpload( - value: unknown, -): value is Required & { - upload: { url: string; headers: Record }; -} { +function parseUploadPreflightResult(value: unknown): UploadPreflightResult | undefined { if (!value || typeof value !== 'object') { - return false; + return undefined; } - const preflight = value as UploadPreflightDirectResponse; + const preflight = value as UploadPreflightResponse; if (preflight.ok !== true || typeof preflight.uploadId !== 'string') { - return false; + return undefined; } - if (!preflight.upload || typeof preflight.upload.url !== 'string') { - return false; + if (preflight.cacheHit === true) { + return { + kind: 'cache-hit', + uploadId: preflight.uploadId, + }; + } + + const upload = preflight.upload; + if (!upload || typeof upload.url !== 'string') { + return undefined; } - const headers = preflight.upload.headers ?? {}; + const headers = upload.headers ?? {}; if (!isStringRecord(headers)) { - return false; + return undefined; } - preflight.upload.headers = headers; - return true; + return { + kind: 'direct-upload', + uploadId: preflight.uploadId, + url: upload.url, + headers, + }; } function isStringRecord(value: unknown): value is Record { @@ -338,33 +278,59 @@ async function uploadDirectArtifact( payloadPath: string, ticket: Extract, ): Promise { - const uploadUrl = new URL(ticket.url); - const transport = uploadUrl.protocol === 'https:' ? https : http; + const response = await streamFileToHttpRequest({ + url: new URL(ticket.url), + method: 'PUT', + headers: ticket.headers, + payloadPath, + timeoutMessage: 'Direct artifact upload timed out', + timeoutHint: 'The direct upload ticket did not accept the artifact within the timeout.', + errorMessage: 'Failed to upload artifact with direct upload ticket', + }); - await new Promise((resolve, reject) => { + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new AppError('COMMAND_FAILED', 'Direct artifact upload failed', { + statusCode: response.statusCode, + statusMessage: response.statusMessage, + }); + } +} + +async function streamFileToHttpRequest(options: { + url: URL; + method: 'POST' | 'PUT'; + headers: Record; + payloadPath: string; + timeoutMessage: string; + timeoutHint?: string; + errorMessage: string; + errorHint?: string; +}): Promise<{ statusCode: number; statusMessage?: string; body: string }> { + const transport = options.url.protocol === 'https:' ? https : http; + + return await new Promise((resolve, reject) => { const req = transport.request( { - protocol: uploadUrl.protocol, - host: uploadUrl.hostname, - port: uploadUrl.port, - method: 'PUT', - path: uploadUrl.pathname + uploadUrl.search, - headers: ticket.headers, + protocol: options.url.protocol, + host: options.url.hostname, + port: options.url.port, + method: options.method, + path: options.url.pathname + options.url.search, + headers: options.headers, }, (res) => { - res.resume(); + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + body += chunk; + }); res.on('end', () => { - const statusCode = res.statusCode ?? 500; - if (statusCode < 200 || statusCode >= 300) { - reject( - new AppError('COMMAND_FAILED', 'Direct artifact upload failed', { - statusCode, - statusMessage: res.statusMessage, - }), - ); - return; - } - resolve(); + clearTimeout(timeout); + resolve({ + statusCode: res.statusCode ?? 500, + statusMessage: res.statusMessage, + body, + }); }); }, ); @@ -372,9 +338,9 @@ async function uploadDirectArtifact( const timeout = setTimeout(() => { req.destroy(); reject( - new AppError('COMMAND_FAILED', 'Direct artifact upload timed out', { + new AppError('COMMAND_FAILED', options.timeoutMessage, { timeoutMs: UPLOAD_TIMEOUT_MS, - hint: 'The direct upload ticket did not accept the artifact within the timeout.', + ...(options.timeoutHint ? { hint: options.timeoutHint } : {}), }), ); }, UPLOAD_TIMEOUT_MS); @@ -384,15 +350,15 @@ async function uploadDirectArtifact( reject( new AppError( 'COMMAND_FAILED', - 'Failed to upload artifact with direct upload ticket', - {}, + options.errorMessage, + options.errorHint ? { hint: options.errorHint } : {}, err, ), ); }); req.on('close', () => clearTimeout(timeout)); - const fileStream = fs.createReadStream(payloadPath); + const fileStream = fs.createReadStream(options.payloadPath); fileStream.pipe(req); fileStream.on('error', (err) => { req.destroy(); diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index a9f45cbbc..780f22140 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -179,7 +179,7 @@ If the daemon cannot determine installed app identity, the request fails instead - Private and loopback hosts are blocked by default. - Archive-backed URL installs are only supported for trusted artifact services, currently GitHub Actions and EAS. -- For existing reachable artifact URLs, use `source: { kind: 'url', url: ... }`; do not download, repackage, or publish artifacts elsewhere just to reshape the URL. +- For existing reachable artifact URLs, use `source: { kind: 'url', url: ... }`. - For local artifacts, use `source: { kind: 'path', path: ... }` or the CLI `install`/`reinstall` commands. Direct Android `.apk` and `.aab` URL sources can still resolve package identity from the downloaded install artifact. Trusted GitHub Actions and EAS archive URLs may contain one installable `.apk`, `.aab`, `.ipa`, or iOS `.app` tar archive. diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index bb5b9189b..4cf0541be 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -382,7 +382,6 @@ agent-device install-from-source https://api.github.com/repos/acme/app/actions/a - Repeat `--header ` for authenticated or signed artifact requests. - Supports the same device coverage as `install`: Android devices/emulators, iOS simulators, and iOS physical devices. - Use `install` or `reinstall` for local `.apk`, `.aab`, `.app`, and `.ipa` paths; use `install-from-source` when the artifact already exists at a URL reachable by the daemon. -- Do not download, repackage, or publish artifacts elsewhere just to reshape a trusted CI artifact URL. - Direct Android URL sources may be `.apk` or `.aab`. - Trusted artifact service URLs, currently GitHub Actions and EAS, may resolve to archives containing one installable `.apk`, `.aab`, `.ipa`, or iOS `.app` tar archive. - `--retain-paths` keeps retained materialized artifact paths after install, and `--retention-ms ` sets their TTL. diff --git a/website/docs/docs/quick-start.md b/website/docs/docs/quick-start.md index f50aa9b99..60ca63ad0 100644 --- a/website/docs/docs/quick-start.md +++ b/website/docs/docs/quick-start.md @@ -59,7 +59,6 @@ agent-device get text @e1 # Get text content agent-device screenshot page.png # Save to specific path agent-device install com.example.app ./build/app.apk # Install app binary in-place agent-device install-from-source https://example.com/builds/app.apk --platform android -agent-device install-from-source https://example.com/builds/app.aab --platform android agent-device reinstall com.example.app ./build/app.apk # Fresh-state uninstall + install agent-device close ``` @@ -70,8 +69,7 @@ agent-device close - `.aab` requires `bundletool` in `PATH`, or `AGENT_DEVICE_BUNDLETOOL_JAR=` with `java` in `PATH`. - Optional: `AGENT_DEVICE_ANDROID_BUNDLETOOL_MODE=` overrides bundletool `build-apks --mode` (default: `universal`). - `.ipa` installs extract `Payload/*.app`; if multiple app bundles exist, `` selects the target by bundle id or bundle name. - -Use `install` or `reinstall` for local `.apk`, `.aab`, `.app`, and `.ipa` paths. Use `install-from-source` when the artifact already exists at a URL reachable by the daemon, including direct Android `.apk`/`.aab` URLs and trusted GitHub Actions or EAS artifact archives with one installable artifact. Do not create temporary releases, re-zip artifacts, or publish replacement assets just to install a CI artifact. +- Use `install-from-source` for existing artifact URLs, including direct Android `.apk`/`.aab` URLs and trusted GitHub Actions or EAS archives with one installable artifact. If `open` fails because no booted simulator/emulator/device is available, run `boot --platform ios|android` and retry. If `open` fails because the app id is wrong or missing, run `apps` and retry with the discovered package or bundle id instead of guessing. From fb9848aa5481db7cd7b62417e41d71df16fe49ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Apr 2026 12:21:05 +0200 Subject: [PATCH 3/4] fix: fall back after direct upload failure --- src/__tests__/upload-client.test.ts | 77 +++++++++++++++++++++++++++++ src/upload-client.ts | 20 +++++--- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/__tests__/upload-client.test.ts b/src/__tests__/upload-client.test.ts index 60e8a655e..3836158b4 100644 --- a/src/__tests__/upload-client.test.ts +++ b/src/__tests__/upload-client.test.ts @@ -263,6 +263,83 @@ test('uploadArtifact uses direct upload ticket and finalize flow', async () => { } }); +test.each([ + { + name: 'direct upload failure', + failDirectUpload: true, + expectedRequests: ['POST /upload/preflight', 'PUT /signed-upload', 'POST /upload'], + }, + { + name: 'finalize failure', + failDirectUpload: false, + expectedRequests: [ + 'POST /upload/preflight', + 'PUT /signed-upload', + 'POST /upload/finalize', + 'POST /upload', + ], + }, +])( + 'uploadArtifact falls back to legacy upload after $name', + async ({ failDirectUpload, expectedRequests }) => { + const content = `${failDirectUpload ? 'direct' : 'finalize'}-fallback-apk`; + const artifactPath = createTempFile('app.apk', content); + const requests: string[] = []; + let legacyUploadBody = ''; + + const server = await startServer(async (req, res) => { + requests.push(`${req.method} ${req.url}`); + if (req.method === 'POST' && req.url === '/upload/preflight') { + await readRequestBody(req); + sendJson(res, { + ok: true, + cacheHit: false, + uploadId: 'direct-ticket', + upload: { + url: `${server.baseUrl}/signed-upload`, + headers: { 'x-signed-ticket': 'ticket-header' }, + }, + }); + return; + } + if (req.method === 'PUT' && req.url === '/signed-upload') { + await readRequestBody(req); + res.statusCode = failDirectUpload ? 503 : 200; + res.end(failDirectUpload ? 'storage unavailable' : 'ok'); + return; + } + if (req.method === 'POST' && req.url === '/upload/finalize') { + await readRequestBody(req); + res.statusCode = 503; + res.end('finalize unavailable'); + return; + } + if (req.method === 'POST' && req.url === '/upload') { + assert.equal(req.headers['x-artifact-type'], 'file'); + assert.equal(req.headers['x-artifact-filename'], 'app.apk'); + legacyUploadBody = (await readRequestBody(req)).toString('utf8'); + sendJson(res, { ok: true, uploadId: 'legacy-fallback' }); + return; + } + res.statusCode = 404; + res.end('not found'); + }); + + try { + const uploadId = await uploadArtifact({ + localPath: artifactPath, + baseUrl: server.baseUrl, + token: TEST_TOKEN, + }); + assert.equal(uploadId, 'legacy-fallback'); + assert.equal(legacyUploadBody, content); + assert.deepEqual(requests, expectedRequests); + } finally { + await server.close(); + } + }, +); + test('uploadArtifact preflights and legacy-uploads compressed app bundle directories', async () => { const tempRoot = createTempDir(); const appPath = path.join(tempRoot, 'Sample.app'); diff --git a/src/upload-client.ts b/src/upload-client.ts index 736c6a887..f91cddba2 100644 --- a/src/upload-client.ts +++ b/src/upload-client.ts @@ -72,12 +72,20 @@ export async function uploadArtifact(options: UploadArtifactOptions): Promise Date: Thu, 16 Apr 2026 12:22:15 +0200 Subject: [PATCH 4/4] docs: correct ios install source examples --- skills/agent-device/references/remote-tenancy.md | 1 - website/docs/docs/commands.md | 1 - 2 files changed, 2 deletions(-) diff --git a/skills/agent-device/references/remote-tenancy.md b/skills/agent-device/references/remote-tenancy.md index 768fc6bf8..118a2c65b 100644 --- a/skills/agent-device/references/remote-tenancy.md +++ b/skills/agent-device/references/remote-tenancy.md @@ -43,7 +43,6 @@ Remote install examples: ```bash agent-device install com.example.app ./app.apk agent-device install-from-source https://example.com/builds/app.aab --platform android -agent-device install-from-source https://example.com/builds/MyApp.ipa --platform ios agent-device install-from-source https://api.github.com/repos/acme/app/actions/artifacts/123/zip --platform ios --header "authorization: Bearer TOKEN" ``` diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 4cf0541be..ad286f6d0 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -374,7 +374,6 @@ agent-device reinstall com.example.app ./build/MyApp.app --platform ios ```bash agent-device install-from-source https://example.com/builds/app.apk --platform android agent-device install-from-source https://example.com/builds/app.aab --platform android -agent-device install-from-source https://example.com/builds/MyApp.ipa --platform ios --header "authorization: Bearer TOKEN" agent-device install-from-source https://api.github.com/repos/acme/app/actions/artifacts/123/zip --platform ios --header "authorization: Bearer TOKEN" ```