fix(cli): make encryption key push non-interactive-safe + add --skip-reencrypt-on-key-change (#9402)

When encryption_key.yaml changes and is pushed via `wmill sync push`,
pushWorkspaceKey prompted interactively to confirm re-encrypting the
remote secrets with the new key. That prompt ignored `--yes` and had no
TTY guard, so a CI/non-interactive push that included the key would
block (or behave undefinedly) on the prompt.

Thread a key-push options object (non-interactive flag + explicit
re-encryption choice) through pushObj into pushWorkspaceKey:

- Non-interactive (`--yes` or no TTY) and no explicit choice: skip the
  prompt and default to re-encrypting all remote secrets with the new
  key (matches the interactive default), preserving their plaintext
  values.
- New `--skip-reencrypt-on-key-change` flag (and the
  WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true env var for CI) opt out of
  re-encryption — only safe when the remote ciphertexts are already
  encrypted with the new key (e.g. workspace/instance migration).
- Interactive behavior (TTY, no `--yes`) is unchanged.

Regenerates system_prompts for the new option and adds unit tests for
the no-op, re-encrypt-by-default, flag-skip, and env-skip paths.

Fixes WIN-2005

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-06-02 09:11:29 +02:00
committed by GitHub
parent d71d553ba4
commit e356bb1f5d
9 changed files with 166 additions and 9 deletions
+12
View File
@@ -4041,6 +4041,10 @@ export async function push(
originalWorkspaceSpecificPath,
permissionedAsContext,
isWsSpecific ? true : undefined,
{
noninteractive: (opts.yes ?? false) || !process.stdin.isTTY,
skipReencrypt: opts.skipReencryptOnKeyChange,
},
);
if (stateTarget) {
@@ -4126,6 +4130,10 @@ export async function push(
localFilePath, // Pass the actual local file path
permissionedAsContext,
isAddedWsSpecific ? true : undefined,
{
noninteractive: (opts.yes ?? false) || !process.stdin.isTTY,
skipReencrypt: opts.skipReencryptOnKeyChange,
},
);
if (stateTarget) {
@@ -4682,6 +4690,10 @@ const command = new Command()
.option("--include-groups", "Include syncing groups")
.option("--include-settings", "Include syncing workspace settings")
.option("--include-key", "Include workspace encryption key")
.option(
"--skip-reencrypt-on-key-change",
"When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt.",
)
.option("--skip-branch-validation", "Skip git branch validation and prompts")
.option("--json-output", "Output results in JSON format")
.option(
+1
View File
@@ -88,6 +88,7 @@ export interface SyncOptions {
includeGroups?: boolean;
includeSettings?: boolean;
includeKey?: boolean;
skipReencryptOnKeyChange?: boolean;
skipBranchValidation?: boolean;
message?: string;
includes?: string[];
+48 -7
View File
@@ -445,11 +445,23 @@ export async function pushWorkspaceSettings(
}
}
export interface PushWorkspaceKeyOptions {
// True when no prompt may be shown (e.g. `--yes` was passed or stdin is not a
// TTY). In that case the re-encryption decision is taken from `skipReencrypt`
// / the WMILL_NO_REENCRYPT_ON_KEY_CHANGE env var instead of an interactive
// confirmation.
noninteractive?: boolean;
// Explicit re-encryption decision from `--skip-reencrypt-on-key-change`.
// When set it takes precedence over the prompt and the env var.
skipReencrypt?: boolean;
}
export async function pushWorkspaceKey(
workspace: string,
_path: string,
key: string | undefined,
localKey: string
localKey: string,
opts?: PushWorkspaceKeyOptions
) {
try {
key = await wmill
@@ -461,17 +473,46 @@ export async function pushWorkspaceKey(
throw new Error(`Failed to get workspace encryption key: ${err}`);
}
if (localKey && key !== localKey) {
const confirm = await Confirm.prompt({
message:
"The local workspace encryption key does not match the remote. Do you want to reencrypt all your secrets on the remote with the new key?\nSay 'no' if your local secrets are already encrypted with the new key (e.g. workspace/instance migration)\nOtherwise, say 'yes' and pull the secrets after the reencryption.\n",
default: true,
});
// Changing the key on the remote means the existing ciphertexts (encrypted
// with the old key) become unreadable unless they are re-encrypted. By
// default we ask the backend to re-encrypt every secret variable with the
// new key, which preserves their plaintext values. The only reason to skip
// re-encryption is when the stored ciphertexts are *already* encrypted with
// the new key (e.g. a workspace/instance migration).
let reencrypt: boolean;
// Explicit choice via `--skip-reencrypt-on-key-change` or the env var wins
// over everything, regardless of interactivity.
const explicitSkip =
opts?.skipReencrypt ||
(process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE ?? "").toLowerCase() ===
"true";
if (explicitSkip) {
reencrypt = false;
log.info(
"Workspace encryption key changed; leaving remote ciphertexts untouched (skip re-encryption requested)."
);
} else if (opts?.noninteractive) {
// No TTY (or --yes) and no explicit skip: we can't prompt, so default to
// re-encrypting (matches the interactive default) to preserve secret
// values. Pass --skip-reencrypt-on-key-change (or set
// WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true) to opt out.
reencrypt = true;
log.info(
"Workspace encryption key changed; re-encrypting all remote secrets with the new key (non-interactive)."
);
} else {
reencrypt = await Confirm.prompt({
message:
"The local workspace encryption key does not match the remote. Do you want to reencrypt all your secrets on the remote with the new key?\nSay 'no' if your local secrets are already encrypted with the new key (e.g. workspace/instance migration)\nOtherwise, say 'yes' and pull the secrets after the reencryption.\n",
default: true,
});
}
log.debug(`Updating workspace encryption key...`);
await wmill.setWorkspaceEncryptionKey({
workspace,
requestBody: {
new_key: localKey,
skip_reencrypt: !confirm,
skip_reencrypt: !reencrypt,
},
});
} else {
+1
View File
@@ -6608,6 +6608,7 @@ sync local with a remote workspaces or the opposite (push or pull)
- \`--include-groups\` - Include syncing groups
- \`--include-settings\` - Include syncing workspace settings
- \`--include-key\` - Include workspace encryption key
- \`--skip-reencrypt-on-key-change\` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt.
- \`--skip-branch-validation\` - Skip git branch validation and prompts
- \`--json-output\` - Output results in JSON format
- \`-i --includes <patterns:file[]>\` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)
+8 -2
View File
@@ -18,7 +18,11 @@ import { pushSchedule } from "./commands/schedule/schedule.ts";
import { pushWorkspaceUser } from "./commands/user/user.ts";
import { pushGroup } from "./commands/user/user.ts";
import { pushWorkspaceDependencies } from "./commands/dependencies/dependencies.ts";
import { pushWorkspaceSettings, pushWorkspaceKey } from "./core/settings.ts";
import {
pushWorkspaceSettings,
pushWorkspaceKey,
PushWorkspaceKeyOptions,
} from "./core/settings.ts";
import { pushTrigger, pushNativeTrigger } from "./commands/trigger/trigger.ts";
import { pushRawApp } from "./commands/app/raw_apps.ts";
import type { PermissionedAsContext } from "./core/permissioned_as.ts";
@@ -179,6 +183,7 @@ function redactString(s: string): string {
* @param alreadySynced - Array to track already synced items
* @param message - Optional commit/update message
* @param originalLocalPath - The original local file path (used for branch-specific resource file resolution)
* @param keyPushOpts - Options for the encryption_key push: non-interactive flag and explicit re-encryption choice
*/
export async function pushObj(
workspace: string,
@@ -191,6 +196,7 @@ export async function pushObj(
originalLocalPath?: string,
permissionedAsContext?: PermissionedAsContext,
wsSpecific?: boolean,
keyPushOpts?: PushWorkspaceKeyOptions,
) {
const typeEnding = getTypeStrFromPath(p);
@@ -256,7 +262,7 @@ export async function pushObj(
} else if (typeEnding === "settings") {
await pushWorkspaceSettings(workspace, p, befObj, newObj);
} else if (typeEnding === "encryption_key") {
await pushWorkspaceKey(workspace, p, befObj, newObj);
await pushWorkspaceKey(workspace, p, befObj, newObj, keyPushOpts);
} else {
throw new Error(
`The item ${p} has an unrecognized type ending ${typeEnding}`
+93
View File
@@ -0,0 +1,93 @@
/**
* Unit tests for pushWorkspaceKey in settings.ts.
*
* Covers WIN-2005: changing the encryption key in encryption_key.yaml and
* pushing it must (by default) re-encrypt the remote secrets with the new key.
*
* Verifies that:
* - an unchanged key is a no-op (no setWorkspaceEncryptionKey call)
* - a changed key in non-interactive mode re-encrypts by default
* (skip_reencrypt = false), so secret plaintext values are preserved
* - the --skip-reencrypt-on-key-change flag keeps the remote ciphertexts
* untouched (skip_reencrypt = true)
* - WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true does the same via env var
*/
import { expect, test, describe, beforeEach, afterEach, mock } from "bun:test";
// Track calls to mocked wmill functions
let remoteKey = "";
let setEncryptionKeyCalls: {
workspace: string;
requestBody: { new_key: string; skip_reencrypt?: boolean };
}[] = [];
// Mock the wmill module before importing settings.ts
mock.module("../gen/services.gen.ts", () => ({
getWorkspaceEncryptionKey: async (_args: { workspace: string }) => ({
key: remoteKey,
}),
setWorkspaceEncryptionKey: async (args: {
workspace: string;
requestBody: { new_key: string; skip_reencrypt?: boolean };
}) => {
setEncryptionKeyCalls.push(args);
},
}));
import { pushWorkspaceKey } from "../src/core/settings.ts";
describe("pushWorkspaceKey", () => {
const ws = "test-workspace";
beforeEach(() => {
remoteKey = "";
setEncryptionKeyCalls = [];
delete process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE;
});
afterEach(() => {
delete process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE;
});
test("no-op when local key matches the remote key", async () => {
remoteKey = "samekey";
await pushWorkspaceKey(ws, "encryption_key", undefined, "samekey", {
noninteractive: true,
});
expect(setEncryptionKeyCalls.length).toBe(0);
});
test("changed key re-encrypts by default in non-interactive mode", async () => {
remoteKey = "oldkey";
await pushWorkspaceKey(ws, "encryption_key", undefined, "newkey", {
noninteractive: true,
});
expect(setEncryptionKeyCalls.length).toBe(1);
expect(setEncryptionKeyCalls[0].requestBody.new_key).toBe("newkey");
// skip_reencrypt false => backend re-encrypts existing secrets with new key
expect(setEncryptionKeyCalls[0].requestBody.skip_reencrypt).toBe(false);
});
test("--skip-reencrypt-on-key-change skips re-encryption", async () => {
remoteKey = "oldkey";
await pushWorkspaceKey(ws, "encryption_key", undefined, "newkey", {
noninteractive: true,
skipReencrypt: true,
});
expect(setEncryptionKeyCalls.length).toBe(1);
expect(setEncryptionKeyCalls[0].requestBody.new_key).toBe("newkey");
expect(setEncryptionKeyCalls[0].requestBody.skip_reencrypt).toBe(true);
});
test("WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true skips re-encryption non-interactively", async () => {
remoteKey = "oldkey";
process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE = "true";
await pushWorkspaceKey(ws, "encryption_key", undefined, "newkey", {
noninteractive: true,
});
expect(setEncryptionKeyCalls.length).toBe(1);
expect(setEncryptionKeyCalls[0].requestBody.new_key).toBe("newkey");
expect(setEncryptionKeyCalls[0].requestBody.skip_reencrypt).toBe(true);
});
});
@@ -583,6 +583,7 @@ sync local with a remote workspaces or the opposite (push or pull)
- `--include-groups` - Include syncing groups
- `--include-settings` - Include syncing workspace settings
- `--include-key` - Include workspace encryption key
- `--skip-reencrypt-on-key-change` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt.
- `--skip-branch-validation` - Skip git branch validation and prompts
- `--json-output` - Output results in JSON format
- `-i --includes <patterns:file[]>` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)
+1
View File
@@ -3133,6 +3133,7 @@ sync local with a remote workspaces or the opposite (push or pull)
- \`--include-groups\` - Include syncing groups
- \`--include-settings\` - Include syncing workspace settings
- \`--include-key\` - Include workspace encryption key
- \`--skip-reencrypt-on-key-change\` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt.
- \`--skip-branch-validation\` - Skip git branch validation and prompts
- \`--json-output\` - Output results in JSON format
- \`-i --includes <patterns:file[]>\` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)
@@ -588,6 +588,7 @@ sync local with a remote workspaces or the opposite (push or pull)
- `--include-groups` - Include syncing groups
- `--include-settings` - Include syncing workspace settings
- `--include-key` - Include workspace encryption key
- `--skip-reencrypt-on-key-change` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt.
- `--skip-branch-validation` - Skip git branch validation and prompts
- `--json-output` - Output results in JSON format
- `-i --includes <patterns:file[]>` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)