@loadmill/droid-cua 2.40.0 → 2.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +33 -3
  2. package/build/index.js +69 -10
  3. package/build/src/browser/connection.js +5 -0
  4. package/build/src/browser/factory.js +1 -0
  5. package/build/src/browser/playwright/connection.js +3 -0
  6. package/build/src/cli/headless-runner.js +55 -3
  7. package/build/src/cli/headless-target.js +15 -7
  8. package/build/src/commands/help.js +10 -4
  9. package/build/src/core/execution-engine.js +26 -1
  10. package/build/src/core/report/render-html-report.js +36 -6
  11. package/build/src/core/report/run-log-model.js +65 -0
  12. package/build/src/core/report/run-report-model.js +1 -0
  13. package/build/src/core/report/run-report-sections.js +15 -0
  14. package/build/src/core/tool-handlers/assert.js +45 -2
  15. package/build/src/core/tool-handlers/query-network.js +92 -0
  16. package/build/src/desktop/computer-use.js +137 -51
  17. package/build/src/desktop/primary-display.js +74 -0
  18. package/build/src/device/openai.js +25 -0
  19. package/build/src/modes/execution-mode.js +10 -1
  20. package/build/src/network/browser-capture.js +110 -0
  21. package/build/src/network/certificate-authority.js +205 -0
  22. package/build/src/network/certificate-install.js +242 -0
  23. package/build/src/network/local-proxy.js +287 -0
  24. package/build/src/network/network-capture.js +243 -0
  25. package/build/src/network/query-network.js +215 -0
  26. package/build/src/network/run-network-capture.js +107 -0
  27. package/build/src/prompts/base.js +9 -6
  28. package/build/src/prompts/execution.js +10 -6
  29. package/package.json +2 -1
  30. package/skill/droid-cua/SKILL.md +1 -1
  31. package/skill/droid-cua/references/authoring-tests.md +16 -0
  32. package/skill/droid-cua/references/onboarding-and-troubleshooting.md +10 -0
  33. package/skill/droid-cua/references/running-and-healing.md +9 -1
