Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
"name": "DCD_API_URL",
"description": "Override the API base URL (e.g. for a non-production environment).",
"isRequired": false
},
{
"name": "DCD_ENCRYPT",
"description": "Uploads (app binary, flows, env vars) are encrypted on your machine by default. Set to 0 to upload them unencrypted; a run's encrypt input overrides this.",
"isRequired": false
}
]
}
Expand Down
4 changes: 2 additions & 2 deletions src/commands/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export const cloudCommand = defineCommand({
// Client-side envelope encryption of every sensitive artifact: the
// binary (#1138), the flow zip (#1151), and env vars (#1152). Kept as
// given (--no-encrypt is an explicit false) and resolved once the
// compatibility data, which carries the org's default, is in.
// compatibility data, which carries the server default, is in.
const encryptFlag = args.encrypt as boolean | undefined;
const includeTags = coerceArray(
collectRepeatedFlag(rawArgs, ['--include-tags']),
Expand Down Expand Up @@ -797,7 +797,7 @@ export const cloudCommand = defineCommand({
}

if (encrypt && encryption.source === 'server') {
out(ui.note('Encrypting uploads (beta); opt out with --no-encrypt'));
out(ui.note('Encrypting uploads; opt out with --no-encrypt'));
}

if (!finalBinaryId) {
Expand Down
4 changes: 2 additions & 2 deletions src/commands/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const uploadCommand = defineCommand({
const appUrl = args['app-url'] as string | undefined;
const ignoreShaCheck = Boolean(args['ignore-sha-check']);
// Kept as given, so --no-encrypt is an explicit false and a missing flag
// leaves DCD_ENCRYPT and then the org's default to decide.
// leaves DCD_ENCRYPT and then the server default to decide.
const encryptFlag = args.encrypt as boolean | undefined;
const debug = Boolean(args.debug);
const positional = args.appFile as string | undefined;
Expand Down Expand Up @@ -121,7 +121,7 @@ export const uploadCommand = defineCommand({
ui.branch([
...ui.fields([['file', colors.highlight(resolvedFile)]]),
...(encryption.enabled && encryption.source === 'server'
? [ui.note('Encrypting uploads (beta); opt out with --no-encrypt')]
? [ui.note('Encrypting uploads; opt out with --no-encrypt')]
: []),
]),
);
Expand Down
11 changes: 6 additions & 5 deletions src/config/environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ export interface DcdEnvironment {
* KEK the platform API unwraps with. **Public — safe to embed**: it can only
* *encrypt*; the private half lives solely on the API.
*
* Left `null` until the KEK is generated and provisioned. Generate a keypair
* with, e.g.:
* Both environments pin v1. To rotate, generate a keypair with, e.g.:
* 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"))'
* Pin `public` here; set `private` as `BINARY_KEK_PRIVATE_KEYS={"<version>":"<private base64>"}`
* on the API service and keep one offline escrow copy. Until then, encryption
* can be exercised via the `DCD_BINARY_KEK_PUBLIC` env override (see envelope.ts).
* Add `private` to the API's `BINARY_KEK_PRIVATE_KEYS` JSON map next to the
* versions already there (dropping one bricks its uploads), keep one offline
* escrow copy, add `public` to dcd's `PINNED_KEK_PUBLIC_KEYS`, then pin it
* here with the new version. `null` pins nothing, so encryption then needs
* the `DCD_BINARY_KEK_PUBLIC` env override (see envelope.ts).
*/
kekPublicKey: { version: number; key: string } | null;
}
Expand Down
9 changes: 3 additions & 6 deletions src/config/flags/binary.flags.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CliArgsDef } from '../../utils/help.js';
import type { ArgsDef } from 'citty';

/**
* Binary upload and management flags
Expand Down Expand Up @@ -26,10 +26,7 @@ export const binaryFlags = {
},
encrypt: {
type: 'boolean',
// Left out of --help while encryption is a per-org beta (dcd#1138); the
// flag still parses, --no-encrypt included.
hidden: true,
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. Also turned on by DCD_ENCRYPT=1, and by default for orgs in the encryption beta; --no-encrypt or DCD_ENCRYPT=0 turns it off.',
'Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). On by default; --no-encrypt or DCD_ENCRYPT=0 turns it off. The binary is still deduplicated across runs, so an unchanged app is not re-uploaded.',
},
} as const satisfies CliArgsDef;
} as const satisfies ArgsDef;
5 changes: 3 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
* Flags are organized by domain in src/config/flags/
*/

import type { ArgsDef } from 'citty';

import { apiFlags } from './config/flags/api.flags.js';
import { binaryFlags } from './config/flags/binary.flags.js';
import { deviceFlags } from './config/flags/device.flags.js';
import { environmentFlags } from './config/flags/environment.flags.js';
import { executionFlags } from './config/flags/execution.flags.js';
import { githubFlags } from './config/flags/github.flags.js';
import { outputFlags } from './config/flags/output.flags.js';
import type { CliArgsDef } from './utils/help.js';

/**
* All flag definitions consolidated from domain-specific flag modules.
Expand All @@ -24,6 +25,6 @@ export const flags = {
...executionFlags,
...githubFlags,
...outputFlags,
} as const satisfies CliArgsDef;
} as const satisfies ArgsDef;

export type FlagKey = keyof typeof flags;
11 changes: 2 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { uploadCommand } from './commands/upload.js';
import { whoamiCommand } from './commands/whoami.js';
import { telemetry } from './services/telemetry.service.js';
import { CliError, getCliVersion, logger, wantsJson } from './utils/cli.js';
import { withoutHiddenArgs } from './utils/help.js';

// @clack/prompts ships the US spelling ("Canceled") for its built-in
// spinner/prompt cancellation message; align it with the British spelling
Expand Down Expand Up @@ -83,18 +82,12 @@ async function resolveSubCommand(
return [cmd, parent];
}

// Usage for the (sub)command named in rawArgs, without the args marked hidden.
async function showHelp(rawArgs: string[]): Promise<void> {
const [cmd, parent] = await resolveSubCommand(main, rawArgs);
await showUsage(await withoutHiddenArgs(cmd), parent);
}

async function run(): Promise<void> {
const rawArgs = process.argv.slice(2);
telemetry.recordCommandStart();
try {
if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
await showHelp(rawArgs);
await showUsage(...(await resolveSubCommand(main, rawArgs)));
} else if (rawArgs.length === 1 && rawArgs[0] === '--version') {
logger.log(getCliVersion());
} else {
Expand All @@ -110,7 +103,7 @@ async function run(): Promise<void> {
// citty throws CLIError (by name — the class isn't exported) for usage
// problems like unknown commands or missing required args.
if (!json && error instanceof Error && error.name === 'CLIError') {
await showHelp(rawArgs);
await showUsage(...(await resolveSubCommand(main, rawArgs)));
}

const exitCode = error instanceof CliError ? error.exitCode : 1;
Expand Down
6 changes: 3 additions & 3 deletions src/mcp/tools/run-cloud-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export function registerRunCloudTest(server: McpServer): void {
.boolean()
.optional()
.describe(
'true encrypts the binary, flow zip and env vars client-side before upload; false never does. Omit to follow DCD_ENCRYPT, then the organization default.',
'true encrypts the binary, flow zip and env vars client-side before upload; false never does. Omit to follow DCD_ENCRYPT, then the default, which is on.',
),
wait: z
.boolean()
Expand Down Expand Up @@ -206,7 +206,7 @@ export function registerRunCloudTest(server: McpServer): void {
}

// Client-side envelope encryption (binary/flow/env), in the same order
// as `dcd cloud`: the encrypt input, DCD_ENCRYPT, then the org default.
// as `dcd cloud`: the encrypt input, DCD_ENCRYPT, then the server default.
const encryption = checkKekForEncryption(
resolveEncryption({
flag: args.encrypt,
Expand All @@ -225,7 +225,7 @@ export function registerRunCloudTest(server: McpServer): void {
};
telemetryExtra.encrypt = encryptTelemetry;
if (encrypt && encryption.source === 'server') {
logStderr('Encrypting uploads (beta); opt out with encrypt: false');
logStderr('Encrypting uploads; opt out with encrypt: false');
}

// Resolve the binary: existing id, or upload the local file.
Expand Down
2 changes: 1 addition & 1 deletion src/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ async function encryptUploadSource(
const kek = resolveKekPublicKey(apiUrl);
if (!kek) {
throw new Error(
'Encryption is on (from --encrypt, DCD_ENCRYPT or the organization default) but no KEK public key is configured for this API. ' +
'Encryption is on (from --encrypt, DCD_ENCRYPT or the default) but no KEK public key is configured for this API. ' +
'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64>, or pass --no-encrypt to upload without encryption.',
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/services/test-submission.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export class TestSubmissionService {
const kek = resolveKekPublicKey(apiUrl);
if (!kek) {
throw new Error(
'Encryption is on (from --encrypt, DCD_ENCRYPT or the organization default) but no KEK public key is configured for this API. ' +
'Encryption is on (from --encrypt, DCD_ENCRYPT or the default) but no KEK public key is configured for this API. ' +
'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64>, or pass --no-encrypt to upload without encryption.',
);
}
Expand Down
5 changes: 2 additions & 3 deletions src/utils/compatibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,8 @@ export interface CompatibilityData {
/** Active CLI notices, piggybacked onto the compatibility response by the API. */
notices?: Notice[];
/**
* The org's client-side encryption default (dcd#1138): on for orgs in the
* `client_encryption` beta unless the API's kill switch is set. An older API
* omits it, which means off.
* The client-side encryption default (dcd#1138): on for every org unless the
* API's kill switch is set. An older API omits it, which means off.
*/
encryption?: { defaultOn: boolean };
}
Expand Down
4 changes: 2 additions & 2 deletions src/utils/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ function parseEncryptEnv(value: string | undefined): boolean | undefined {
* 1. `flag`: `--encrypt` / `--no-encrypt`, or the MCP tool's `encrypt` input.
* 2. `DCD_ENCRYPT`, then its legacy alias `DCD_ENCRYPT_BINARIES`.
* 3. `serverDefault`: `encryption.defaultOn` from the compatibility data,
* which the API turns on for orgs in the `client_encryption` beta.
* which the API turns on for every org unless its kill switch is set.
* 4. Off. An API too old to send `encryption` ends up here.
* When on, the binary, the flow zip, and the env map are each encrypted with
* their **own** per-upload DEK (all wrapped under the same per-env KEK).
Expand All @@ -112,7 +112,7 @@ export function resolveEncryption(opts: {
* Check that `apiUrl` has a KEK before a run encrypts against it: either
* `DCD_BINARY_KEK_PUBLIC` is set or the URL is a known devicecloud.dev API
* (see kekEnvForApiUrl). Without one, an explicit `--encrypt` is an error,
* while encryption from DCD_ENCRYPT or the org default is skipped with a
* while encryption from DCD_ENCRYPT or the server default is skipped with a
* notice, so a run against another API URL uploads in plaintext as before.
*/
export function checkKekForEncryption(
Expand Down
22 changes: 0 additions & 22 deletions src/utils/help.ts

This file was deleted.

31 changes: 10 additions & 21 deletions test/unit/hidden-help.test.ts → test/unit/encrypt-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@ import * as path from 'node:path';

import { cloudCommand } from '../../src/commands/cloud.js';
import { uploadCommand } from '../../src/commands/upload.js';
import { withoutHiddenArgs } from '../../src/utils/help.js';

/**
* While client-side encryption is a per-org beta (dcd#1138), --encrypt and
* --no-encrypt are left out of --help but must still parse.
* Client-side encryption is on by default (dcd#1138), so --encrypt is listed
* in --help, and --no-encrypt, the per-run opt-out, must parse.
*/
const commands: Array<[string, CommandDef<ArgsDef>]> = [
['cloud', cloudCommand as CommandDef<ArgsDef>],
Expand All @@ -22,21 +21,16 @@ async function argsOf(cmd: CommandDef<ArgsDef>): Promise<ArgsDef> {
return (typeof cmd.args === 'function' ? await cmd.args() : await cmd.args) ?? {};
}

describe('hidden --encrypt flag', () => {
describe('--encrypt flag', () => {
for (const [name, cmd] of commands) {
describe(`dcd ${name}`, () => {
it('leaves encrypt out of the usage', async () => {
const usage = await renderUsage(await withoutHiddenArgs(cmd));
expect(usage).to.not.match(/encrypt/i);
// Everything else still renders.
expect(usage).to.contain('--api-key');
it('lists encrypt in the usage, with the opt-out', async () => {
const usage = await renderUsage(cmd);
expect(usage).to.contain('--encrypt');
expect(usage).to.contain('--no-encrypt or DCD_ENCRYPT=0 turns it off');
});

it('would list it without the filter, so the test above means something', async () => {
expect(await renderUsage(cmd)).to.contain('--encrypt');
});

it('still parses --encrypt and --no-encrypt', async () => {
it('parses --encrypt and --no-encrypt', async () => {
const args = await argsOf(cmd);
expect(parseArgs(['--encrypt'], args).encrypt).to.equal(true);
expect(parseArgs(['--no-encrypt'], args).encrypt).to.equal(false);
Expand All @@ -45,11 +39,6 @@ describe('hidden --encrypt flag', () => {
});
}

it('does not change the command it filters', async () => {
await withoutHiddenArgs(cloudCommand as CommandDef<ArgsDef>);
expect(await argsOf(cloudCommand as CommandDef<ArgsDef>)).to.have.property('encrypt');
});

describe('through the real entry point', () => {
let configDir: string;

Expand All @@ -62,7 +51,7 @@ describe('hidden --encrypt flag', () => {
});

for (const name of ['cloud', 'upload']) {
it(`dcd ${name} --help does not mention encrypt`, () => {
it(`dcd ${name} --help lists --encrypt`, () => {
const out = execFileSync(
process.execPath,
['--import', 'tsx/esm', path.resolve('src/index.ts'), name, '--help'],
Expand All @@ -77,7 +66,7 @@ describe('hidden --encrypt flag', () => {
},
);
expect(out).to.contain(`dcd ${name}`);
expect(out).to.not.match(/encrypt/i);
expect(out).to.contain('--encrypt');
});
}
});
Expand Down
2 changes: 1 addition & 1 deletion test/unit/encryption-decision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {

/**
* The order that decides whether a run encrypts (dcd#1138): an explicit flag,
* then DCD_ENCRYPT (or its legacy alias), then the org's default from the
* then DCD_ENCRYPT (or its legacy alias), then the server default from the
* compatibility data, then off. Every case passes `env` explicitly so the
* developer's own shell can't leak in.
*/
Expand Down
Loading
Loading