diff --git a/server.json b/server.json index 5764b41..9145abc 100644 --- a/server.json +++ b/server.json @@ -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 } ] } diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 1420b78..92fe2fb 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -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']), @@ -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) { diff --git a/src/commands/upload.ts b/src/commands/upload.ts index c8c4955..792e49c 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -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; @@ -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')] : []), ]), ); diff --git a/src/config/environments.ts b/src/config/environments.ts index 477ce53..dfdc2db 100644 --- a/src/config/environments.ts +++ b/src/config/environments.ts @@ -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={"":""}` - * 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; } diff --git a/src/config/flags/binary.flags.ts b/src/config/flags/binary.flags.ts index cbda3f8..96111f3 100644 --- a/src/config/flags/binary.flags.ts +++ b/src/config/flags/binary.flags.ts @@ -1,4 +1,4 @@ -import type { CliArgsDef } from '../../utils/help.js'; +import type { ArgsDef } from 'citty'; /** * Binary upload and management flags @@ -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; diff --git a/src/constants.ts b/src/constants.ts index 0030737..7cdf1e1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,6 +3,8 @@ * 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'; @@ -10,7 +12,6 @@ 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. @@ -24,6 +25,6 @@ export const flags = { ...executionFlags, ...githubFlags, ...outputFlags, -} as const satisfies CliArgsDef; +} as const satisfies ArgsDef; export type FlagKey = keyof typeof flags; diff --git a/src/index.ts b/src/index.ts index da07bd4..7c0da60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 @@ -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 { - const [cmd, parent] = await resolveSubCommand(main, rawArgs); - await showUsage(await withoutHiddenArgs(cmd), parent); -} - async function run(): Promise { 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 { @@ -110,7 +103,7 @@ async function run(): Promise { // 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; diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts index c57137f..3bb3eec 100644 --- a/src/mcp/tools/run-cloud-test.ts +++ b/src/mcp/tools/run-cloud-test.ts @@ -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() @@ -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, @@ -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. diff --git a/src/methods.ts b/src/methods.ts index d4eb213..8d8ffca 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -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=:, or pass --no-encrypt to upload without encryption.', ); } diff --git a/src/services/test-submission.service.ts b/src/services/test-submission.service.ts index 367208b..abb291f 100644 --- a/src/services/test-submission.service.ts +++ b/src/services/test-submission.service.ts @@ -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=:, or pass --no-encrypt to upload without encryption.', ); } diff --git a/src/utils/compatibility.ts b/src/utils/compatibility.ts index 9df0285..0440087 100644 --- a/src/utils/compatibility.ts +++ b/src/utils/compatibility.ts @@ -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 }; } diff --git a/src/utils/envelope.ts b/src/utils/envelope.ts index 751243b..a8ef2d1 100644 --- a/src/utils/envelope.ts +++ b/src/utils/envelope.ts @@ -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). @@ -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( diff --git a/src/utils/help.ts b/src/utils/help.ts deleted file mode 100644 index 3560332..0000000 --- a/src/utils/help.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ArgDef, ArgsDef, CommandDef } from 'citty'; - -/** - * A citty arg definition plus a local `hidden` marker. citty (0.2) can hide a - * command from help (`meta.hidden`) but not an arg, so {@link withoutHiddenArgs} - * drops hidden args before usage is rendered. They still parse as normal. - */ -export type CliArgDef = ArgDef & { hidden?: boolean }; -export type CliArgsDef = Record; - -/** `cmd` with its hidden args left out, for rendering `--help` only. */ -export async function withoutHiddenArgs( - cmd: CommandDef, -): Promise> { - const args = typeof cmd.args === 'function' ? await cmd.args() : await cmd.args; - if (!args) return cmd; - - const visible = Object.fromEntries( - Object.entries(args).filter(([, def]) => !(def as CliArgDef).hidden), - ) as T; - return { ...cmd, args: visible }; -} diff --git a/test/unit/hidden-help.test.ts b/test/unit/encrypt-help.test.ts similarity index 63% rename from test/unit/hidden-help.test.ts rename to test/unit/encrypt-help.test.ts index 89029ea..871ce2a 100644 --- a/test/unit/hidden-help.test.ts +++ b/test/unit/encrypt-help.test.ts @@ -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]> = [ ['cloud', cloudCommand as CommandDef], @@ -22,21 +21,16 @@ async function argsOf(cmd: CommandDef): Promise { 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); @@ -45,11 +39,6 @@ describe('hidden --encrypt flag', () => { }); } - it('does not change the command it filters', async () => { - await withoutHiddenArgs(cloudCommand as CommandDef); - expect(await argsOf(cloudCommand as CommandDef)).to.have.property('encrypt'); - }); - describe('through the real entry point', () => { let configDir: string; @@ -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'], @@ -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'); }); } }); diff --git a/test/unit/encryption-decision.test.ts b/test/unit/encryption-decision.test.ts index cda1aa7..2621498 100644 --- a/test/unit/encryption-decision.test.ts +++ b/test/unit/encryption-decision.test.ts @@ -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. */ diff --git a/test/unit/upload-encrypt.test.ts b/test/unit/upload-encrypt.test.ts index a5a1c26..35f1cb5 100644 --- a/test/unit/upload-encrypt.test.ts +++ b/test/unit/upload-encrypt.test.ts @@ -10,7 +10,7 @@ import { clearCompatibilityCache } from '../../src/utils/compatibility.js'; /** * `dcd upload` decides encryption in the same order as `dcd cloud`: the flag - * (--encrypt / --no-encrypt), then DCD_ENCRYPT, then the org's default from + * (--encrypt / --no-encrypt), then DCD_ENCRYPT, then the server default from * the compatibility data (dcd#1138), then off. * * Observed through the dedup lookup, which is the first upload request either @@ -21,6 +21,8 @@ const API = 'http://localhost:9999'; /** Not a devicecloud.dev API, so no pinned KEK applies. */ const UNKNOWN_API = 'https://api.example.com'; const APK = path.join(process.cwd(), 'test/fixtures/wikipedia.apk'); +/** Printed when the server default, not a flag or env var, turned encryption on. */ +const DEFAULT_ON_NOTICE = 'Encrypting uploads; opt out with --no-encrypt'; describe('dcd upload encryption', () => { const ORIGINAL_ENV = { ...process.env }; @@ -141,17 +143,17 @@ describe('dcd upload encryption', () => { expect(lookups[0]).to.not.have.property('encrypted'); }); - it('uploads in plaintext when the org default is off', async () => { + it('uploads in plaintext when the server default is off', async () => { compatEncryption = { defaultOn: false }; expect(await upload()).to.equal('plain-binary'); }); - it('encrypts when the org default is on', async () => { + it('encrypts when the server default is on', async () => { compatEncryption = { defaultOn: true }; expect(await upload()).to.equal('encrypted-binary'); }); - it('lets --no-encrypt beat the org default', async () => { + it('lets --no-encrypt beat the server default', async () => { compatEncryption = { defaultOn: true }; expect(await upload({ encrypt: false })).to.equal('plain-binary'); }); @@ -161,7 +163,7 @@ describe('dcd upload encryption', () => { expect(await upload({ encrypt: false })).to.equal('plain-binary'); }); - it('lets DCD_ENCRYPT=0 beat the org default', async () => { + it('lets DCD_ENCRYPT=0 beat the server default', async () => { process.env.DCD_ENCRYPT = '0'; compatEncryption = { defaultOn: true }; expect(await upload()).to.equal('plain-binary'); @@ -172,18 +174,16 @@ describe('dcd upload encryption', () => { expect(await upload()).to.equal('plain-binary'); }); - it('says the beta is on, and how to opt out, when the org default encrypts', async () => { + it('says it is encrypting, and how to opt out, when the server default encrypts', async () => { compatEncryption = { defaultOn: true }; - expect(await run({})).to.contain( - 'Encrypting uploads (beta); opt out with --no-encrypt', - ); + expect(await run({})).to.contain(DEFAULT_ON_NOTICE); }); - it('says nothing about the beta when the flag or env var turned encryption on', async () => { + it('prints no notice when the flag or env var turned encryption on', async () => { compatEncryption = { defaultOn: true }; - expect(await run({ encrypt: true })).to.not.contain('(beta)'); + expect(await run({ encrypt: true })).to.not.contain(DEFAULT_ON_NOTICE); process.env.DCD_ENCRYPT = '1'; - expect(await run({})).to.not.contain('(beta)'); + expect(await run({})).to.not.contain(DEFAULT_ON_NOTICE); }); describe('against an API URL with no pinned KEK', () => { @@ -191,12 +191,12 @@ describe('dcd upload encryption', () => { delete process.env.DCD_BINARY_KEK_PUBLIC; }); - it('skips the org default, with a notice, and uploads in plaintext', async () => { + it('skips the server default, with a notice, and uploads in plaintext', async () => { compatEncryption = { defaultOn: true }; expect(await upload({ 'api-url': UNKNOWN_API })).to.equal('plain-binary'); const out = await run({ 'api-url': UNKNOWN_API }); expect(out).to.contain(`Not encrypting uploads: ${UNKNOWN_API}`); - expect(out).to.not.contain('(beta)'); + expect(out).to.not.contain(DEFAULT_ON_NOTICE); }); it('skips DCD_ENCRYPT=1 the same way', async () => {