From 465c15aa6b9ec4bb92b2cc5a2a45157fe6ef6f16 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Thu, 1 Oct 2026 09:23:34 -0700 Subject: [PATCH] fix(sessions): mint a chat flow's secret arguments before its run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../lib/components/details/FlowDetail.svelte | 14 ++++++++++++- .../flows/conversations/FlowChat.svelte | 20 +++++++++++++++---- .../sessions/sessionRuntime.svelte.ts | 16 ++++++++++++++- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index bae6e9f314..c43522e1b4 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -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 ): Promise { + // 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 diff --git a/frontend/src/lib/components/flows/conversations/FlowChat.svelte b/frontend/src/lib/components/flows/conversations/FlowChat.svelte index 9d4b0e7b94..ed9e72cfa5 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChat.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChat.svelte @@ -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): 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 diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 73f0e90e18..ac25e031ea 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -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)