feat: support sensitive/secret fields for non-string types (#8635)

* feat: support sensitive/secret fields for non-string types

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

* refactor: restrict sensitive toggle to object type, move after showExpr

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

* fix: show sensitive toggle in PropertyEditor at bottom, after children

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

* refactor: gate sensitive toggle with showSensitiveToggle prop

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

* fix: process secret args in flow test and script test paths

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

* refactor: inline SecretArgInput into ArgInput, delete component

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

* fix: address CI review feedback

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

* fix: pass showSensitiveToggle to flow input schema editors

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

* fix: use explicit prop syntax to satisfy svelte-check

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

* fix: narrow try/catch to only processSecretArgs

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-03-31 15:13:23 +00:00
committed by GitHub
parent 52a04d210f
commit 375fb66abe
14 changed files with 162 additions and 17 deletions
+10
View File
@@ -575,6 +575,16 @@ pub async fn transform_json_value(
.await?;
Ok(Value::String(v))
}
Value::String(y) if y.starts_with("$jsonvar:") => {
let path = y.strip_prefix("$jsonvar:").unwrap();
let v =
crate::variables::get_value_internal(&db_with_opt_authed, workspace, path, false)
.await?;
serde_json::from_str::<Value>(&v).map_err(|e| {
Error::internal_err(format!("Failed to parse $jsonvar value as JSON: {e}"))
})
}
Value::String(y) if y.starts_with("$res:") => {
let path = y.strip_prefix("$res:").unwrap();
if path.split("/").count() < 2 {
+10 -1
View File
@@ -145,7 +145,7 @@ pub async fn write_file_binary(dir: &str, path: &str, content: &[u8]) -> error::
}
lazy_static::lazy_static! {
static ref RE_RES_VAR: Regex = Regex::new(r#"\$(?:var|res|encrypted)\:"#).unwrap();
static ref RE_RES_VAR: Regex = Regex::new(r#"\$(?:var|jsonvar|res|encrypted)\:"#).unwrap();
}
pub async fn transform_json<'a>(
@@ -255,6 +255,15 @@ pub async fn transform_json_value(
Error::NotFound(format!("Variable {path} not found for `{name}`: {e:#}"))
})
}
Value::String(y) if y.starts_with("$jsonvar:") => {
let path = y.strip_prefix("$jsonvar:").unwrap();
let v = client.get_variable_value(path).await.map_err(|e| {
Error::NotFound(format!("Variable {path} not found for `{name}`: {e:#}"))
})?;
serde_json::from_str::<serde_json::Value>(&v).map_err(|e| {
Error::internal_err(format!("Failed to parse $jsonvar value as JSON: {e}"))
})
}
Value::String(y) if y.starts_with("$res:") => {
let path = y.strip_prefix("$res:").unwrap();
+9 -1
View File
@@ -74,7 +74,15 @@
<button
class="text-xs text-accent"
onclick={async () => {
await getVariable(value.substring('$res:'.length))
await getVariable(value.substring('$var:'.length))
jsonViewer?.toggleDrawer()
}}>{value}</button
>
{:else if isString(value) && value.startsWith('$jsonvar:')}
<button
class="text-xs text-accent"
onclick={async () => {
await getVariable(value.substring('$jsonvar:'.length))
jsonViewer?.toggleDrawer()
}}>{value}</button
>
@@ -495,6 +495,8 @@
let { debounced, clearDebounce } = debounce(() => compareValues(value), 50)
let inputCat = $derived(computeInputCat(type, format, itemsType?.type, enum_, contentEncoding))
let isNonStringSecret = $derived((password || extra?.['password'] == true) && type === 'object')
let displayJsonToggleHeader = $derived(
displayHeader &&
inputCat === 'list' &&
@@ -558,6 +560,12 @@
class="text-accent underline font-normal"
onclick={() => variableEditor?.editVariable?.(value.slice(5))}>{value.slice(5)}</button
>
{:else if value && typeof value == 'string' && value?.startsWith('$jsonvar:')}
Linked to variable <button
class="text-accent underline font-normal"
onclick={() => variableEditor?.editVariable?.(value.slice('$jsonvar:'.length))}
>{value.slice('$jsonvar:'.length)}</button
>
{/if}
</div>
{/if}
@@ -1488,6 +1496,18 @@
{@render actions?.()}
</div>
{#if isNonStringSecret}
{#if typeof value === 'string' && value.startsWith('$jsonvar:')}
<div class="text-2xs text-tertiary">
Sensitive — stored as secret: <code class="text-2xs">{value.slice('$jsonvar:'.length)}</code
>
</div>
{:else}
<div class="text-2xs text-tertiary italic">Sensitive — will be stored as secret on submit</div
>
{/if}
{/if}
{#if !compact || (error && error != '')}
<div class="text-right text-xs leading-3 text-red-600 dark:text-red-400 mb-2">
{#if disabled || error === ''}
@@ -51,6 +51,7 @@
noPreview?: boolean
jsonEnabled?: boolean
isAppInput?: boolean
showSensitiveToggle?: boolean
displayWebhookWarning?: boolean
onlyMaskPassword?: boolean
editTab:
@@ -95,6 +96,7 @@
noPreview = false,
jsonEnabled = true,
isAppInput = false,
showSensitiveToggle = false,
displayWebhookWarning = false,
onlyMaskPassword = false,
editTab,
@@ -297,7 +299,9 @@
}
const editTabDefaultSize = untrack(() => noPreview) ? 100 : 50
editPanelSize = untrack(() => editTab) ? (untrack(() => editPanelInitialSize) ?? editTabDefaultSize) : 0
editPanelSize = untrack(() => editTab)
? (untrack(() => editPanelInitialSize) ?? editTabDefaultSize)
: 0
let inputPanelSize = $state(100 - editPanelSize)
let editPanelSizeSmooth = tweened(editPanelSize, {
duration: 150
@@ -677,6 +681,7 @@
bind:order={schema.properties[argName].order}
{isFlowInput}
{isAppInput}
{showSensitiveToggle}
>
{#snippet typeeditor()}
{#if isFlowInput || isAppInput}
@@ -11,6 +11,7 @@
import { createEventDispatcher, getContext, untrack } from 'svelte'
import type { FlowEditorContext } from './flows/types'
import { runFlowPreview } from './flows/utils.svelte'
import { processSecretArgs } from './secretArgUtils'
import SchemaForm from './SchemaForm.svelte'
import SchemaFormWithArgPicker from './SchemaFormWithArgPicker.svelte'
import FlowStatusViewer from '../components/FlowStatusViewer.svelte'
@@ -171,6 +172,7 @@
lastPreviewFlow = JSON.stringify(flowStore.val)
flowProgressBar?.reset()
const newFlow = extractFlow(previewMode)
args = await processSecretArgs(args, flowStore.val.schema as any)
newJobId = await runFlowPreview(args, newFlow, $pathStore, restartedFrom, conversationId)
jobId = newJobId
isRunning = true
+19 -5
View File
@@ -3,7 +3,8 @@
computeSharableHash as computeSharableHash,
defaultIfEmptyString,
emptyString,
truncateHash
truncateHash,
sendUserToast
} from '$lib/utils'
import type { Schema } from '$lib/common'
@@ -21,6 +22,7 @@
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import InputSelectedBadge from './schema/InputSelectedBadge.svelte'
import { untrack } from 'svelte'
import { processSecretArgs } from './secretArgUtils'
let reloadArgs = $state(0)
let jsonEditor: JsonInputs | undefined = $state(undefined)
@@ -33,8 +35,20 @@
reloadArgs++
}
export function run() {
runAction(scheduledForStr, args ?? {}, invisible_to_owner, overrideTag)
export async function run(overrideScheduledForStr?: string | undefined | null) {
let processedArgs: Record<string, any>
try {
processedArgs = await processSecretArgs(args ?? {}, runnable?.schema)
} catch (e) {
sendUserToast('Failed to process sensitive args: ' + e, true)
return
}
runAction(
overrideScheduledForStr === null ? undefined : (overrideScheduledForStr ?? scheduledForStr),
processedArgs,
invisible_to_owner,
overrideTag
)
}
interface Props {
@@ -276,7 +290,7 @@
unifiedSize="md"
btnClasses="!inline-flex"
disabled={!isValid && !jsonView}
on:click={() => runAction(scheduledForStr, args ?? {}, invisible_to_owner, overrideTag)}
on:click={() => run()}
shortCut={{ Icon: CornerDownLeft, hide: !viewKeybinding }}
>
{scheduledForStr ? 'Schedule to run later' : buttonText}
@@ -315,7 +329,7 @@
btnClasses="!px-6 !py-1 w-full"
variant="accent"
disabled={!isValid && !jsonView}
on:click={() => runAction(undefined, args ?? {}, invisible_to_owner, overrideTag)}
on:click={() => run(null)}
shortCut={{ Icon: CornerDownLeft, hide: !viewKeybinding }}
>
{buttonText}
@@ -1,5 +1,6 @@
<script lang="ts">
import { buildWsUrl } from '$lib/wsUrl'
import { processSecretArgs } from './secretArgUtils'
import type { Schema, SupportedLanguage } from '$lib/common'
import {
type CompletedJob,
@@ -645,12 +646,14 @@
const testCode = activeModuleTab !== null ? editorCode : code
const testLang = activeModuleTab !== null ? effectiveLang : lang
const testArgs =
const rawTestArgs =
activeModuleTab !== null
? testPanelArgs
: selectedTab === 'preprocessor' || kind === 'preprocessor'
? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...(args ?? {}) }
: (args ?? {})
const testSchema = activeModuleTab !== null ? testPanelSchema : schema
const testArgs = await processSecretArgs(rawTestArgs, testSchema)
//@ts-ignore
let job = await jobLoader.runPreview(
@@ -12,4 +12,4 @@
let { schema = $bindable(), customUi = undefined }: Props = $props()
</script>
<EditableSchemaForm bind:schema uiOnly {customUi} editTab="inputEditor" />
<EditableSchemaForm bind:schema uiOnly {customUi} editTab="inputEditor" showSensitiveToggle />
@@ -686,6 +686,7 @@
bind:schema={flowStore.val.schema}
hiddenArgs={['user_message']}
isFlowInput
showSensitiveToggle
editTab={chatInputsEditTab ? 'inputEditor' : undefined}
showDynOpt
bind:dynCode
@@ -741,6 +742,7 @@
bind:this={editableSchemaForm}
bind:schema={flowStore.val.schema}
isFlowInput
showSensitiveToggle
on:delete={(e) => {
addPropertyV2?.handleDeleteArgument([e.detail])
}}
@@ -20,6 +20,7 @@
fullHeight = true,
formatExtension = $bindable(undefined),
isFileset = $bindable(undefined),
showSensitiveToggle = false,
customUi
}: EditableSchemaWrapperProps = $props()
@@ -113,6 +114,7 @@
bind:this={editableSchemaForm}
bind:schema
isFlowInput
{showSensitiveToggle}
on:delete={(e) => {
addPropertyComponent?.handleDeleteArgument([e.detail])
}}
@@ -162,9 +164,9 @@
</Alert>
{:else if formatExtension && formatExtension !== ''}
<Alert title={`Example: my_file.${formatExtension}`} type="info">
The <span class="font-bold font-mono"> .{formatExtension} </span> extension will be used to
infer the format when displaying the content and this is also how the resource will appear
when pulling via the CLI.
The <span class="font-bold font-mono"> .{formatExtension} </span> extension will be used to infer
the format when displaying the content and this is also how the resource will appear when pulling
via the CLI.
</Alert>
<div></div>
{/if}
@@ -175,10 +177,7 @@
path and contains text content. In the CLI, filesets are stored as directories.
</Alert>
{/if}
<ToggleButtonGroup
selected={resourceMode}
onSelected={(mode) => switchResourceMode(mode)}
>
<ToggleButtonGroup selected={resourceMode} onSelected={(mode) => switchResourceMode(mode)}>
{#snippet children({ item })}
<ToggleButton value="schema" label="JSON" {item} size="sm" />
<ToggleButton value="file" label="File" {item} size="sm" />
@@ -7,6 +7,7 @@
import NumberTypeNarrowing from '../NumberTypeNarrowing.svelte'
import StringTypeNarrowing from '../StringTypeNarrowing.svelte'
import Tooltip from '../Tooltip.svelte'
import Toggle from '../Toggle.svelte'
import EditableSchemaForm from '../EditableSchemaForm.svelte'
import { deepEqual } from 'fast-equals'
@@ -35,6 +36,7 @@
nonEmpty?: boolean | undefined
isFlowInput?: boolean
isAppInput?: boolean
showSensitiveToggle?: boolean
order?: string[] | undefined
itemsType?:
| {
@@ -66,6 +68,7 @@
properties = $bindable(),
isFlowInput = false,
isAppInput = false,
showSensitiveToggle = false,
order = $bindable(),
itemsType = $bindable(undefined),
typeeditor,
@@ -290,5 +293,25 @@
{/if}
{@render children?.()}
{#if type == 'object' && showSensitiveToggle}
<Toggle
size="xs"
options={{
right: 'Is sensitive',
rightTooltip:
'The value will be stored as an ephemeral secret variable in the user space of the caller of the job, only viewable by him.'
}}
checked={extra['password'] ?? false}
on:change={(e) => {
if (e.detail) {
extra['password'] = true
} else {
extra['password'] = undefined
}
dispatch('change')
}}
/>
{/if}
</div>
</div>
@@ -7,6 +7,7 @@ export type EditableSchemaWrapperProps = {
fullHeight?: boolean
formatExtension?: string | undefined
isFileset?: boolean | undefined
showSensitiveToggle?: boolean
customUi?: {
noAddPopover?: boolean
}
@@ -0,0 +1,49 @@
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'
/**
* Process args before job submission: for non-string fields marked as password/sensitive,
* create ephemeral secret variables and replace values with $jsonvar:path references.
* String password fields are already handled by PasswordArgInput (uses $var:).
*/
export async function processSecretArgs(
args: Record<string, any>,
schema: Schema | undefined
): Promise<Record<string, any>> {
if (!schema?.properties) return args
const workspace = get(workspaceStore)
const user = get(userStore)
if (!workspace || !user) return args
const username = (user.username ?? user.email)?.split('@')[0]
if (!username) return args
const userPrefix = `u/${username}/secret_arg/`
const result = { ...args }
for (const [key, prop] of Object.entries(schema.properties)) {
if (!prop.password) continue
if (prop.type !== 'object') continue // only object types; strings handled by PasswordArgInput
if (result[key] == null || result[key] === undefined) continue
if (typeof result[key] === 'string' && result[key].startsWith('$jsonvar:')) continue // already processed
const path = userPrefix + generateRandomString(12)
await VariableService.createVariable({
workspace,
requestBody: {
value: JSON.stringify(result[key]),
is_secret: true,
path,
description: 'Ephemeral secret variable',
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString()
}
})
result[key] = '$jsonvar:' + path
}
return result
}