mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 00:05:27 +00:00
* 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>
95 lines
3.8 KiB
TypeScript
95 lines
3.8 KiB
TypeScript
import { readFileSync } from 'node:fs'
|
|
import { dirname, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
const TAGS = [
|
|
'SchemaFormDnd',
|
|
'SchemaForm',
|
|
'PasswordArgInput',
|
|
'ArgInput',
|
|
'FlowPropertyEditor',
|
|
'PropertyEditor',
|
|
'EditableSchemaForm',
|
|
'EditableSchemaDrawer',
|
|
'ArrayTypeNarrowing',
|
|
'InputTransformSchemaForm',
|
|
'InputTransformForm',
|
|
'ScriptSchema'
|
|
]
|
|
// `SchemaFormDnd` precedes `SchemaForm` so the longer tag is not matched as the shorter one.
|
|
const OPENING = new RegExp(`<(${TAGS.join('|')})(?=[\\s/>]|$)`, 'g')
|
|
|
|
function formMounts(source: string): { tag: string; line: number; block: string }[] {
|
|
const lines = source.split('\n')
|
|
const mounts: { tag: string; line: number; block: string }[] = []
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const open = lines[i].trim().match(new RegExp(`^${OPENING.source}`))
|
|
if (!open) continue
|
|
// Requiring a lone `>` would run past a mount whose last prop shares the closing line, into
|
|
// the next component, and read its `workspace` as this one's — a false pass on exactly the
|
|
// regression this guards.
|
|
let end = i
|
|
while (end < lines.length && !lines[end].trim().endsWith('>')) end++
|
|
// Running off the end means the props were never delimited, so the block would swallow the
|
|
// rest of the file and match any `workspace` in it — a false pass, not a failure.
|
|
if (end >= lines.length) {
|
|
throw new Error(`unterminated <${open[1]}> mount at line ${i + 1}`)
|
|
}
|
|
mounts.push({ tag: open[1], line: i + 1, block: lines.slice(i, end + 1).join('\n') })
|
|
i = end
|
|
}
|
|
return mounts
|
|
}
|
|
|
|
// `workspace={$workspaceStore}` and `workspace={undefined}` are the nav-workspace fallback this
|
|
// guards against, so presence of the prop is not enough.
|
|
function forwardsWorkspace(block: string): boolean {
|
|
const m = block.match(/\{workspace\}|workspace=\{([^{}]*)\}/)
|
|
if (!m) return false
|
|
const expr = m[1]?.trim()
|
|
return expr === undefined || (expr !== 'undefined' && expr !== '$workspaceStore')
|
|
}
|
|
|
|
function read(relPath: string): string {
|
|
return readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), relPath), 'utf-8')
|
|
}
|
|
|
|
// Every hop between the form a caller mounts and the PasswordArgInput that mints the secret, plus
|
|
// the entry points that supply the workspace in the first place. A hop that drops `workspace` falls
|
|
// back to the navigation workspace, so the secret lands where the job will not run — while the
|
|
// top-level case keeps passing.
|
|
describe.each([
|
|
['ArgInput.svelte', 7],
|
|
['schema/SchemaFormDND.svelte', 1],
|
|
['SchemaForm.svelte', 2],
|
|
['EditableSchemaForm.svelte', 3],
|
|
['schema/FlowPropertyEditor.svelte', 3],
|
|
['schema/EditableSchemaDrawer.svelte', 2],
|
|
['schema/PropertyEditor.svelte', 3],
|
|
['ArrayTypeNarrowing.svelte', 1],
|
|
['InputTransformSchemaForm.svelte', 1],
|
|
['InputTransformForm.svelte', 1],
|
|
['ScriptSchema.svelte', 1],
|
|
['ScriptBuilder.svelte', 1],
|
|
['flows/content/FlowInput.svelte', 2],
|
|
['flows/content/FlowModuleComponent.svelte', 1],
|
|
['flows/content/AgentToolBindings.svelte', 1],
|
|
['ModulePreviewForm.svelte', 1],
|
|
['dbt/DbtEditor.svelte', 1]
|
|
// `flows/content/FlowModuleSuspend.svelte` stays out: its two unthreaded mounts render the
|
|
// locally built `groups` schema and a preview whose args stay empty, so neither can mint.
|
|
])('%s nested forms', (relPath, minMounts) => {
|
|
it('forwards workspace to every nested form', () => {
|
|
const source = read(relPath)
|
|
const mounts = formMounts(source)
|
|
expect(mounts.length).toBeGreaterThanOrEqual(minMounts)
|
|
// The scan only recognises a mount opening its own line, so one written inline would be
|
|
// skipped and silently unguarded. Every opening tag in the file has to be accounted for.
|
|
expect(mounts.length).toBe(source.match(OPENING)?.length ?? 0)
|
|
expect(
|
|
mounts.filter((m) => !forwardsWorkspace(m.block)).map((m) => `${m.tag}:${m.line}`)
|
|
).toEqual([])
|
|
})
|
|
})
|