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..118a2c65b 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,20 @@ 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://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. +- 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. ## Remote config shape diff --git a/src/__tests__/upload-client.test.ts b/src/__tests__/upload-client.test.ts index 74a8d9a28..3836158b4 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,203 @@ 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.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'); 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 +394,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 +465,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..1ed0087fb 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: req.flags?.platform, }); return { positionals, @@ -323,6 +324,7 @@ async function prepareRemoteInstallSource( localPath, baseUrl: info.baseUrl!, token: info.token, + platform: req.flags?.platform, }); return { installSource: { 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..f91cddba2 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?: string; +}; + +type PreparedUploadArtifact = { + payloadPath: string; + fileName: string; + artifactType: 'app-bundle' | 'file'; + platform?: 'ios' | 'android'; + contentType: string; + sha256: string; + sizeBytes: number; + cleanup: () => void; }; type UploadResponse = { @@ -23,60 +37,294 @@ type UploadResponse = { type UploadPreflightResponse = { ok: boolean; - cacheHit: boolean; + cacheHit?: boolean; uploadId?: string; + upload?: { + url?: string; + headers?: Record; + }; }; -export async function uploadArtifact(options: UploadArtifactOptions): Promise { - const { localPath, baseUrl, token } = options; +type UploadPreflightResult = + | { + kind: 'cache-hit'; + uploadId: string; + } + | { + kind: 'direct-upload'; + uploadId: string; + url: string; + headers: Record; + }; - const stat = fs.statSync(localPath); - const isDirectory = stat.isDirectory(); - const filename = path.basename(localPath); - const artifactType = isDirectory ? 'app-bundle' : 'file'; +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') { + try { + await uploadDirectArtifact(prepared.payloadPath, preflight); + return await finalizeDirectUpload({ + normalizedBase, + token: options.token, + uploadId: preflight.uploadId, + }); + } catch { + return await uploadLegacyArtifact({ + normalizedBase, + token: options.token, + artifact: prepared, + }); + } + } + + return await uploadLegacyArtifact({ + normalizedBase, + token: options.token, + artifact: prepared, + }); + } finally { + prepared.cleanup(); + } +} + +async function prepareUploadArtifact( + localPath: string, + requestedPlatform: string | undefined, +): Promise { + const stat = fs.statSync(localPath); + const fileName = path.basename(localPath); + const isDirectory = stat.isDirectory(); + const platform = + normalizeUploadPlatform(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 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 }); + } +} + +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; } - return new Promise((resolve, reject) => { + 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: { + normalizedBase: string; + token: string; + artifact: PreparedUploadArtifact; +}): Promise { + const preflightUrl = new URL('upload/preflight', 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(preflightUrl, { + method: 'POST', + headers, + signal: AbortSignal.timeout(UPLOAD_PREFLIGHT_TIMEOUT_MS), + body: JSON.stringify({ + 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); + + if (!response?.ok) { + return undefined; + } + + return parseUploadPreflightResult(await response.json().catch(() => undefined)); +} + +function parseUploadPreflightResult(value: unknown): UploadPreflightResult | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const preflight = value as UploadPreflightResponse; + if (preflight.ok !== true || typeof preflight.uploadId !== 'string') { + return undefined; + } + 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 = upload.headers ?? {}; + if (!isStringRecord(headers)) { + return undefined; + } + return { + kind: 'direct-upload', + uploadId: preflight.uploadId, + url: upload.url, + headers, + }; +} + +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 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', + }); + + 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: 'POST', - path: uploadUrl.pathname + uploadUrl.search, - 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) => { let body = ''; @@ -86,16 +334,11 @@ export async function uploadArtifact(options: UploadArtifactOptions): Promise { 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}`)); - } + resolve({ + statusCode: res.statusCode ?? 500, + statusMessage: res.statusMessage, + body, + }); }); }, ); @@ -103,9 +346,9 @@ export async function uploadArtifact(options: UploadArtifactOptions): Promise { req.destroy(); reject( - new AppError('COMMAND_FAILED', 'Artifact upload timed out', { + new AppError('COMMAND_FAILED', options.timeoutMessage, { timeoutMs: UPLOAD_TIMEOUT_MS, - hint: 'The upload to the remote daemon exceeded the 5-minute timeout.', + ...(options.timeoutHint ? { hint: options.timeoutHint } : {}), }), ); }, UPLOAD_TIMEOUT_MS); @@ -115,53 +358,29 @@ export async function uploadArtifact(options: UploadArtifactOptions): Promise clearTimeout(timeout)); - if (isDirectory) { - const parentDir = path.dirname(localPath); - const dirName = path.basename(localPath); - const tar = spawn('tar', ['cf', '-', '-C', parentDir, dirName], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - tar.stdout.pipe(req); - tar.on('error', (err) => { - 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(options.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: { +async function finalizeDirectUpload(options: { normalizedBase: string; token: string; - hash: string; - filename: string; - sizeBytes: number; - artifactType: string; -}): Promise { - const preflightUrl = new URL('upload/preflight', options.normalizedBase); + uploadId: string; +}): Promise { + const finalizeUrl = new URL('upload/finalize', options.normalizedBase); const headers: Record = { 'content-type': 'application/json', }; @@ -170,35 +389,27 @@ async function requestUploadPreflight(options: { headers['x-agent-device-token'] = options.token; } - const response = await fetch(preflightUrl, { + const response = await fetch(finalizeUrl, { method: 'POST', 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, - }), - }).catch(() => undefined); + body: JSON.stringify({ uploadId: options.uploadId }), + }).catch((error) => { + throw new AppError('COMMAND_FAILED', 'Failed to finalize direct artifact upload', {}, error); + }); - if (!response?.ok) { - return undefined; + 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 unknown; - return isUploadPreflightHit(parsed) ? parsed.uploadId : undefined; -} - -function isUploadPreflightHit(value: unknown): value is Required { - if (!value || typeof value !== 'object') { - return false; + const parsed = (await response.json().catch(() => undefined)) as UploadResponse | undefined; + if (!parsed?.ok || !parsed.uploadId) { + throw new AppError('COMMAND_FAILED', 'Invalid upload finalize response'); } - const preflight = value as UploadPreflightResponse; - return ( - preflight.ok === true && preflight.cacheHit === true && typeof preflight.uploadId === 'string' - ); + return parsed.uploadId; } async function computeFileHash(localPath: string): Promise { diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index d5669c986..780f22140 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: ... }`. +- 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..ad286f6d0 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -373,12 +373,16 @@ 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/MyApp.ipa --platform ios --header "authorization: Bearer TOKEN" +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-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. +- 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..60ca63ad0 100644 --- a/website/docs/docs/quick-start.md +++ b/website/docs/docs/quick-start.md @@ -69,6 +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-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.