Files
windmill/frontend/src/lib/components/VariableForm.svelte
T
GuilhemandClaude Opus 5 eb238e3f0b fix: stop the AI chat destroying secret variables on edit (#10616)
* fix: stop the AI chat destroying secret variables on edit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: clear stale staged secret values and state the draft-staging rule

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: condense the pending-secret invariant to its field

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse empty and oauth-managed secret values, keep drawer-staged ones in the draft

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve a variable deploy's secret from one draft snapshot

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: make the variable draft the single source of a staged secret

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: drop stale in-memory secret invariants from comments and the eval

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop null account/expires_at leaking into variable drafts and diffs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: report when a variable deploy leaves the secret value unchanged

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: scope the variable-value readability claims to the chat

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct the secret-draft invariant in the diff masking comment

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record why a non-secret value is resent on a partial update

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop "Load secret value" discarding a staged secret

The audit-logged load writes the deployed secret into the draft row the
variable drawer shares with the AI chat, so offering it while that row
already stages a value silently replaces it — and the deploy that follows
carries the old value with no sign the staged one was lost.

The gate that hid the action already existed but keyed on
`isEncryptedDraftValue`, which only holds once a draft has round-tripped
through the server. A value staged in the same tab is still plaintext, so
it slipped through. Key on "anything staged" instead; clearing stays
explicit via Reset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: extend the variable draft's empty-value sentinel past secrets

Two gaps in the chat's variable write path, both from treating "the draft
cannot carry this value" as meaning only "the value is secret".

`variableToDraftState` drops the value of an OAuth-managed variable so a
refreshed live token is never pinned into a draft, leaving '' behind. The
deploy body resent that '' verbatim for a non-secret one, wiping the token
the refresh flow owns. The sentinel now covers every value the draft is not
allowed to hold, which also removes the divergence from
`VariableEditor.save` and the shared deployer.

Making a variable secret when it holds no value produced a secret draft
staging '', a deploy body with no `value`, and the backend's "cannot change
is_secret without updating value too" — the sibling create path already
answers that case with guidance, so answer it here too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate the Secret toggle's secret load on the staged value too

The toggle calls `onLoadSecret` on every change so an is_secret flip has a
value to send, but that load overwrites the shared draft row — the same
discard the button gate just closed, reached by a different control.

It now loads only when the row stages nothing, which is exactly when the
flip needs a value fetched. With a value already staged there is one to
send, and it is the one the user or the chat put there.

Blocking the load costs the side effect that used to mask a worse bug: for
a deployed variable, the load replaced an `$encrypted:` marker with real
plaintext before save. Without it, un-securing a marker would store the
marker string as the value, since the deploy endpoints only decrypt it while
is_secret stays true. So the toggle is disabled outright while a marker is
staged — Reset first. That closes the marker case for draft-only variables
as well, where no load could ever have masked it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 13:13:41 +02:00

207 lines
6.7 KiB
Svelte

<script lang="ts">
import Path from './Path.svelte'
import LabelsInput from './LabelsInput.svelte'
import Toggle from './Toggle.svelte'
import Alert from './common/alert/Alert.svelte'
import { Button } from './common'
import Tooltip from './Tooltip.svelte'
import Label from './Label.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { Loader2, RotateCcw } from 'lucide-svelte'
import autosize from '$lib/autosize'
import { userStore, workspaceStore } from '$lib/stores'
import { isOwner } from '$lib/utils'
import { isEncryptedDraftValue } from '$lib/encryptedDraft'
import EncryptedDraftField from './EncryptedDraftField.svelte'
interface Variable {
value: string
is_secret: boolean
description: string
}
interface Props {
path: string
initialPath: string
pathError: string
variable: Variable
labels: string[] | undefined
wsSpecific: boolean
deployTo: string | undefined
can_write: boolean
edit: boolean
onLoadSecret?: () => void
/** Workspace the path is validated against; defaults to the nav workspace. */
workspace?: string | undefined
}
let {
path = $bindable(),
initialPath,
pathError = $bindable(),
variable = $bindable(),
labels = $bindable(),
wsSpecific = $bindable(),
deployTo,
can_write,
edit,
onLoadSecret,
workspace = undefined
}: Props = $props()
let ws = $derived(workspace ?? $workspaceStore)
// Loading the deployed secret overwrites the draft row this form shares with the AI
// chat, so every path that would trigger it has to be blocked while that row stages a
// value — otherwise the staged one is replaced and the next deploy carries the old one.
// '' is the sentinel for "stages nothing", matching the deploy bodies.
let hasStagedValue = $derived(variable.value !== '')
const MAX_VARIABLE_LENGTH = 10000
let editorKind: 'plain' | 'json' | 'yaml' = $state('plain')
let editor: any = $state(undefined)
export function setCode(value: string) {
editor?.setCode(value)
}
</script>
<div class="flex flex-col gap-1">
<label for="path" class="text-xs font-semibold text-emphasis">Path</label>
<Path
disabled={initialPath != '' && !isOwner(initialPath, $userStore, ws)}
bind:error={pathError}
bind:path
{initialPath}
namePlaceholder="variable"
kind="variable"
workspaceOverride={workspace}
/>
<LabelsInput bind:labels />
</div>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Secret</span>
<!-- An `$encrypted:` value is only redeemable while the variable stays secret — the
deploy endpoints decrypt the marker inside their `is_secret` branch and store it
verbatim otherwise — so un-securing one has to be unreachable until it is Reset. -->
<Toggle
on:change={() => edit && !hasStagedValue && onLoadSecret?.()}
bind:checked={variable.is_secret}
disabled={edit && ($userStore?.operator || isEncryptedDraftValue(variable.value))}
/>
{#if variable.is_secret}
<Alert type="info" title="Audit log for each access">
Every secret is encrypted at rest and in transit with a key specific to this workspace. In
addition, any read of a secret variable generates an audit log whose operation name is:
variables.decrypt_secret
</Alert>
{/if}
</label>
{#if deployTo}
<Label
label="Workspace specific"
tooltip="Prevents this variable from being deployed to prod/staging. May have been enabled automatically because a workspace-specific resource references this variable via $var:. Disabling this toggle does not retroactively un-mark the resource that referenced it."
>
<Toggle bind:checked={wsSpecific} />
</Label>
{/if}
<div class="flex flex-col gap-1">
<label for="variable-value" class="flex flex-row justify-left items-center">
<span class="text-xs font-semibold text-emphasis">Variable value&nbsp;</span>
{#if !isEncryptedDraftValue(variable.value)}
<span class="text-xs text-secondary font-normal">
({variable.value.length}/{MAX_VARIABLE_LENGTH} characters)
</span>
{/if}
{#if edit && variable.is_secret}
<div class="ml-3"></div>
{#if hasStagedValue}
<!-- Clearing the staged value is the only way back to loading the deployed one;
see `hasStagedValue`. An `$encrypted:` value additionally cannot be displayed. -->
<Button
size="xs"
variant="default"
startIcon={{ icon: RotateCcw }}
disabled={!can_write}
on:click={() => (variable.value = '')}
>
Reset
</Button>
{:else if $userStore?.operator}
<div class="p-2 border">Operators cannot load secret value</div>
{:else}
<Button size="xs" variant="default" on:click={() => onLoadSecret?.()}>
Load secret value<Tooltip>Will generate an audit log</Tooltip>
</Button>
{/if}
{/if}
</label>
<div>
{#if isEncryptedDraftValue(variable.value)}
<!-- The draft's secret value was encrypted server-side and can't be
loaded back. Saving deploys the last saved value as-is; Reset clears
it so a new secret can be typed. -->
<EncryptedDraftField disabled={!can_write} onReset={() => (variable.value = '')} />
{:else}
<div class="flex flex-col gap-2">
<ToggleButtonGroup bind:selected={editorKind}>
{#snippet children({ item })}
<ToggleButton value="plain" label="Plain" {item} />
<ToggleButton value="json" label="Json" {item} />
<ToggleButton value="yaml" label="YAML" {item} />
{/snippet}
</ToggleButtonGroup>
{#if editorKind == 'plain'}
<textarea
disabled={!can_write}
rows="4"
use:autosize
bind:value={variable.value}
placeholder="Update variable value"
id="variable-value"
></textarea>
{:else if editorKind == 'json'}
<div class="border rounded mb-4 w-full">
{#await import('$lib/components/SimpleEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
bind:this={editor}
autoHeight
lang="json"
bind:code={variable.value}
fixedOverflowWidgets={false}
class="bg-surface-tertiary"
/>
{/await}
</div>
{:else if editorKind == 'yaml'}
<div class="border rounded mb-4 w-full">
{#await import('$lib/components/SimpleEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
bind:this={editor}
autoHeight
lang="yaml"
bind:code={variable.value}
fixedOverflowWidgets={false}
class="bg-surface-tertiary"
/>
{/await}
</div>
{/if}
</div>
{/if}
</div>
</div>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Description</span>
<textarea rows="4" use:autosize bind:value={variable.description} placeholder="Used for X"
></textarea>
</label>