Files
windmill/frontend/src/lib/components/secretArgUtils.ts
T
AlexRV12andClaude Opus 5 a6abf2c8a7 feat: run and test scripts from the AI chat through an argument form (#11001)
* fix: disable a dynamic input when its schema field is disabled

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* refactor: extract the run form's argument hygiene into job_args

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: give Tabs an opt-in sliding selection indicator

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* refactor: share the chat's scroll-fade measurement

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: give the chat a run-form contract and incremental job output

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: run and test a script from the chat through an argument form

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: carry a chat run's card and job across saves and reloads

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: render a chat run as a tool call row with its form, logs and result

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: open a pending run form in the sessions preview pane

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* test: benchmark running a deployed script from the chat

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERed9zo2oJjpSczzMayaNh

* feat: offer a test run's dynamic options from the draft it previews

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say that a test run's dynselect helper executes on form display

* fix: send a schema default the model omitted when yolo skips the form

* fix: infer a test run's schema when the stored one declares no properties

* docs: tighten the note on the form's mount-time helper job

* fix: apply a nested schema default the bypass posture counts as answered

* fix: apply a declared default to a null value and an optional nested field

* fix: check required fields inside a supplied optional object before bypassing

* fix: read required args as own properties before bypassing the form

* fix: stop the turn from the run form's action row in the preview panel

* refactor: drop the run-form prediction and share its secret minting

* refactor: prefill a proposed secret instead of emptying the field

* docs: correct the comments the run-form prediction left behind

* fix: keep a proposed secret out of the chat's stored messages

* docs: say what a literal secret argument now does

* test: restore the copilotInfo export the aiStore mock omits

* docs: cut the run form's helper-script note to its constraints

* refactor: settle a run form from one entry and fetch a job's logs once

* fix: separate colliding secret paths, gate plan mode, keep polled logs

* fix: mint before the form opens, skip empty fields, show what ran

* revert: mint a run form's secrets at submit, not before it opens

* fix: settle a cancelled run card on the form's arguments, not the proposal

* fix: settle a stopped run form like a cancelled one, and keep an empty secret empty

* fix: snapshot a run's arguments before minting its secrets

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 12:31:58 +00:00

120 lines
4.6 KiB
TypeScript

import type { Schema } from '$lib/common'
import { VariableService } from '$lib/gen'
import { get } from 'svelte/store'
import { userStore, workspaceStore } from '$lib/stores'
import { generateRandomString } from '$lib/utils'
import { stateSnapshot } from '$lib/stateSnapshot.svelte'
import { isSecretProp, mapArgLeaves } from './job_args'
/** Where a caller's own ephemeral secrets live, so a field can tell one it minted from a
* workspace variable someone linked by hand. */
export function ephemeralSecretPrefix(username: string): string {
return `u/${username}/secret_arg/`
}
/**
* Mint the ephemeral secret variable a sensitive argument is submitted as, and return its path.
* It expires on its own, so a run that is abandoned leaves no permanent secret behind.
*/
export async function mintEphemeralSecret(
workspace: string,
username: string,
value: string
): Promise<string> {
const path = ephemeralSecretPrefix(username) + generateRandomString(12)
await VariableService.createVariable({
workspace,
requestBody: {
value,
is_secret: true,
path,
description: 'Ephemeral secret variable',
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString()
}
})
return path
}
/** `$var:` hands the job the variable's text and `$jsonvar:` hands it the parsed value, so a
* field that cannot hold a string needs the second one whichever the caller named. */
function referencePrefix(prop: any, value: unknown): '$var:' | '$jsonvar:' {
return typeof value === 'string' && prop?.type !== 'object' && prop?.type !== 'array'
? '$var:'
: '$jsonvar:'
}
/**
* Turn every sensitive argument into a reference before the job is submitted: a plaintext value
* is minted into an ephemeral secret variable, so what is stored on the job — and readable by
* anyone who can see its run — names a secret instead of holding one.
*
* The single place that decides how a secret reaches a job: {@link PasswordArgInput} mints
* through it while the user types, and a run the autonomy posture starts without a form calls it
* in the widget's stead.
*/
export async function processSecretArgs(
args: Record<string, any>,
schema: Schema | undefined,
// Workspace the ephemeral secret variable is created in — must match the
// workspace the preview job runs in, else $jsonvar: resolves to a missing var.
forceWorkspace?: string
): Promise<Record<string, any>> {
if (!schema?.properties) return args
const workspace = forceWorkspace ?? get(workspaceStore)
const user = get(userStore)
if (!workspace || !user) return args
const username = (user.username ?? user.email)?.split('@')[0]
if (!username) return args
// Detached from the caller: every one binds a form that stays editable across the awaits
// below, and the two walks address a leaf by its path — an array reordered between them
// would hand a row the reference minted for another row's secret.
args = stateSnapshot(args)
// A value that already names a variable is one; anything else is the secret itself. An empty
// field holds nothing to mint, and ArgInput synthesises '' for every untouched string.
const holdsSecret = (value: unknown) =>
value != null &&
value !== '' &&
!(
typeof value === 'string' &&
(value.startsWith('$var:') || value.startsWith('$jsonvar:') || value.startsWith('$res:'))
)
// Collected first and substituted after, because the walk is synchronous and minting is not.
// Keyed by the whole path the walk reports, which is what tells two same-named leaves apart.
const pending: { key: string; prop: any; value: unknown }[] = []
mapArgLeaves(args, schema as any, isSecretProp, (value, prop, path) => {
if (holdsSecret(value)) pending.push({ key: JSON.stringify(path), prop, value })
return value
})
const minted = new Map<string, string>()
for (const { key, prop, value } of pending) {
const reference = referencePrefix(prop, value)
const variable = await mintEphemeralSecret(
workspace,
username,
reference === '$var:' ? String(value) : JSON.stringify(value)
)
minted.set(key, reference + variable)
}
return mapArgLeaves(args, schema as any, isSecretProp, (value, prop, path) => {
const replacement = minted.get(JSON.stringify(path))
if (replacement !== undefined) return replacement
// A plain variable named for a field that cannot hold a string: the caller meant that
// variable's contents, which is the same secret read the way the field needs it.
if (
typeof value === 'string' &&
value.startsWith('$var:') &&
referencePrefix(prop, value) === '$jsonvar:'
) {
return '$jsonvar:' + value.slice('$var:'.length)
}
return value
})
}