@@ -0,0 +1,205 @@
1
+ import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, X509Certificate } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import forge from "node-forge";
6
+ export function getCertificatePaths(directory = path.join(os.homedir(), ".droid-cua", "network")) {
7
+ return {
8
+ directory,
9
+ certificate: path.join(directory, "ca.pem"),
10
+ privateKey: path.join(directory, "ca-key.pem"),
11
+ displayName: path.join(directory, "ca-name.json"),
12
+ preparedDevices: path.join(directory, "prepared-devices.json"),
13
+ };
14
+ }
15
+ function inspectCertificate(certificate, privateKey) {
16
+ const parsed = new X509Certificate(certificate);
17
+ if (!parsed.ca || !parsed.checkPrivateKey(createPrivateKey(privateKey))) {
18
+ throw new Error("The network CA certificate and private key do not match, or the certificate is not a CA.");
19
+ }
20
+ return {
21
+ certificate,
22
+ privateKey,
23
+ fingerprint: parsed.fingerprint256,
24
+ androidFileName: `${legacySubjectHash(certificate)}.0`,
25
+ };
26
+ }
27
+ function createCertificateAuthority() {
28
+ const { privateKey, publicKey } = generateKeyPairSync("rsa", {
29
+ modulusLength: 2048,
30
+ publicKeyEncoding: { type: "spki", format: "pem" },
31
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
32
+ });
33
+ const certificate = forge.pki.createCertificate();
34
+ certificate.publicKey = forge.pki.publicKeyFromPem(publicKey);
35
+ certificate.serialNumber = randomBytes(16).toString("hex").replace(/^./, "1");
36
+ certificate.validity.notBefore = new Date(Date.now() - 24 * 60 * 60 * 1000);
37
+ certificate.validity.notAfter = new Date(Date.now() + 10 * 365 * 24 * 60 * 60 * 1000);
38
+ const subject = [{ name: "commonName", value: `Droid CUA Root ${randomBytes(4).toString("hex")}` }];
39
+ certificate.setSubject(subject);
40
+ certificate.setIssuer(subject);
41
+ certificate.setExtensions([
42
+ { name: "basicConstraints", cA: true, critical: true },
43
+ { name: "keyUsage", keyCertSign: true, cRLSign: true, critical: true },
44
+ { name: "subjectKeyIdentifier" },
45
+ ]);
46
+ certificate.sign(forge.pki.privateKeyFromPem(privateKey), forge.md.sha256.create());
47
+ return inspectCertificate(forge.pki.certificateToPem(certificate), privateKey);
48
+ }
49
+ function generatedName() {
50
+ return `droid-network-${new Date().toISOString().slice(0, 10)}.pem`;
51
+ }
52
+ async function storeCertificateAuthority(paths, ca, name) {
53
+ await mkdir(paths.directory, { recursive: true, mode: 0o700 });
54
+ let previousFingerprint = null;
55
+ try {
56
+ previousFingerprint = new X509Certificate(await readFile(paths.certificate, "utf8")).fingerprint256;
57
+ }
58
+ catch (error) {
59
+ if (error?.code !== "ENOENT")
60
+ throw error;
61
+ }
62
+ const suffix = randomBytes(6).toString("hex");
63
+ const tempKey = `${paths.privateKey}.${suffix}.tmp`;
64
+ const tempCertificate = `${paths.certificate}.${suffix}.tmp`;
65
+ const tempName = `${paths.displayName}.${suffix}.tmp`;
66
+ try {
67
+ await writeFile(tempKey, ca.privateKey, { mode: 0o600, flag: "wx" });
68
+ await writeFile(tempCertificate, ca.certificate, { mode: 0o600, flag: "wx" });
69
+ await writeFile(tempName, JSON.stringify({ name }), { mode: 0o600, flag: "wx" });
70
+ await rename(tempKey, paths.privateKey);
71
+ await rename(tempCertificate, paths.certificate);
72
+ await rename(tempName, paths.displayName);
73
+ if (previousFingerprint !== ca.fingerprint)
74
+ await rm(paths.preparedDevices, { force: true });
75
+ }
76
+ finally {
77
+ await Promise.all([tempKey, tempCertificate, tempName].map((file) => rm(file, { force: true })));
78
+ }
79
+ return ca;
80
+ }
81
+ function parsePemBundle(pem) {
82
+ const blocks = [...pem.matchAll(/-----BEGIN ([A-Z ]+)-----[\s\S]*?-----END \1-----/g)];
83
+ const certificates = blocks.filter((block) => block[1] === "CERTIFICATE");
84
+ const keys = blocks.filter((block) => block[1].endsWith("PRIVATE KEY"));
85
+ if (certificates.length !== 1 || keys.length !== 1 || keys[0][1] === "ENCRYPTED PRIVATE KEY") {
86
+ throw new Error("Choose one PEM file containing a CA certificate and one unencrypted private key.");
87
+ }
88
+ const certificate = `${certificates[0][0]}\n`;
89
+ const privateKey = `${keys[0][0]}\n`;
90
+ let ca;
91
+ try {
92
+ ca = inspectCertificate(certificate, privateKey);
93
+ }
94
+ catch (error) {
95
+ throw new Error(`The PEM does not contain a matching CA certificate and private key. ${error.message}`);
96
+ }
97
+ const parsed = new X509Certificate(certificate);
98
+ const key = createPrivateKey(privateKey);
99
+ const usage = forge.pki.certificateFromPem(certificate).getExtension("keyUsage");
100
+ if (key.asymmetricKeyType !== "rsa" || !usage?.keyCertSign || parsed.issuer !== parsed.subject || !parsed.verify(parsed.publicKey)) {
101
+ throw new Error("The PEM must contain a self-signed RSA root CA that can sign certificates.");
102
+ }
103
+ const now = Date.now();
104
+ if (now < Date.parse(parsed.validFrom) || now >= Date.parse(parsed.validTo)) {
105
+ throw new Error("The CA certificate is not currently valid.");
106
+ }
107
+ return ca;
108
+ }
109
+ export async function loadCertificateAuthority(directory) {
110
+ const paths = getCertificatePaths(directory);
111
+ let certificate;
112
+ let privateKey;
113
+ try {
114
+ [certificate, privateKey] = await Promise.all([
115
+ readFile(paths.certificate, "utf8"),
116
+ readFile(paths.privateKey, "utf8"),
117
+ ]);
118
+ }
119
+ catch (error) {
120
+ if (error?.code === "ENOENT") {
121
+ throw new Error(`The Droid network CA is incomplete or missing in ${paths.directory}. Use Tools → Install Network Certificate in the desktop app to create it.`);
122
+ }
123
+ throw error;
124
+ }
125
+ return inspectCertificate(certificate, privateKey);
126
+ }
127
+ export async function getCertificateAuthorityStatus(directory) {
128
+ const paths = getCertificatePaths(directory);
129
+ const files = await Promise.all([paths.certificate, paths.privateKey].map(async (file) => {
130
+ try {
131
+ await stat(file);
132
+ return true;
133
+ }
134
+ catch (error) {
135
+ if (error?.code === "ENOENT")
136
+ return false;
137
+ throw error;
138
+ }
139
+ }));
140
+ if (files.every((exists) => !exists))
141
+ return { name: null, fingerprint: null };
142
+ const ca = await loadCertificateAuthority(directory);
143
+ let name;
144
+ try {
145
+ name = JSON.parse(await readFile(paths.displayName, "utf8")).name;
146
+ }
147
+ catch (error) {
148
+ if (error?.code !== "ENOENT")
149
+ throw error;
150
+ const { mtime } = await stat(paths.certificate);
151
+ name = `droid-network-${mtime.toISOString().slice(0, 10)}.pem`;
152
+ }
153
+ return { name, fingerprint: ca.fingerprint };
154
+ }
155
+ export async function importCertificateAuthority(pem, fileName, directory) {
156
+ if (!fileName.toLowerCase().endsWith(".pem"))
157
+ throw new Error("Choose a .pem file.");
158
+ const ca = parsePemBundle(pem);
159
+ return storeCertificateAuthority(getCertificatePaths(directory), ca, path.basename(fileName));
160
+ }
161
+ export async function generateCertificateAuthority(directory) {
162
+ return storeCertificateAuthority(getCertificatePaths(directory), createCertificateAuthority(), generatedName());
163
+ }
164
+ export async function removeCertificateAuthority(directory) {
165
+ const paths = getCertificatePaths(directory);
166
+ await Promise.all([paths.certificate, paths.privateKey, paths.displayName, paths.preparedDevices].map((file) => rm(file, { force: true })));
167
+ }
168
+ export async function ensureCertificateAuthority(directory) {
169
+ const paths = getCertificatePaths(directory);
170
+ await mkdir(paths.directory, { recursive: true, mode: 0o700 });
171
+ const existing = await Promise.all([paths.certificate, paths.privateKey].map(async (file) => {
172
+ try {
173
+ await stat(file);
174
+ return true;
175
+ }
176
+ catch (error) {
177
+ if (error?.code === "ENOENT")
178
+ return false;
179
+ throw error;
180
+ }
181
+ }));
182
+ if (existing.some(Boolean))
183
+ return loadCertificateAuthority(directory);
184
+ try {
185
+ await stat(paths.preparedDevices);
186
+ throw new Error(`The Droid network CA is missing from ${paths.directory}, but devices were prepared with it. Restore the original CA before installing another device.`);
187
+ }
188
+ catch (error) {
189
+ if (error?.code !== "ENOENT")
190
+ throw error;
191
+ }
192
+ return storeCertificateAuthority(paths, createCertificateAuthority(), generatedName());
193
+ }
194
+ export function legacySubjectHash(certificatePem) {
195
+ const certificate = forge.pki.certificateFromPem(certificatePem);
196
+ const asn1 = forge.pki.certificateToAsn1(certificate);
197
+ const tbs = asn1.value[0];
198
+ // TBSCertificate places the subject at index 5 when the optional version is present, otherwise 4.
199
+ const hasVersion = tbs.value[0].tagClass === forge.asn1.Class.CONTEXT_SPECIFIC;
200
+ const subject = tbs.value[hasVersion ? 5 : 4];
201
+ const subjectDer = Buffer.from(forge.asn1.toDer(subject).getBytes(), "binary");
202
+ const digest = createHash("md5").update(subjectDer).digest();
203
+ // Android's legacy OpenSSL hash reads the first four digest bytes as a little-endian word.
204
+ return digest.readUInt32LE(0).toString(16).padStart(8, "0");
205
+ }
@@ -0,0 +1,242 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { getCertificatePaths, loadCertificateAuthority } from "./certificate-authority.js";
7
+ const execFileAsync = promisify(execFile);
8
+ const MISSING = "__DROID_CERT_MISSING__";
9
+ const APEX_CERTS = "/apex/com.android.conscrypt/cacerts";
10
+ const SYSTEM_CERTS = "/system/etc/security/cacerts";
11
+ function commandError(error) {
12
+ const output = [error?.stderr, error?.stdout, error?.message].find((value) => typeof value === "string" && value.trim());
13
+ return String(output || "Command failed").trim();
14
+ }
15
+ async function run(file, args, options = {}) {
16
+ try {
17
+ return await execFileAsync(file, args, { timeout: 120000, maxBuffer: 4 * 1024 * 1024, ...options });
18
+ }
19
+ catch (error) {
20
+ throw new Error(`${file} ${args.slice(0, 3).join(" ")}: ${commandError(error)}`);
21
+ }
22
+ }
23
+ async function adb(adbPath, serial, args, options) {
24
+ return run(adbPath, ["-s", serial, ...args], options);
25
+ }
26
+ async function shell(adbPath, serial, script) {
27
+ return adb(adbPath, serial, ["shell", script]);
28
+ }
29
+ async function requireAndroidRoot(adbPath, serial) {
30
+ try {
31
+ await adb(adbPath, serial, ["root"]);
32
+ await adb(adbPath, serial, ["wait-for-device"]);
33
+ const { stdout } = await adb(adbPath, serial, ["shell", "id", "-u"]);
34
+ if (stdout.trim() !== "0")
35
+ throw new Error("ADB did not gain root access.");
36
+ }
37
+ catch (error) {
38
+ throw new Error(`This Android Emulator image does not provide the root access needed for system CA trust. Use a rootable AOSP or Google APIs image. ${commandError(error)}`);
39
+ }
40
+ }
41
+ async function readRemoteCertificate(adbPath, serial, directory, fileName, namespacePid) {
42
+ const remote = `${directory}/${fileName}`;
43
+ const inner = `[ -f ${remote} ] && cat ${remote} || printf ${MISSING}`;
44
+ const result = namespacePid
45
+ ? await shell(adbPath, serial, `nsenter --mount=/proc/${namespacePid}/ns/mnt -- sh -c \"${inner}\"`)
46
+ : await shell(adbPath, serial, inner);
47
+ return result.stdout.trim() === MISSING ? null : result.stdout;
48
+ }
49
+ export async function selectAndroidCertificateFileName(ca, readExisting) {
50
+ const hash = ca.androidFileName.slice(0, -2);
51
+ for (let index = 0; index < 100; index += 1) {
52
+ const name = `${hash}.${index}`;
53
+ const existing = await readExisting(name);
54
+ if (existing === null || existing.trim() === ca.certificate.trim())
55
+ return { name, alreadyPresent: existing !== null };
56
+ }
57
+ throw new Error("The Android certificate store has too many certificates with the same subject hash.");
58
+ }
59
+ async function chooseCertificateName(adbPath, serial, directory, ca) {
60
+ return selectAndroidCertificateFileName(ca, (name) => readRemoteCertificate(adbPath, serial, directory, name));
61
+ }
62
+ async function isTrustedInNamespaces(adbPath, serial, directory, ca, pids) {
63
+ const hash = ca.androidFileName.slice(0, -2);
64
+ const results = await Promise.all(pids.map(async (pid) => {
65
+ for (let index = 0; index < 100; index += 1) {
66
+ const current = await readRemoteCertificate(adbPath, serial, directory, `${hash}.${index}`, pid);
67
+ if (current?.trim() === ca.certificate.trim())
68
+ return true;
69
+ if (current === null)
70
+ return false;
71
+ }
72
+ return false;
73
+ }));
74
+ return results.every(Boolean);
75
+ }
76
+ export async function getAndroidAvdName(adbPath, serial) {
77
+ const { stdout } = await adb(adbPath, serial, ["emu", "avd", "name"]);
78
+ const name = stdout.split(/\r?\n/).map((line) => line.trim()).find((line) => line && line !== "OK");
79
+ if (!name)
80
+ throw new Error("Could not identify the Android Virtual Device for certificate preparation.");
81
+ return name;
82
+ }
83
+ async function readPreparedDevices() {
84
+ try {
85
+ return JSON.parse(await readFile(getCertificatePaths().preparedDevices, "utf8"));
86
+ }
87
+ catch (error) {
88
+ if (error?.code === "ENOENT")
89
+ return {};
90
+ throw error;
91
+ }
92
+ }
93
+ export async function hasPreparedAndroidCertificates() {
94
+ return Object.keys(await readPreparedDevices()).length > 0;
95
+ }
96
+ export async function recordPreparedAndroidCertificate(adbPath, serial, fingerprint) {
97
+ const prepared = await readPreparedDevices();
98
+ prepared[await getAndroidAvdName(adbPath, serial)] = fingerprint;
99
+ await writeFile(getCertificatePaths().preparedDevices, JSON.stringify(prepared, null, 2), { mode: 0o600 });
100
+ }
101
+ async function matchingPreparedAvd(adbPath, serial, ca = null) {
102
+ const prepared = await readPreparedDevices();
103
+ if (Object.keys(prepared).length === 0)
104
+ return null;
105
+ const avdName = await getAndroidAvdName(adbPath, serial);
106
+ if (!prepared[avdName])
107
+ return null;
108
+ const activeCa = ca || await loadCertificateAuthority();
109
+ if (prepared[avdName] !== activeCa.fingerprint) {
110
+ throw new Error(`The active network certificate has changed since ${avdName} was prepared. Run Tools → Install Network Certificate again.`);
111
+ }
112
+ return { avdName, ca: activeCa };
113
+ }
114
+ async function pushCertificate(adbPath, serial, ca, fileName) {
115
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "droid-network-ca-"));
116
+ const localPath = path.join(tempDir, fileName);
117
+ const remotePath = `/data/local/tmp/${fileName}`;
118
+ try {
119
+ await writeFile(localPath, ca.certificate, { mode: 0o600 });
120
+ await adb(adbPath, serial, ["push", localPath, remotePath]);
121
+ return remotePath;
122
+ }
123
+ finally {
124
+ await rm(tempDir, { recursive: true, force: true });
125
+ }
126
+ }
127
+ async function getZygotePids(adbPath, serial) {
128
+ const { stdout } = await shell(adbPath, serial, "pidof zygote zygote64 || true");
129
+ return [...new Set(stdout.trim().split(/\s+/).filter((value) => /^\d+$/.test(value)))];
130
+ }
131
+ async function requireMountCapabilities(adbPath, serial, directory) {
132
+ const { stdout } = await shell(adbPath, serial, `test -d '${directory}' && command -v nsenter && command -v mount && command -v chcon`);
133
+ if (!stdout.trim())
134
+ throw new Error("The certificate store or required mount tools are unavailable.");
135
+ const pids = await getZygotePids(adbPath, serial);
136
+ if (pids.length === 0)
137
+ throw new Error("No Android Zygote process was found.");
138
+ try {
139
+ await adb(adbPath, serial, ["shell", "nsenter", `--mount=/proc/${pids[0]}/ns/mnt`, "--", "true"]);
140
+ }
141
+ catch (error) {
142
+ throw new Error(`This emulator does not allow entry into application mount namespaces. ${commandError(error)}`);
143
+ }
144
+ return pids;
145
+ }
146
+ async function installMountedAndroid(adbPath, serial, ca, directory) {
147
+ let zygotes;
148
+ try {
149
+ zygotes = await requireMountCapabilities(adbPath, serial, directory);
150
+ }
151
+ catch (error) {
152
+ throw new Error(`This Android emulator cannot use Droid's system CA mount strategy. ${commandError(error)}`);
153
+ }
154
+ if (await isTrustedInNamespaces(adbPath, serial, directory, ca, zygotes))
155
+ return { changed: false };
156
+ const workDir = "/data/local/tmp/droid-cua-cacerts";
157
+ await shell(adbPath, serial, `mkdir -p '${workDir}' && cp '${directory}'/* '${workDir}/' && chmod 755 '${workDir}'`);
158
+ const { name } = await chooseCertificateName(adbPath, serial, workDir, ca);
159
+ const staged = await pushCertificate(adbPath, serial, ca, name);
160
+ await shell(adbPath, serial, `cp '${staged}' '${workDir}/${name}' && chown root:root '${workDir}'/* && chmod 644 '${workDir}'/* && chcon u:object_r:system_file:s0 '${workDir}' '${workDir}'/*`);
161
+ try {
162
+ // Zygote namespaces supply trust to newly launched apps.
163
+ for (const pid of zygotes) {
164
+ await adb(adbPath, serial, ["shell", "nsenter", `--mount=/proc/${pid}/ns/mnt`, "--", "mount", "--bind", workDir, directory]);
165
+ }
166
+ }
167
+ catch (error) {
168
+ throw new Error(`This emulator does not allow the required certificate-store bind mount. ${commandError(error)}`);
169
+ }
170
+ // Already running apps keep their own mount namespaces until restarted.
171
+ for (const pid of zygotes) {
172
+ const { stdout } = await shell(adbPath, serial, `ps -o PID -P ${pid} | tail -n +2`);
173
+ for (const appPid of stdout.trim().split(/\s+/).filter((value) => /^\d+$/.test(value))) {
174
+ try {
175
+ await adb(adbPath, serial, ["shell", "nsenter", `--mount=/proc/${appPid}/ns/mnt`, "--", "mount", "--bind", workDir, directory]);
176
+ }
177
+ catch { } // An app may exit while its mount is being updated.
178
+ }
179
+ }
180
+ const installed = await readRemoteCertificate(adbPath, serial, directory, name, zygotes[0]);
181
+ if (installed?.trim() !== ca.certificate.trim())
182
+ throw new Error("The Droid CA is not visible from Android's application mount namespace.");
183
+ return { changed: true };
184
+ }
185
+ export async function inspectNetworkDevice({ platform, deviceId, adbPath = process.env.DROID_CUA_ANDROID_ADB_PATH || "adb" }) {
186
+ if (platform === "ios") {
187
+ if (process.platform !== "darwin")
188
+ throw new Error("iOS Simulator certificate setup requires macOS.");
189
+ const { stdout } = await run("xcrun", ["simctl", "list", "devices", "--json"]);
190
+ const devices = JSON.parse(stdout).devices;
191
+ for (const [runtime, entries] of Object.entries(devices)) {
192
+ const simulator = entries.find((entry) => entry.udid === deviceId);
193
+ if (simulator)
194
+ return { platform, deviceId, osVersion: runtime.match(/iOS-([\d-]+)/)?.[1]?.replaceAll("-", ".") || runtime };
195
+ }
196
+ throw new Error("The connected device is not an available iOS Simulator.");
197
+ }
198
+ if (platform !== "android" || !/^emulator-\d+$/.test(deviceId)) {
199
+ throw new Error("Network certificate setup supports local iOS Simulators and Android Emulators only.");
200
+ }
201
+ const { stdout } = await adb(adbPath, deviceId, ["shell", "getprop", "ro.build.version.sdk"]);
202
+ const apiLevel = Number(stdout.trim());
203
+ if (!Number.isInteger(apiLevel) || apiLevel < 1) {
204
+ throw new Error(`Could not determine this Android Emulator's API level: ${stdout.trim() || "no value returned"}.`);
205
+ }
206
+ if (apiLevel > 36) {
207
+ const version = apiLevel === 37 ? "Android 17 (API 37)" : `Android API ${apiLevel}`;
208
+ throw new Error(`This emulator runs ${version}. Droid supports certificate setup through API 36, so installation stopped before checking root access. Use a rootable AOSP or Google APIs emulator on API 36 or earlier, without a Google Play image.`);
209
+ }
210
+ return { platform, deviceId, osVersion: `Android API ${apiLevel}`, apiLevel };
211
+ }
212
+ export async function installNetworkCertificate(target, ca, options = {}) {
213
+ if (target.platform === "ios") {
214
+ await run("xcrun", ["simctl", "keychain", target.deviceId, "add-root-cert", options.certificatePath]);
215
+ return { changed: true };
216
+ }
217
+ const adbPath = options.adbPath || process.env.DROID_CUA_ANDROID_ADB_PATH || "adb";
218
+ await requireAndroidRoot(adbPath, target.deviceId);
219
+ return installMountedAndroid(adbPath, target.deviceId, ca, target.apiLevel >= 34 ? APEX_CERTS : SYSTEM_CERTS);
220
+ }
221
+ export async function ensureCliAndroidCertificate(deviceId, ca, options = {}) {
222
+ const adbPath = options.adbPath || process.env.DROID_CUA_ANDROID_ADB_PATH || "adb";
223
+ const target = await inspectNetworkDevice({ platform: "android", deviceId, adbPath });
224
+ await requireAndroidRoot(adbPath, deviceId);
225
+ const directory = target.apiLevel >= 34 ? APEX_CERTS : SYSTEM_CERTS;
226
+ const pids = await requireMountCapabilities(adbPath, deviceId, directory);
227
+ if (await isTrustedInNamespaces(adbPath, deviceId, directory, ca, pids))
228
+ return { changed: false };
229
+ if (!await matchingPreparedAvd(adbPath, deviceId, ca)) {
230
+ throw new Error("The Android Emulator does not trust Droid's active network CA. Install it with Tools → Install Network Certificate or provision matching trust before running the CLI.");
231
+ }
232
+ return installNetworkCertificate(target, ca, { adbPath });
233
+ }
234
+ export async function reapplyPreparedAndroidCertificate(deviceId, options = {}) {
235
+ const adbPath = options.adbPath || process.env.DROID_CUA_ANDROID_ADB_PATH || "adb";
236
+ const prepared = await matchingPreparedAvd(adbPath, deviceId);
237
+ if (!prepared)
238
+ return null;
239
+ const target = await inspectNetworkDevice({ platform: "android", deviceId, adbPath });
240
+ const result = await installNetworkCertificate(target, prepared.ca, { adbPath });
241
+ return { ...result, avdName: prepared.avdName };
242
+ }