@devicecloud.dev/dcd 5.2.0 → 5.3.1-beta.1
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.
- package/dist/commands/cloud.d.ts +4 -8
- package/dist/commands/cloud.js +10 -9
- package/dist/commands/upload.d.ts +4 -0
- package/dist/commands/upload.js +3 -0
- package/dist/config/environments.d.ts +17 -0
- package/dist/config/environments.js +2 -0
- package/dist/config/flags/binary.flags.d.ts +4 -0
- package/dist/config/flags/binary.flags.js +4 -0
- package/dist/config/flags/environment.flags.d.ts +0 -8
- package/dist/config/flags/environment.flags.js +0 -8
- package/dist/constants.d.ts +4 -8
- package/dist/gateways/api-gateway.d.ts +43 -1
- package/dist/gateways/api-gateway.js +98 -3
- package/dist/mcp/tools/run-cloud-test.d.ts +1 -1
- package/dist/mcp/tools/run-cloud-test.js +8 -1
- package/dist/methods.d.ts +6 -0
- package/dist/methods.js +103 -13
- package/dist/services/execution-plan.service.js +55 -37
- package/dist/services/execution-plan.utils.d.ts +15 -0
- package/dist/services/execution-plan.utils.js +47 -0
- package/dist/services/test-submission.service.d.ts +7 -2
- package/dist/services/test-submission.service.js +40 -7
- package/dist/types/domain/device.types.d.ts +1 -0
- package/dist/types/domain/device.types.js +1 -0
- package/dist/types/generated/schema.types.d.ts +3531 -873
- package/dist/types.d.ts +7 -0
- package/dist/utils/envelope.d.ts +73 -0
- package/dist/utils/envelope.js +220 -0
- package/package.json +4 -5
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -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"];
|
package/dist/commands/cloud.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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;
|
package/dist/commands/upload.js
CHANGED
|
@@ -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',
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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,
|
|
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;
|