fix(sessions): mint a chat flow's secret arguments before its run

A chat flow's inputs reach the job straight from the composer, with no RunForm
between them to turn a password-marked value into a reference as it is typed.
The handover put the proposed arguments there directly, so a literal secret was
stored in the job's arguments, readable by anyone who can read the run — the
form path had always minted it first. runFlowForChat now does, which covers the
reader's own typed inputs as well as a proposal's.

Also from round 2:
- a viewer pinned to an older version kept that pin when the handover merely
  focused it, so Run executed a version the arguments were not prepared against
- the proposal now decides every input the schema declares, including the ones
  it omits: merging let a value saved on an earlier visit ride along in a run
  the chat announced without it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-01 09:23:34 -07:00
co-authored by Claude Opus 5
parent 3d80243963
commit 465c15aa6b
3 changed files with 44 additions and 6 deletions
@@ -27,6 +27,8 @@
import { Badge as HeaderBadge, Alert } from '$lib/components/common'
import MoveDrawer from '$lib/components/MoveDrawer.svelte'
import RunForm from '$lib/components/RunForm.svelte'
import { processSecretArgs } from '$lib/components/secretArgUtils'
import type { Schema } from '$lib/common'
import ShareModal from '$lib/components/ShareModal.svelte'
import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { useOperatorBuilderFlows } from '$lib/operatorWriteRights'
@@ -323,11 +325,21 @@
conversationId: string,
additionalInputs?: Record<string, any>
): Promise<string> {
// A chat flow's inputs reach the job straight from the composer, with no RunForm in
// between to mint a secret as it is typed — so this is the only place a value the
// schema marks `password` can become a reference. Without it the literal is stored in
// the job's arguments, where anyone who can read the run can read it. Idempotent, so a
// reference that was already minted costs a walk and no round trip.
const requestBody = await processSecretArgs(
{ user_message: userMessage, ...(additionalInputs ?? {}) },
flow?.schema as Schema | undefined,
workspace
)
const run = await JobService.runFlowByPath({
workspace: workspace!,
path,
memoryId: conversationId,
requestBody: { user_message: userMessage, ...(additionalInputs ?? {}) },
requestBody,
skipPreprocessor: true
})
return run
@@ -191,12 +191,24 @@
return panel?.host.offerMessage(text) ?? false
}
/** Set the flow's non-message inputs for the run a chat tool proposed. Separate from the
/** Set the flow's non-message inputs to the run a chat tool proposed. Separate from the
* message because the two can land apart: a composer the reader is typing in declines the
* message, and the inputs still belong to the proposal they can send afterwards. */
* message, and the inputs still belong to the proposal they can send afterwards.
*
* Every input the schema declares is taken from the proposal, including the ones it leaves
* out — those go back to their default. Merging instead would let a value the reader saved
* on an earlier visit ride along in a run the agent announced without it, which is a
* different run from the one the chat described. Inputs the composer owns rather than the
* schema (its model wiring, its attachments) are not the proposal's to touch. */
export function applyInputs(inputs: Record<string, any>): void {
if (Object.keys(inputs).length === 0) return
inputValues = { ...inputValues, ...inputs }
const declared = Object.keys(additionalInputsSchema?.properties ?? {})
if (declared.length === 0) return
const next = { ...inputValues }
for (const key of declared) {
if (key in inputs) next[key] = inputs[key]
else delete next[key]
}
inputValues = next
}
/** Whether the conversation on screen has a composer to offer a message to. A caller that
@@ -624,7 +624,21 @@ function createRuntime(session: Session): SessionRuntime {
const target = previewTargetForSessionTarget(kind, path)
if (target?.type !== 'item') return false
seededRunArgs.set(seededKey(kind, path), { args, seq: ++seededRunSeq })
previewTabs.open({ ...target, item: { ...target.item, summary }, mode: 'view' })
const viewTarget = { ...target, item: { ...target.item, summary }, mode: 'view' as const }
// A viewer already open on an older version of this item keeps that pin when it is
// merely focused, and Run there executes that version — not the deployment these
// arguments were prepared against. Re-point it instead, which drops the pin.
const pinned = previewTabs.tabs.find((t) => {
const slot = resolvePreviewTab(t.url)
return (
slot.kind === 'viewer' &&
slot.viewerKind === kind &&
slot.path === path &&
t.url.includes('version=')
)
})
if (pinned) previewTabs.navigate(viewTarget, pinned.id)
else previewTabs.open(viewTarget)
return true
}
manager.closeRunForm = (toolCallId) => previewTabs.closeRunForm(toolCallId)