@devicecloud.dev/dcd 5.2.0 → 5.3.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.
@@ -144,14 +144,6 @@ export declare const cloudCommand: import("citty").CommandDef<{
144
144
  readonly alias: ["m"];
145
145
  readonly description: "Arbitrary key-value metadata to include with your test run (format: key=value, may be repeated)";
146
146
  };
147
- readonly mitmHost: {
148
- readonly type: "string";
149
- readonly description: "used for mitmproxy support, enterprise only, contact support if interested";
150
- };
151
- readonly mitmPath: {
152
- readonly type: "string";
153
- readonly description: "used for mitmproxy support, enterprise only, contact support if interested";
154
- };
155
147
  readonly 'moropo-v1-api-key': {
156
148
  readonly type: "string";
157
149
  readonly description: "API key for Moropo v1 integration";
@@ -234,6 +226,10 @@ export declare const cloudCommand: import("citty").CommandDef<{
234
226
  readonly type: "boolean";
235
227
  readonly description: "Ignore the sha hash check and upload the binary regardless of whether it already exists (not recommended)";
236
228
  };
229
+ readonly encrypt: {
230
+ readonly type: "boolean";
231
+ readonly description: "Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Can also be enabled with DCD_ENCRYPT=1.";
232
+ };
237
233
  readonly 'api-key': {
238
234
  readonly type: "string";
239
235
  readonly alias: ["apiKey"];
@@ -17,6 +17,7 @@ import { VersionService } from '../services/version.service.js';
17
17
  import { EAndroidApiLevels, EAndroidDevices, EiOSDevices, EiOSVersions, isIosMatrixConfig, } from '../types/domain/device.types.js';
18
18
  import { resolveAuth } from '../utils/auth.js';
19
19
  import { assertMatrixSupported, matrixIsIos, parseDeviceMatrix, } from '../utils/device-matrix.js';
20
+ import { isEncryptionEnabled } from '../utils/envelope.js';
20
21
  import { detectCiContext, isCI } from '../utils/ci.js';
21
22
  import { CliError, coerceArray, collectRepeatedFlag, getCliVersion, getUpgradeCommand, logger, parseIntFlag, validateEnum, } from '../utils/cli.js';
22
23
  import { fetchCompatibilityData, } from '../utils/compatibility.js';
@@ -132,6 +133,10 @@ export const cloudCommand = defineCommand({
132
133
  let flows = args.flows;
133
134
  const googlePlay = Boolean(args['google-play']);
134
135
  const ignoreShaCheck = Boolean(args['ignore-sha-check']);
136
+ // Single opt-in for client-side envelope encryption of every sensitive
137
+ // artifact — the binary (#1138), the flow zip (#1151), and env vars
138
+ // (#1152). Flag wins; otherwise DCD_ENCRYPT / DCD_ENCRYPT_BINARIES.
139
+ const encrypt = isEncryptionEnabled(args['encrypt'] ? true : undefined);
135
140
  const includeTags = coerceArray(collectRepeatedFlag(rawArgs, ['--include-tags']));
136
141
  const iOSDevice = validateEnum(args['ios-device'], Object.values(EiOSDevices), 'ios-device');
137
142
  const iOSVersion = validateEnum(args['ios-version'], Object.values(EiOSVersions), 'ios-version');
@@ -147,8 +152,6 @@ export const cloudCommand = defineCommand({
147
152
  const jsonFileName = args['json-file-name'];
148
153
  const maestroVersion = args['maestro-version'];
149
154
  const metadata = coerceArray(collectRepeatedFlag(rawArgs, ['--metadata', '-m']), false);
150
- const mitmHost = args.mitmHost;
151
- const mitmPath = args.mitmPath;
152
155
  const moropoApiKey = args['moropo-v1-api-key'];
153
156
  const name = args.name;
154
157
  const orientation = validateEnum(args.orientation, ORIENTATION_OPTIONS, 'orientation');
@@ -176,9 +179,6 @@ export const cloudCommand = defineCommand({
176
179
  quiet = true;
177
180
  out('--json-file is true: JSON output will be written to file, forcing --quiet flag for better CI output');
178
181
  }
179
- if (mitmPath && !mitmHost) {
180
- throw new CliError('--mitmPath requires --mitmHost to be set');
181
- }
182
182
  if (jsonFileName && !jsonFileFlag) {
183
183
  throw new CliError('--json-file-name requires --json-file');
184
184
  }
@@ -260,10 +260,10 @@ export const cloudCommand = defineCommand({
260
260
  out(ui.info('runnerType m4 is experimental and currently supports iOS only, Android will revert to default.'));
261
261
  }
262
262
  if (runnerType === 'm1') {
263
- out(ui.info('runnerType m1 is experimental and currently supports Android (Pixel 7, API Level 34) only.'));
263
+ out(ui.info('runnerType m1 is experimental and currently supports Android only (all devices, API level 34-36).'));
264
264
  }
265
265
  if (runnerType === 'gpu1') {
266
- out(ui.info('runnerType gpu1 is Android-only (all devices, API Level 34 or 35), available to all users.'));
266
+ out(ui.info('runnerType gpu1 is Android-only (all devices, API level 34+), available to all users.'));
267
267
  }
268
268
  const firstFile = args.firstFile;
269
269
  const secondFile = args.secondFile;
@@ -498,6 +498,7 @@ export const cloudCommand = defineCommand({
498
498
  auth,
499
499
  apiUrl,
500
500
  debug,
501
+ encrypt,
501
502
  filePath: finalAppFile,
502
503
  ignoreShaCheck,
503
504
  log: !json,
@@ -526,6 +527,7 @@ export const cloudCommand = defineCommand({
526
527
  androidApiLevel,
527
528
  androidDevice,
528
529
  androidNoSnapshot,
530
+ apiUrl,
529
531
  appBinaryId: finalBinaryId,
530
532
  cliVersion,
531
533
  commonRoot,
@@ -533,6 +535,7 @@ export const cloudCommand = defineCommand({
533
535
  debug,
534
536
  deviceLocale,
535
537
  deviceMatrix,
538
+ encrypt,
536
539
  env,
537
540
  executionPlan,
538
541
  flowFile,
@@ -542,8 +545,6 @@ export const cloudCommand = defineCommand({
542
545
  logger: (m) => out(m),
543
546
  maestroVersion: resolvedMaestroVersion,
544
547
  metadata: mergedMetadata,
545
- mitmHost,
546
- mitmPath,
547
548
  name,
548
549
  orientation,
549
550
  raw: [],
@@ -7,6 +7,10 @@ export declare const uploadCommand: import("citty").CommandDef<{
7
7
  readonly type: "boolean";
8
8
  readonly description: "Ignore the sha hash check and upload the binary regardless of whether it already exists (not recommended)";
9
9
  };
10
+ readonly encrypt: {
11
+ readonly type: "boolean";
12
+ readonly description: "Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Can also be enabled with DCD_ENCRYPT=1.";
13
+ };
10
14
  readonly debug: {
11
15
  readonly type: "boolean";
12
16
  readonly default: false;
@@ -19,6 +19,7 @@ export const uploadCommand = defineCommand({
19
19
  ...apiFlags,
20
20
  'app-url': binaryFlags['app-url'],
21
21
  'ignore-sha-check': binaryFlags['ignore-sha-check'],
22
+ encrypt: binaryFlags.encrypt,
22
23
  debug: outputFlags.debug,
23
24
  json: outputFlags.json,
24
25
  appFile: {
@@ -40,6 +41,7 @@ export const uploadCommand = defineCommand({
40
41
  const apiUrl = resolveApiUrl(args['api-url']);
41
42
  const appUrl = args['app-url'];
42
43
  const ignoreShaCheck = Boolean(args['ignore-sha-check']);
44
+ const encryptBinary = Boolean(args['encrypt']);
43
45
  const debug = Boolean(args.debug);
44
46
  const positional = args.appFile;
45
47
  const auth = await resolveAuth({ apiKeyFlag });
@@ -74,6 +76,7 @@ export const uploadCommand = defineCommand({
74
76
  auth,
75
77
  apiUrl,
76
78
  debug,
79
+ encrypt: encryptBinary,
77
80
  filePath: resolvedFile,
78
81
  ignoreShaCheck,
79
82
  log: !json,
@@ -17,6 +17,23 @@ export interface DcdEnvironment {
17
17
  projectRef: string;
18
18
  anonKey: string;
19
19
  };
20
+ /**
21
+ * Pinned KEK public key for client-side binary envelope encryption (dcd#1138).
22
+ * `key` is base64 of the raw 32-byte X25519 public key; `version` selects which
23
+ * KEK the platform API unwraps with. **Public — safe to embed** (like the anon
24
+ * key above): it can only *encrypt*; the private half lives solely on the API.
25
+ *
26
+ * Left `null` until the KEK is generated and provisioned. Generate a keypair
27
+ * with, e.g.:
28
+ * node -e 'const c=require("crypto");const{publicKey,privateKey}=c.generateKeyPairSync("x25519");const pub=publicKey.export({type:"spki",format:"der"}).subarray(12);const priv=privateKey.export({type:"pkcs8",format:"der"}).subarray(16);console.log("public :",pub.toString("base64"));console.log("private:",priv.toString("base64"))'
29
+ * Pin `public` here; set `private` as `BINARY_KEK_PRIVATE_KEYS={"<version>":"<private base64>"}`
30
+ * on the API service and keep one offline escrow copy. Until then, encryption
31
+ * can be exercised via the `DCD_BINARY_KEK_PUBLIC` env override (see envelope.ts).
32
+ */
33
+ kekPublicKey: {
34
+ version: number;
35
+ key: string;
36
+ } | null;
20
37
  }
21
38
  export declare const ENVIRONMENTS: Record<DcdEnvName, DcdEnvironment>;
22
39
  /** Exact-match lookup of a known environment by API URL (trailing slashes ignored). */
@@ -17,6 +17,7 @@ export const ENVIRONMENTS = {
17
17
  projectRef: 'pgydnphbimetinsgfkbo',
18
18
  anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8',
19
19
  },
20
+ kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' },
20
21
  },
21
22
  dev: {
22
23
  apiUrl: 'https://api.dev.devicecloud.dev',
@@ -26,6 +27,7 @@ export const ENVIRONMENTS = {
26
27
  projectRef: 'lbmsowehtjwnqlurpemb',
27
28
  anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ',
28
29
  },
30
+ kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' },
29
31
  },
30
32
  };
31
33
  /** Exact-match lookup of a known environment by API URL (trailing slashes ignored). */
@@ -19,4 +19,8 @@ export declare const binaryFlags: {
19
19
  readonly type: "boolean";
20
20
  readonly description: "Ignore the sha hash check and upload the binary regardless of whether it already exists (not recommended)";
21
21
  };
22
+ readonly encrypt: {
23
+ readonly type: "boolean";
24
+ readonly description: "Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Can also be enabled with DCD_ENCRYPT=1.";
25
+ };
22
26
  };
@@ -19,4 +19,8 @@ export const binaryFlags = {
19
19
  type: 'boolean',
20
20
  description: 'Ignore the sha hash check and upload the binary regardless of whether it already exists (not recommended)',
21
21
  },
22
+ encrypt: {
23
+ type: 'boolean',
24
+ description: 'Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Can also be enabled with DCD_ENCRYPT=1.',
25
+ },
22
26
  };
@@ -13,14 +13,6 @@ export declare const environmentFlags: {
13
13
  readonly alias: ["m"];
14
14
  readonly description: "Arbitrary key-value metadata to include with your test run (format: key=value, may be repeated)";
15
15
  };
16
- readonly mitmHost: {
17
- readonly type: "string";
18
- readonly description: "used for mitmproxy support, enterprise only, contact support if interested";
19
- };
20
- readonly mitmPath: {
21
- readonly type: "string";
22
- readonly description: "used for mitmproxy support, enterprise only, contact support if interested";
23
- };
24
16
  readonly 'moropo-v1-api-key': {
25
17
  readonly type: "string";
26
18
  readonly description: "API key for Moropo v1 integration";
@@ -13,14 +13,6 @@ export const environmentFlags = {
13
13
  alias: ['m'],
14
14
  description: 'Arbitrary key-value metadata to include with your test run (format: key=value, may be repeated)',
15
15
  },
16
- mitmHost: {
17
- type: 'string',
18
- description: 'used for mitmproxy support, enterprise only, contact support if interested',
19
- },
20
- mitmPath: {
21
- type: 'string',
22
- description: 'used for mitmproxy support, enterprise only, contact support if interested',
23
- },
24
16
  'moropo-v1-api-key': {
25
17
  type: 'string',
26
18
  description: 'API key for Moropo v1 integration',
@@ -130,14 +130,6 @@ export declare const flags: {
130
130
  readonly alias: ["m"];
131
131
  readonly description: "Arbitrary key-value metadata to include with your test run (format: key=value, may be repeated)";
132
132
  };
133
- readonly mitmHost: {
134
- readonly type: "string";
135
- readonly description: "used for mitmproxy support, enterprise only, contact support if interested";
136
- };
137
- readonly mitmPath: {
138
- readonly type: "string";
139
- readonly description: "used for mitmproxy support, enterprise only, contact support if interested";
140
- };
141
133
  readonly 'moropo-v1-api-key': {
142
134
  readonly type: "string";
143
135
  readonly description: "API key for Moropo v1 integration";
@@ -220,6 +212,10 @@ export declare const flags: {
220
212
  readonly type: "boolean";
221
213
  readonly description: "Ignore the sha hash check and upload the binary regardless of whether it already exists (not recommended)";
222
214
  };
215
+ readonly encrypt: {
216
+ readonly type: "boolean";
217
+ readonly description: "Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Can also be enabled with DCD_ENCRYPT=1.";
218
+ };
223
219
  readonly 'api-key': {
224
220
  readonly type: "string";
225
221
  readonly alias: ["apiKey"];
@@ -31,9 +31,45 @@ export declare const ApiGateway: {
31
31
  * the download methods.
32
32
  */
33
33
  streamResponseToFile(res: Response, destinationPath: string, operation: string): Promise<void>;
34
- checkForExistingUpload(baseUrl: string, auth: AuthContext, sha: string): Promise<{
34
+ /**
35
+ * Prefer server-assembled bundle delivery. The API returns a signed manifest
36
+ * plus a URL to a delivery service that streams the ZIP straight from
37
+ * storage, so large downloads don't flow through the API itself. The client
38
+ * just relays the signed `{ manifest, sig }` to that URL — no auth header,
39
+ * because the manifest is already signed.
40
+ *
41
+ * Returns true once the bundle has been streamed to disk. Returns false when
42
+ * the bundle path is unavailable or anything about it doesn't pan out — a
43
+ * `501` (deployment doesn't offer it), a non-OK manifest response, a body
44
+ * that isn't a manifest, or a delivery-service error — so the caller can fall
45
+ * back to the inline download endpoint. Definitive errors (e.g. not found)
46
+ * surface through that inline path instead.
47
+ */
48
+ tryBundleDownload(baseUrl: string, auth: AuthContext, manifestEndpoint: string, destinationPath: string, operation: string): Promise<boolean>;
49
+ /**
50
+ * Look for an already-uploaded binary to skip re-uploading.
51
+ *
52
+ * Two lookup keys, because encryption changes what is stable (dcd#1168).
53
+ * Unencrypted uploads pass `sha` (the hash of exactly what gets stored).
54
+ * Encrypted uploads pass `shaPlain` plus `encrypted: true`: their ciphertext is
55
+ * freshly keyed on every upload, so its hash never matches, and the plaintext
56
+ * hash is the only stable key. `sha` is deliberately not sent in that case —
57
+ * see the caller in methods.ts for why sending it would be unsafe.
58
+ *
59
+ * `encrypted` in the response reports whether the *matched* binary is stored
60
+ * encrypted, so the caller can verify the invariant it asked for instead of
61
+ * trusting the server to have applied the right predicate.
62
+ */
63
+ checkForExistingUpload(baseUrl: string, auth: AuthContext, lookup: {
64
+ encrypted?: boolean;
65
+ sha?: string;
66
+ shaPlain?: string;
67
+ } | string): Promise<{
35
68
  appBinaryId: string;
36
69
  exists: boolean;
70
+ encrypted?: boolean;
71
+ } & {
72
+ encrypted?: boolean;
37
73
  }>;
38
74
  downloadArtifactsZip(baseUrl: string, auth: AuthContext, uploadId: string, results: "ALL" | "FAILED", artifactsPath?: string): Promise<void>;
39
75
  finaliseUpload(config: {
@@ -45,6 +81,12 @@ export declare const ApiGateway: {
45
81
  metadata: TAppMetadata;
46
82
  path: string;
47
83
  sha?: string;
84
+ /**
85
+ * Hash of the PLAINTEXT, sent only for encrypted uploads (dcd#1168). Stored
86
+ * as `binaries.sha_plain` so later encrypted uploads of the same input can
87
+ * dedup; `sha` remains the ciphertext hash.
88
+ */
89
+ shaPlain?: string;
48
90
  supabaseSuccess: boolean;
49
91
  }): Promise<Record<string, never>>;
50
92
  getBinaryUploadUrl(baseUrl: string, auth: AuthContext, platform: "android" | "ios", fileSize: number): Promise<{
@@ -139,10 +139,93 @@ export const ApiGateway = {
139
139
  const fileStream = createWriteStream(expandedPath, { flags: 'w' });
140
140
  await pipeline(Readable.fromWeb(res.body), fileStream);
141
141
  },
142
- async checkForExistingUpload(baseUrl, auth, sha) {
142
+ /**
143
+ * Prefer server-assembled bundle delivery. The API returns a signed manifest
144
+ * plus a URL to a delivery service that streams the ZIP straight from
145
+ * storage, so large downloads don't flow through the API itself. The client
146
+ * just relays the signed `{ manifest, sig }` to that URL — no auth header,
147
+ * because the manifest is already signed.
148
+ *
149
+ * Returns true once the bundle has been streamed to disk. Returns false when
150
+ * the bundle path is unavailable or anything about it doesn't pan out — a
151
+ * `501` (deployment doesn't offer it), a non-OK manifest response, a body
152
+ * that isn't a manifest, or a delivery-service error — so the caller can fall
153
+ * back to the inline download endpoint. Definitive errors (e.g. not found)
154
+ * surface through that inline path instead.
155
+ */
156
+ async tryBundleDownload(baseUrl, auth, manifestEndpoint, destinationPath, operation) {
157
+ let manifestRes;
158
+ try {
159
+ manifestRes = await fetch(`${baseUrl}${manifestEndpoint}`, {
160
+ headers: { ...auth.headers },
161
+ method: 'GET',
162
+ });
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ // 501 => this deployment has no bundle delivery; any other non-OK => let
168
+ // the inline path re-request and surface the real error.
169
+ if (!manifestRes.ok) {
170
+ return false;
171
+ }
172
+ let bundle;
173
+ try {
174
+ bundle = (await manifestRes.json());
175
+ }
176
+ catch {
177
+ return false;
178
+ }
179
+ if (!bundle?.bundleUrl || !bundle?.manifest || !bundle?.sig) {
180
+ return false;
181
+ }
182
+ let zipRes;
183
+ try {
184
+ zipRes = await fetch(bundle.bundleUrl, {
185
+ body: JSON.stringify({ manifest: bundle.manifest, sig: bundle.sig }),
186
+ // No auth header: the manifest is signed and is the access token.
187
+ headers: { 'content-type': 'application/json' },
188
+ method: 'POST',
189
+ });
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ if (!zipRes.ok) {
195
+ return false;
196
+ }
197
+ // A mid-stream failure (dropped/truncated connection) or a null body on an
198
+ // otherwise-OK response throws here — fall back to the inline path rather
199
+ // than let it escape past the caller's fallback. The inline path re-opens
200
+ // the destination with flags: 'w', truncating any partial file left behind.
201
+ try {
202
+ await this.streamResponseToFile(zipRes, destinationPath, operation);
203
+ }
204
+ catch {
205
+ return false;
206
+ }
207
+ return true;
208
+ },
209
+ /**
210
+ * Look for an already-uploaded binary to skip re-uploading.
211
+ *
212
+ * Two lookup keys, because encryption changes what is stable (dcd#1168).
213
+ * Unencrypted uploads pass `sha` (the hash of exactly what gets stored).
214
+ * Encrypted uploads pass `shaPlain` plus `encrypted: true`: their ciphertext is
215
+ * freshly keyed on every upload, so its hash never matches, and the plaintext
216
+ * hash is the only stable key. `sha` is deliberately not sent in that case —
217
+ * see the caller in methods.ts for why sending it would be unsafe.
218
+ *
219
+ * `encrypted` in the response reports whether the *matched* binary is stored
220
+ * encrypted, so the caller can verify the invariant it asked for instead of
221
+ * trusting the server to have applied the right predicate.
222
+ */
223
+ async checkForExistingUpload(baseUrl, auth, lookup) {
224
+ // Historically this took a bare sha string; keep that shape working.
225
+ const body = typeof lookup === 'string' ? { sha: lookup } : { ...lookup };
143
226
  try {
144
227
  const res = await fetch(`${baseUrl}/uploads/checkForExistingUpload`, {
145
- body: JSON.stringify({ sha }),
228
+ body: JSON.stringify(body),
146
229
  headers: {
147
230
  'content-type': 'application/json',
148
231
  ...auth.headers,
@@ -163,6 +246,10 @@ export const ApiGateway = {
163
246
  }
164
247
  },
165
248
  async downloadArtifactsZip(baseUrl, auth, uploadId, results, artifactsPath = './artifacts.zip') {
249
+ // Prefer bundle delivery; fall back to the inline download below.
250
+ if (await this.tryBundleDownload(baseUrl, auth, `/results/${uploadId}/artifacts-bundle?results=${results}`, artifactsPath, 'Failed to download artifacts')) {
251
+ return;
252
+ }
166
253
  try {
167
254
  const res = await fetch(`${baseUrl}/results/${uploadId}/download`, {
168
255
  body: JSON.stringify({ results }),
@@ -185,7 +272,7 @@ export const ApiGateway = {
185
272
  }
186
273
  },
187
274
  async finaliseUpload(config) {
188
- const { baseUrl, auth, id, metadata, path, sha, supabaseSuccess, backblazeSuccess, bytes } = config;
275
+ const { baseUrl, auth, id, metadata, path, sha, shaPlain, supabaseSuccess, backblazeSuccess, bytes } = config;
189
276
  try {
190
277
  const res = await fetch(`${baseUrl}/uploads/finaliseUpload`, {
191
278
  body: JSON.stringify({
@@ -195,6 +282,7 @@ export const ApiGateway = {
195
282
  metadata,
196
283
  path, // This is tempPath for TUS uploads
197
284
  ...(sha ? { sha } : {}),
285
+ ...(shaPlain ? { shaPlain } : {}),
198
286
  supabaseSuccess,
199
287
  }),
200
288
  headers: {
@@ -486,6 +574,13 @@ export const ApiGateway = {
486
574
  const { endpoint, defaultFilename, notFoundMessage, errorPrefix } = config[reportType];
487
575
  const finalReportPath = reportPath || path.resolve(process.cwd(), defaultFilename);
488
576
  const url = `${baseUrl}${endpoint}`;
577
+ // The HTML report is a ZIP bundle; prefer bundle delivery when available.
578
+ // (junit is a single small file and allure has its own endpoint — both stay
579
+ // on the inline path.)
580
+ if (reportType === 'html' &&
581
+ (await this.tryBundleDownload(baseUrl, auth, `/results/${uploadId}/report-bundle`, finalReportPath, errorPrefix))) {
582
+ return;
583
+ }
489
584
  try {
490
585
  // Make the download request
491
586
  const res = await fetch(url, {
@@ -9,6 +9,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
9
9
  * `waitTimeoutSeconds`.
10
10
  *
11
11
  * Mirrors the `dcd cloud` command's submission path but headless: no Expo URL
12
- * download, mitm, GitHub metadata, or JSON-file output. Use the CLI for those.
12
+ * download, GitHub metadata, or JSON-file output. Use the CLI for those.
13
13
  */
14
14
  export declare function registerRunCloudTest(server: McpServer): void;
@@ -9,6 +9,7 @@ import { VersionService } from '../../services/version.service.js';
9
9
  import { uploadBinary, uploadFlowZip, verifyAppZip } from '../../methods.js';
10
10
  import { getCliVersion } from '../../utils/cli.js';
11
11
  import { fetchCompatibilityData } from '../../utils/compatibility.js';
12
+ import { isEncryptionEnabled } from '../../utils/envelope.js';
12
13
  import { getConsoleUrl } from '../../utils/styling.js';
13
14
  import { getContext, logStderr } from '../context.js';
14
15
  import { jsonResult, runTool } from '../helpers.js';
@@ -26,7 +27,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
26
27
  * `waitTimeoutSeconds`.
27
28
  *
28
29
  * Mirrors the `dcd cloud` command's submission path but headless: no Expo URL
29
- * download, mitm, GitHub metadata, or JSON-file output. Use the CLI for those.
30
+ * download, GitHub metadata, or JSON-file output. Use the CLI for those.
30
31
  */
31
32
  export function registerRunCloudTest(server) {
32
33
  server.registerTool('dcd_run_cloud_test', {
@@ -130,6 +131,9 @@ export function registerRunCloudTest(server) {
130
131
  sequentialFlowCount: executionPlan.sequence?.flows.length ?? 0,
131
132
  });
132
133
  }
134
+ // Client-side envelope encryption (binary/flow/env). No MCP flag, so
135
+ // it's env-driven: DCD_ENCRYPT / DCD_ENCRYPT_BINARIES.
136
+ const encrypt = isEncryptionEnabled();
133
137
  // Resolve the binary: existing id, or upload the local file.
134
138
  let appBinaryId = args.appBinaryId;
135
139
  if (!appBinaryId) {
@@ -145,6 +149,7 @@ export function registerRunCloudTest(server) {
145
149
  appBinaryId = await uploadBinary({
146
150
  auth,
147
151
  apiUrl,
152
+ encrypt,
148
153
  filePath: args.appFile,
149
154
  ignoreShaCheck: Boolean(args.ignoreShaCheck),
150
155
  log: false,
@@ -153,10 +158,12 @@ export function registerRunCloudTest(server) {
153
158
  const { continueOnFailure = true } = executionPlan.sequence ?? {};
154
159
  const testSubmissionService = new TestSubmissionService();
155
160
  const { buffer, fields } = await testSubmissionService.buildTestPayload({
161
+ apiUrl,
156
162
  appBinaryId,
157
163
  cliVersion,
158
164
  commonRoot,
159
165
  continueOnFailure,
166
+ encrypt,
160
167
  executionPlan,
161
168
  flowFile,
162
169
  env: args.env ?? [],
package/dist/methods.d.ts CHANGED
@@ -5,6 +5,12 @@ interface UploadBinaryConfig {
5
5
  auth: AuthContext;
6
6
  apiUrl: string;
7
7
  debug?: boolean;
8
+ /**
9
+ * Encrypt the binary before upload (client-side envelope encryption, #1138).
10
+ * Defaults to `DCD_ENCRYPT` / `DCD_ENCRYPT_BINARIES` when unset (see
11
+ * {@link isEncryptionEnabled}).
12
+ */
13
+ encrypt?: boolean;
8
14
  filePath: string;
9
15
  ignoreShaCheck?: boolean;
10
16
  log?: boolean;