Files
windmill/frontend/src/lib/components/PasswordArgInput.svelte
T
AlexRV12andClaude Opus 5 93081e255f fix(frontend): mint string password secrets in the operating workspace (#10815)
* fix(frontend): mint password secrets in the operating workspace

A `password: true` string argument is rendered by PasswordArgInput, which
mints an ephemeral secret variable on the first keystroke and rebinds the
argument to `$var:<path>`. It minted into `$workspaceStore` — the globally
active navigation workspace.

Session editors operate on a different, possibly forked workspace without
switching `$workspaceStore`, and thread that operating workspace explicitly
as a `workspace` prop. When the two diverged the secret landed where the
user was merely looking while the job ran elsewhere, and the backend failed
with `Variable not found`.

Add the `workspace` prop to PasswordArgInput and thread it through every hop
between a form mount and the minting field, plus the entry points that supply
it. Track `mintedIn` so updates target where the variable actually lives, and
re-mint when the operating workspace moves after a path already exists.

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

* fix(frontend): keep a password field consistent with its argument

A parent can replace the whole args object without remounting this field —
previewing a saved input, say — leaving `path` and `password` describing a
secret the argument no longer points at. Minting from them then copies the
old plaintext over the replacement, and the replacement is lost.

State that rule once as `argReplaced` and gate every mint on it. The
replacement can also land while the create is in flight, so the bound value
is captured before the request and re-checked after it resolves; the variable
that mint produced was never referenced, so it is deleted outright. A mint
that ends without binding re-seeds `password` from what the argument now
holds, so the field stops displaying a secret that will not be submitted and
a later workspace move cannot re-mint the stale plaintext. `updateValue`
returns early before anything is minted, since its 404 retry would otherwise
bind over a replacement it cannot see.

A failed initial mint now raises a toast rather than passing silently, which
also removes the component's last unhandled rejection.

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

* test(frontend): guard workspace forwarding to PasswordArgInput

Every hop between the form a caller mounts and the PasswordArgInput that
mints the secret must forward `workspace`, and so must the entry points that
supply it. A hop that drops the prop falls back to the navigation workspace
while the top-level case keeps passing, and no typechecker catches it because
every hop declares `workspace?: string | undefined`.

The forwarded expression is checked rather than the prop's presence, so
`workspace={$workspaceStore}` and `workspace={undefined}` fail. Two ways the
scan could stop guarding without failing are asserted too: an unterminated
mount raises instead of swallowing the rest of the file, and the number of
mounts parsed must equal the number of tag occurrences, so a mount written
inline rather than at the start of a line fails loudly.

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

* fix(frontend): list and create variables in the operating workspace

* fix(frontend): surface and bound a failed recovery mint

* test(frontend): end a mount at the first line closing it

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 22:36:07 +02:00

161 lines
5.4 KiB
Svelte

<script lang="ts">
import { VariableService } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { generateRandomString } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
import { Button } from './common'
import Password from './Password.svelte'
import { untrack } from 'svelte'
interface Props {
value?: string | undefined
disabled: boolean
minRows?: number
/** Workspace the ephemeral secret is minted in; defaults to the nav workspace.
* Session editors pass their acting workspace. */
workspace?: string | undefined
}
let { value = $bindable(undefined), disabled, minRows, workspace }: Props = $props()
let ws = $derived(workspace ?? $workspaceStore)
let path = $state('')
// Workspace the variable at `path` actually lives in; `ws` can move away from it.
let mintedIn = $state<string | undefined>(undefined)
// What the field mints from: an argument already holding a `$var:` ref has nothing to mint.
function plaintextOf(v: unknown): string {
return typeof v === 'string' && v !== '' && !v.startsWith('$var:') ? v : ''
}
let password = $state(plaintextOf(value))
// The argument no longer holds what this field would mint from — a parent can replace the whole
// args object without remounting it (previewing a saved input, say). Minting now would describe a
// secret the argument does not point at, and binding it would discard the replacement.
let argReplaced = $derived(path !== '' && value !== '$var:' + path)
let isGenerating = false
let userPrefix = $derived(
'u/' + ($userStore?.username ?? $userStore?.email)?.split('@')[0] + '/secret_arg/'
)
async function generateValue() {
if (isGenerating || argReplaced) return
isGenerating = true
const mintWs = ws!
const boundBefore = value
try {
let npath = userPrefix + generateRandomString(12)
let nvalue = '$var:' + npath
await VariableService.createVariable({
workspace: mintWs,
requestBody: {
value: password,
is_secret: true,
path: npath,
description: 'Ephemeral secret variable',
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString()
}
})
// The arg can be replaced the same way while the create is in flight. Nothing ever
// referenced the variable just minted, so delete it; it expires on its own if that fails.
if (value !== boundBefore) {
VariableService.deleteVariable({ workspace: mintWs, path: npath }).catch(() => {})
return
}
path = npath
mintedIn = mintWs
console.log('generated', nvalue)
value = nvalue
debouncedUpdate()
} finally {
// Ended without binding: discarded just above, or the create failed after the argument
// moved. The field would otherwise keep showing a secret the argument does not hold, and
// the mint effect tracks `ws` — a workspace move would bind that stale plaintext over the
// replacement. Re-seeding leaves the field describing the argument again.
if (path === '' && value !== boundBefore) {
password = plaintextOf(value)
}
isGenerating = false
}
}
async function updateValue() {
// The first keystroke queues an update before anything is minted: letting it run would 404 and
// retry the mint, binding over an argument that was replaced while the first mint was in flight.
if (path === '') return
const updating = path
try {
await VariableService.updateVariable({
workspace: mintedIn ?? ws!,
path: path,
requestBody: {
value: password
}
})
} catch (e) {
// A re-mint can bind a fresh variable while this update is in flight; recovering then
// would orphan the one it just bound.
if (path !== updating) return
generateValue().catch((e) =>
sendUserToast(`Could not create the secret: ${e?.body ?? e?.message ?? e}`, true)
)
}
}
let timeout: number | undefined = undefined
function debouncedUpdate() {
timeout && clearTimeout(timeout)
timeout = setTimeout(updateValue, 500)
}
$effect(() => {
password && untrack(() => debouncedUpdate())
})
$effect(() => {
ws &&
($userStore?.username || $userStore?.email) &&
path == '' &&
password != '' &&
untrack(() =>
// A failed mint leaves the plaintext bound to nothing and the argument empty. Only a
// further keystroke re-runs this, so say so rather than submitting the job without it.
generateValue().catch((e) =>
sendUserToast(`Could not create the secret: ${e?.body ?? e?.message ?? e}`, true)
)
)
})
// The operating workspace can move after minting (a session forking, say), leaving the
// variable behind where the job will not find it: mint a fresh one in the new workspace.
// Bounded to a live instance: a field mounted onto an existing `$var:` holds neither the
// plaintext nor the workspace it was minted in, so it can only be moved by retyping it.
$effect(() => {
const cur = ws
if (!cur || path === '' || password === '' || mintedIn === cur || argReplaced) return
untrack(() =>
generateValue().catch((e) =>
sendUserToast(`Could not create the secret in ${cur}: ${e?.body ?? e?.message ?? e}`, true)
)
)
})
</script>
{#if value?.startsWith('$var:') && !value.startsWith('$var:' + userPrefix)}
<div class="flex items-center gap-2 text-sm text-primary">
Linked to static variable
<Button
size="xs"
variant="default"
onclick={() => {
value = ''
}}
>
Reset variable link
</Button>
</div>
{:else}
<Password {disabled} {minRows} bind:password />
{/if}