From 2ec77e349af61ef33d1da09c9e5dff7b2dba1bac Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:58:28 +0200 Subject: [PATCH] feat: run a deployed script from an AI session through an argument form Co-Authored-By: Claude Opus 5 (1M context) --- .../frontend/core/shared/baseEvalRunner.ts | 4 + .../adapters/frontend/vitestAdapter.test.ts | 13 ++ ai_evals/cases/global.yaml | 34 ++++ frontend/src/lib/components/RunForm.svelte | 38 +--- .../copilot/chat/AIChatDisplay.svelte | 9 + .../copilot/chat/AIChatManager.svelte.ts | 88 ++++++++- .../copilot/chat/AIChatManager.test.ts | 50 +++++ .../copilot/chat/RunArgsFormDisplay.svelte | 141 +++++++++++++ .../copilot/chat/ToolExecutionDisplay.svelte | 7 +- .../copilot/chat/global/core.test.ts | 186 ++++++++++++++++++ .../components/copilot/chat/global/core.ts | 144 +++++++++++++- .../components/copilot/chat/shared.test.ts | 41 ++++ .../src/lib/components/copilot/chat/shared.ts | 84 ++++++-- frontend/src/lib/components/job_args.test.ts | 14 ++ frontend/src/lib/components/job_args.ts | 104 ++++++++++ 15 files changed, 906 insertions(+), 51 deletions(-) create mode 100644 frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte create mode 100644 frontend/src/lib/components/job_args.test.ts diff --git a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts index 4501f24810..2e29b9064d 100644 --- a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts @@ -132,6 +132,10 @@ export async function runEval( setToolStatus: () => {}, removeToolStatus: () => {}, isPlanModeActive, + // Accepts the run form exactly as the model prefilled it: there is nobody here to + // edit the arguments, so a case can assert what the model proposed but never how + // it reacts to the user changing something. + requestRunArgs: async (_toolId, form) => form.args, onNewToken: (token: string) => { if (shouldEmitMessageStart) { onAssistantMessageStart?.(); diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index 338ed8504c..e5b275b86e 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -86,6 +86,7 @@ vi.mock('$lib/gen', async () => { previewBenchmarkSchedule, runBenchmarkDatatableSql, runBenchmarkFlowByPath, + runBenchmarkScriptByPath, runBenchmarkScriptPreview, updateBenchmarkDraft, listBenchmarkMcpTools @@ -279,6 +280,18 @@ vi.mock('$lib/gen', async () => { } return runBenchmarkScriptPreview({ workspace: data.workspace, requestBody }) }, + runScriptByPath: async (data: { + workspace: string + path: string + requestBody?: Record + }) => + hasBenchmarkWorkspace(data.workspace) + ? runBenchmarkScriptByPath({ + workspace: data.workspace, + path: data.path, + args: data.requestBody + }) + : actual.JobService.runScriptByPath(data), runFlowByPath: async (data: { workspace: string path: string diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 94ecb5c029..093fa4bef0 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -1974,6 +1974,40 @@ judgeChecklist: - deletes the deployed script via delete_workspace_item rather than a raw API endpoint +- id: global-test33-run-deployed-script-with-form + prompt: |- + Run the deployed script `f/evals/global/format_greeting` for me with the name "ada". + initial: ai_evals/fixtures/frontend/global/initial/format_greeting_script.json + runtime: + maxTurns: 8 + # run_script is offered to session chats only. + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - run_script + forbiddenToolsUsed: + - test_run_script + - call_api_endpoint + - write_script + - deploy_workspace_item + # run_script renders the deployed schema itself, so reading the item first is a + # wasted round-trip. + - read_workspace_item + # An empty form pushes the work back onto the user, so the prefill is part of + # what the tool is for. + toolCallArgs: + - tool: run_script + field: args.name + stringIncludesAnyOf: + - ada + # Running produces no draft, and the judge cannot observe runs; validate via tool use. + skipJudge: true + judgeChecklist: + - runs the deployed script through run_script rather than a preview test run or a raw API endpoint + - passes the name "ada" so the confirmation form comes up prefilled + - id: global-undo-created-draft prompt: |- Create a draft Postgres resource at `u/admin/scratch_db` for host db.example.com port 5432, database `orders`, user `app`, and tell me what fields it ended up with. diff --git a/frontend/src/lib/components/RunForm.svelte b/frontend/src/lib/components/RunForm.svelte index a371f3eea9..99ba0f45c7 100644 --- a/frontend/src/lib/components/RunForm.svelte +++ b/frontend/src/lib/components/RunForm.svelte @@ -24,6 +24,7 @@ import InputSelectedBadge from './schema/InputSelectedBadge.svelte' import { untrack } from 'svelte' import { processSecretArgs } from './secretArgUtils' + import { enforceDisabledDefaults } from './job_args' import PowerShellCommonParams from './PowerShellCommonParams.svelte' let reloadArgs = $state(0) @@ -60,11 +61,14 @@ export async function run(overrideScheduledForStr?: string | undefined | null) { let processedArgs: Record - try { - processedArgs = await processSecretArgs( - enforceDisabledDefaults(args ?? {}, true), - runnable?.schema + const { args: withDefaults, resetKeys } = enforceDisabledDefaults(args ?? {}, runnable?.schema) + if (resetKeys.length > 0) { + sendUserToast( + `Disabled field${resetKeys.length > 1 ? 's' : ''} ${resetKeys.map((k) => `'${k}'`).join(', ')} reset to default value${resetKeys.length > 1 ? 's' : ''}` ) + } + try { + processedArgs = await processSecretArgs(withDefaults, runnable?.schema) } catch (e) { sendUserToast('Failed to process sensitive args: ' + e, true) return @@ -178,30 +182,6 @@ } } - function enforceDisabledDefaults( - args: Record, - notify: boolean = false - ): Record { - const schema = runnable?.schema - if (!schema?.properties) return args - const result = { ...args } - const resetKeys: string[] = [] - for (const [key, prop] of Object.entries(schema.properties) as [string, any][]) { - if (prop?.disabled && 'default' in prop) { - if (notify && result[key] !== prop.default) { - resetKeys.push(key) - } - result[key] = prop.default - } - } - if (resetKeys.length > 0) { - sendUserToast( - `Disabled field${resetKeys.length > 1 ? 's' : ''} ${resetKeys.map((k) => `'${k}'`).join(', ')} reset to default value${resetKeys.length > 1 ? 's' : ''}` - ) - } - return result - } - /** Rewrite the open JSON editor from the current args. Only for args replaced from outside * the editor: entering the JSON view already starts from whatever `args` holds. */ export function syncJsonEditor() { @@ -322,7 +302,7 @@ bind:this={jsonEditor} on:select={(e) => { if (e.detail) { - args = enforceDisabledDefaults(e.detail) + args = enforceDisabledDefaults(e.detail, runnable?.schema).args } }} initialCode={argsToJsonPayload(runnable.schema, args)} diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index f610dd5ff4..a0f46cb650 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -224,6 +224,15 @@ const focusOnChat = !active || active === document.body || (panelEl?.contains(active) ?? false) if (!focusOnChat) return + // The run form parks the loop on the user, so an Escape aimed at one of its + // fields must not discard what they typed. Only the fields: from its buttons + // Escape still stops the turn, which is the way out while a submit is in flight. + if ( + active?.closest('[data-chat-keyboard-scope="run-args-form"]') && + active.matches('input, textarea, select, [contenteditable]') + ) { + return + } e.preventDefault() // Immediate form: other chat panels' identical listeners must not // also cancel on body focus, nor a drawer/modal close on this press. diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index fb54204fe9..959e1e69b9 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -23,6 +23,7 @@ import { type ToolCallbacks, type ToolDisplayMessage, type UserQuestionDisplay, + type RunFormDisplay, type ChatJob, type ChatJobInit, type ChatJobStatus, @@ -682,6 +683,7 @@ export class AIChatManager { { resolve: (value: boolean) => void; toolName?: string } >() private userQuestionCallbacks = new Map void>() + private runFormCallbacks = new Map | undefined) => void>() private appDatatablesRefreshTimeout: ReturnType | undefined = undefined disabledModes: Partial> = $state({}) @@ -1787,6 +1789,67 @@ export class AIChatManager { return true } + requestRunArgs = ( + toolId: string, + _form: RunFormDisplay + ): Promise | undefined> => { + return new Promise((resolve) => { + this.runFormCallbacks.set(toolId, resolve) + }) + } + + // A form restored from history has no callback: the loop that opened it is gone. + isRunFormPending = (toolId: string): boolean => this.runFormCallbacks.has(toolId) + + /** False when the form is no longer pending, so the caller can say so instead of + * leaving its submit button spinning on a run that will never start. */ + handleRunFormSubmit = (toolId: string, args: Record): boolean => { + const callback = this.runFormCallbacks.get(toolId) + if (!callback) { + return false + } + // Only the flag: the card's `parameters` already records what ran, and a second + // copy of the arguments in the transcript is one more place a file argument's + // base64 lands in IndexedDB. + this.#patchRunForm(toolId, { submitted: true }) + callback(args) + this.runFormCallbacks.delete(toolId) + return true + } + + handleRunFormCancel = (toolId: string) => { + const callback = this.runFormCallbacks.get(toolId) + // Settled here rather than only in the tool's fn, which a form restored from + // history no longer has: Cancel is that card's one way out, and while it stays + // active the whole session reads as needs-confirmation (getSessionChatStatus + // asks pendingUserAction before loading). Clearing isLoading is part of + // settling — canceled alone unmounts the form but leaves the card shimmering. + this.displayMessages = this.displayMessages.map((message) => + message.role === 'tool' && message.tool_call_id === toolId && message.runForm + ? { + ...message, + isLoading: false, + error: 'Cancelled by user', + content: `Run of "${message.runForm.path}" cancelled by user`, + runForm: { ...message.runForm, canceled: true } + } + : message + ) + if (!callback) { + return + } + callback(undefined) + this.runFormCallbacks.delete(toolId) + } + + #patchRunForm = (toolId: string, patch: Partial) => { + this.displayMessages = this.displayMessages.map((message) => + message.role === 'tool' && message.tool_call_id === toolId && message.runForm + ? { ...message, runForm: { ...message.runForm, ...patch } } + : message + ) + } + setAiChatInput(aiChatInput: AIChatInput | null) { this.aiChatInput = aiChatInput } @@ -3698,6 +3761,7 @@ export class AIChatManager { isPlanModeActive: () => this.planModeActive, onToolBlockedByPlanMode: this.planMode.noteBlockedTool, requestUserQuestion: this.requestUserQuestion, + requestRunArgs: this.requestRunArgs, onItemModified: (kind, path) => this.recordModifiedItem(kind, path), onItemDeployed: (kind, from, to) => void this.renameModifiedItem(kind, from, to), onItemDiscarded: (kind, path) => void this.removeModifiedItem(kind, path), @@ -3954,6 +4018,10 @@ export class AIChatManager { resolveQuestion(undefined) } this.userQuestionCallbacks.clear() + for (const resolveRunArgs of this.runFormCallbacks.values()) { + resolveRunArgs(undefined) + } + this.runFormCallbacks.clear() const cancelReason = reason ?? USER_CANCEL_REASON console.log('cancelling request:', { reason: cancelReason, @@ -4523,6 +4591,9 @@ export class AIChatManager { ): DisplayMessage[] => messages.map((message) => { if (message.role === 'tool' && (message.isLoading || message.isQueued)) { + // Stopping the turn does not stop the job: once the form was submitted the + // script is running for real, so the card must not claim it was canceled. + const ranAlready = message.runForm?.submitted === true return { ...message, isLoading: false, @@ -4532,15 +4603,22 @@ export class AIChatManager { // and a card that hides its result as still-streaming. needsConfirmation: false, isStreamingArguments: false, - // A question's card disappears once canceled, so keep the question - // itself readable in the collapsed header. + // An interactive card disappears once canceled, so keep what it was + // asking readable in the collapsed header. content: message.userQuestion ? `Asked: ${message.userQuestion.question} — ${messageText}` - : messageText, - error: messageText, + : message.runForm + ? ranAlready + ? `Run ${message.runForm.path} — started, stopped tracking before it finished` + : `Run ${message.runForm.path} — ${messageText}` + : messageText, + // A started run keeps whatever the job reported: it is not this turn's + // error, and the jobs tray is still following it. + ...(ranAlready ? {} : { error: messageText }), userQuestion: message.userQuestion ? { ...message.userQuestion, canceled: true } - : undefined + : undefined, + runForm: message.runForm ? { ...message.runForm, canceled: !ranAlready } : undefined } } return message diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 084dc2c040..b14e688a36 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -226,6 +226,56 @@ describe('AIChatManager unmounted-chat guard', () => { }) }) +describe('AIChatManager run form', () => { + // A transcript can be persisted mid-turn (a background job's status write) and + // restored into a fresh manager, which has none of the turn's callbacks. Cancel is + // then the card's only exit, and until it settles pendingUserAction keeps the whole + // session reading as needs-confirmation. + it('settles a restored form whose callback is gone', async () => { + const manager = new AIChatManager() + manager.displayMessages = [ + { + role: 'tool', + tool_call_id: 'call_r', + content: 'Waiting for you to confirm the arguments of "f/a/b"', + isLoading: true, + runForm: { path: 'f/a/b', schema: {}, args: {} } + } + ] + expect(manager.isRunFormPending('call_r')).toBe(false) + + manager.handleRunFormCancel('call_r') + + const { pendingUserAction } = await import('./shared') + const settled = manager.displayMessages[0] + expect(settled.runForm?.canceled).toBe(true) + expect(settled.isLoading).toBe(false) + expect(pendingUserAction(manager.displayMessages)).toBe(undefined) + }) + + // Stop ends the turn, not the job: the deployed script is already running with all + // its side effects, so the transcript must not record it as cancelled. + it('does not mark a submitted run cancelled when the turn is stopped', () => { + const manager = new AIChatManager() + manager.displayMessages = [ + { + role: 'tool', + tool_call_id: 'call_s', + content: 'Running "f/a/b"...', + isLoading: true, + runForm: { path: 'f/a/b', schema: {}, args: {}, submitted: true } + } + ] + + manager.cancelLoadingTools() + + const settled = manager.displayMessages[0] + expect(settled.runForm?.canceled).toBe(false) + expect(settled.error).toBe(undefined) + expect(settled.isLoading).toBe(false) + }) +}) + describe('AIChatManager.sendOrQueue', () => { // The programmatic senders (an editor's "AI Fix", an arriving hand-off) have no // composer to enforce the composer's rule for them: a second loop on one manager diff --git a/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte new file mode 100644 index 0000000000..edf2c85b0a --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte @@ -0,0 +1,141 @@ + + + +
+
+ +
+

+ Run {runForm.summary || runForm.path} +

+ {#if runForm.summary} +

{runForm.path}

+ {/if} +
+
+ +
+ {#if hasArgs} + + {:else} +

This script takes no arguments.

+ {/if} + {#if runForm.droppedKeys?.length} +

+ Not an input of this script, so it will not be sent: + {runForm.droppedKeys.join(', ')} +

+ {/if} +
+ + +
+ + +
+
diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 34b1adcf68..d240abb0df 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -24,7 +24,7 @@ import { getAiChatManager } from './aiChatManagerContext' const aiChatManager = getAiChatManager() - import { isActiveUserQuestion, type ToolDisplayMessage } from './shared' + import { isActiveRunForm, isActiveUserQuestion, type ToolDisplayMessage } from './shared' import ChatCollapsibleCard from './ChatCollapsibleCard.svelte' import { twMerge } from 'tailwind-merge' import { slide } from 'svelte/transition' @@ -37,6 +37,7 @@ import ToolMessageActions from './ToolMessageActions.svelte' import ToolPreviewCard from './ToolPreviewCard.svelte' import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte' + import RunArgsFormDisplay from './RunArgsFormDisplay.svelte' import WebSearchSourcesDisplay from './WebSearchSourcesDisplay.svelte' import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte' @@ -117,6 +118,8 @@ isActiveUserQuestion(message) ? message.userQuestion : undefined ) + const activeRunForm = $derived(isActiveRunForm(message) ? message.runForm : undefined) + // The preview chip sits on the header row (to the right of the tool-call text); // shown once the tool settled, never while loading/erroring/awaiting confirmation. const showPreviewChip = $derived( @@ -128,6 +131,8 @@ {#if activeUserQuestion} +{:else if activeRunForm} + {:else if message.blockedByPlanMode} diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 19050bb7da..fc10cc3b32 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -82,6 +82,7 @@ vi.mock('$lib/gen', async () => { runScriptPreview: vi.fn(async () => 'job-script-preview'), runFlowPreview: vi.fn(async () => 'job-flow-preview'), runFlowByPath: vi.fn(async () => 'job-flow-by-path'), + runScriptByPath: vi.fn(async () => 'job-script-by-path'), getJob: vi.fn(async () => ({ type: 'CompletedJob', success: true, @@ -4633,6 +4634,187 @@ describe('global AI tools', () => { }) }) + // The form IS the consent, so a dismissed one must leave the script unrun. + it('run_script starts no job when the user cancels the form', async () => { + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + path: 'f/scripts/deployed', + summary: 'Deployed', + schema: { properties: { name: { type: 'string' } } } + } as any) + + const result = await callGlobalTool( + 'run_script', + { path: 'f/scripts/deployed', args: { name: 'Ada' } }, + { ...toolCallbacks, requestRunArgs: async () => undefined } + ) + + expect(JobService.runScriptByPath).not.toHaveBeenCalled() + expect(result).toContain('The user cancelled the run form') + expect(result).toContain('Do not call run_script again') + }) + + // The card is a consent surface: an argument it cannot show is an argument the user + // never approved, so the prefill is conformed to the schema before the form opens. + it('run_script drops a proposed argument the schema does not declare', async () => { + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + path: 'f/scripts/noargs', + summary: 'Takes nothing', + schema: { properties: {} } + } as any) + + let shown: Record | undefined + let dropped: string[] | undefined + const result = await withCompletedTestJob(() => + callGlobalTool( + 'run_script', + { path: 'f/scripts/noargs', args: { force_delete: true } }, + { + ...toolCallbacks, + requestRunArgs: async (_toolId, form) => { + shown = form.args + dropped = form.droppedKeys + return form.args + } + } + ) + ) + + expect(shown).toEqual({}) + // Named on the card and to the model: a silent drop makes the user approve a run + // they think carries force_delete. + expect(dropped).toEqual(['force_delete']) + expect(result).toContain('force_delete') + expect(JobService.runScriptByPath).toHaveBeenCalledWith({ + workspace: WORKSPACE, + path: 'f/scripts/noargs', + requestBody: {} + }) + }) + + // A secret the model picked is not consent, and a result that echoed one back would let + // it propose the same value again on the next call. + it('run_script opens password fields empty and hides secrets from the model', async () => { + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + path: 'f/scripts/rotate', + schema: { + properties: { + token: { type: 'string', password: true }, + nested: { + type: 'object', + properties: { inner: { type: 'string', password: true } } + }, + name: { type: 'string' } + } + } + } as any) + + let shown: Record | undefined + const result = await withCompletedTestJob(() => + callGlobalTool( + 'run_script', + { + path: 'f/scripts/rotate', + args: { + token: 'hunter2', + nested: { inner: '$var:u/ada/prod_api_key' }, + name: 'ada' + } + }, + { + ...toolCallbacks, + requestRunArgs: async (_toolId, form) => { + shown = form.args + return { ...form.args, token: '$var:u/ada/secret_arg/typed' } + } + } + ) + ) + + expect(shown).toEqual({ nested: {}, name: 'ada' }) + expect(result).not.toContain('hunter2') + expect(result).not.toContain('secret_arg') + expect(result).not.toContain('prod_api_key') + expect(result).toContain('ada') + }) + + // The form is its own confirmation, so it never reaches processToolCall's second gate. + // Plan mode can be switched on while it sits open, and the job must not start. + it('run_script starts no job when plan mode is entered while the form is open', async () => { + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + path: 'f/scripts/noargs', + schema: { properties: {} } + } as any) + + let planning = false + const result = await callGlobalTool( + 'run_script', + { path: 'f/scripts/noargs', args: {} }, + { + ...toolCallbacks, + isPlanModeActive: () => planning, + requestRunArgs: async (_toolId, form) => { + planning = true + return form.args + } + } + ) + + expect(JobService.runScriptByPath).not.toHaveBeenCalled() + expect(result).toContain('plan mode is active') + }) + + it('run_script prefills a locked field with its default, not the proposed value', async () => { + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + path: 'f/scripts/locked', + schema: { + properties: { + locked: { type: 'string', default: 'fixed', disabled: true }, + other: { type: 'string' } + } + } + } as any) + + let shown: Record | undefined + await withCompletedTestJob(() => + callGlobalTool( + 'run_script', + { path: 'f/scripts/locked', args: { locked: 'tampered', other: 'hello' } }, + { + ...toolCallbacks, + requestRunArgs: async (_toolId, form) => { + shown = form.args + return form.args + } + } + ) + ) + + expect(shown).toEqual({ locked: 'fixed', other: 'hello' }) + }) + + it('run_script runs the arguments the user submitted, not the ones proposed', async () => { + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + path: 'f/scripts/greet', + schema: { properties: { name: { type: 'string' } } } + } as any) + + const result = await withCompletedTestJob(() => + callGlobalTool( + 'run_script', + { path: 'f/scripts/greet', args: { name: 'Ada' } }, + { ...toolCallbacks, requestRunArgs: async () => ({ name: 'Grace' }) } + ) + ) + + expect(JobService.runScriptByPath).toHaveBeenCalledWith({ + workspace: WORKSPACE, + path: 'f/scripts/greet', + requestBody: { name: 'Grace' } + }) + // The model must not assume its proposal is what ran. + expect(result).toContain('Ran with arguments: {"name":"Grace"}') + }) + it('test_run_step lists nested step ids when a step is not found', async () => { await callGlobalTool('write_flow', { path: 'f/flows/nested-step-error', @@ -5323,6 +5505,9 @@ describe('session-only preview tools gating', () => { expect(names).not.toContain('list_app_runs') expect(names).not.toContain('search_dom') expect(names).not.toContain('read_dom') + // Withheld for its own reason: the side-panel chat cannot render the argument + // form the tool blocks on. + expect(names).not.toContain('run_script') // other tools are still present expect(names).toContain('write_script') }) @@ -5335,6 +5520,7 @@ describe('session-only preview tools gating', () => { expect(names).toContain('list_app_runs') expect(names).toContain('search_dom') expect(names).toContain('read_dom') + expect(names).toContain('run_script') // The session set is the full globalTools minus capability-gated tools: // this environment is not Chromium, so take_screenshot is withheld (DOM // capture is only faithful on Blink). search_dom / read_dom are not gated. diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index e919769e7a..9a00cc87dc 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -47,6 +47,8 @@ import { STARTER_RUNNABLE_KEY, type FrameworkKey } from '$lib/components/raw_apps/templates' +import { conformArgsToSchema, redactSecretArgs, stripSecretArgs } from '$lib/components/job_args' +import { PLAN_MODE_MESSAGES } from '../planModeMessages' import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils' import { appSourceToDraftValue } from '$lib/components/raw_apps/rawAppDraftValue' import type { RawAppDomQuery } from '$lib/components/raw_apps/rawAppDom' @@ -120,6 +122,7 @@ import { isHubPath, type CreatedResourceTriggerKind, type PreviewCardKind, + type RunFormDisplay, type Tool, type ToolCallbacks, type ToolDisplayAction @@ -893,6 +896,18 @@ const testRunScriptToolDef = createToolDef( { strict: false } ) +const runScriptSchema = z.object({ + path: z.string().describe('Workspace path of the deployed script to run.'), + args: testRunArgsSchema +}) + +const runScriptToolDef = createToolDef( + runScriptSchema, + 'run_script', + "Run a DEPLOYED script for real, under the user's own permissions. The user always gets an argument form prefilled with `args` and decides what actually runs, so fill in every argument you can infer rather than leaving the form empty. Use this when the user asks to run or execute something; use test_run_script instead to try out a script you are writing.", + { strict: false } +) + const testRunFlowSchema = z.object({ path: z.string().describe('Workspace path of the flow to test.'), args: testRunArgsSchema, @@ -1322,7 +1337,11 @@ ${pipelineBullet} : ' Pass items (":" entries naming the items you changed) so the review is scoped to them — omitting items preselects every pending change in the workspace' }, or mode ("draft" or "fork") to force which comparison is shown. Prefer offering this review page over calling deploy_workspace_item directly when several items changed. - For a Windmill operation no other tool covers (workers, queue state, a run's args, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools — use the draft tools and delete_workspace_item instead. -- runScriptByPath / runFlowByPath from the API catalog run the DEPLOYED version of an item. Use them only when the user explicitly asks to run the deployed version, and read the item with read_workspace_item version: "deployed" first so the arguments match the deployed input schema (a draft may have different inputs). To test something you are editing or just wrote, always use test_run_script, test_run_flow, or test_run_step — they run the draft. +- ${ + previewTools + ? 'To run a DEPLOYED script for real (the user asks to run/execute something that already exists), use run_script: it shows them an argument form prefilled with what you pass, and they submit it. Fill in every argument you can infer from the conversation — an empty form makes them do the work. runScriptByPath and runFlowByPath from the API catalog also run the deployed item, but without a form: reach for them only for a flow, or when the user explicitly asks for a deployed run you cannot route through run_script — for those, read the item with read_workspace_item version: "deployed" first so the arguments match the deployed input schema (a draft may have different inputs)' + : 'runScriptByPath / runFlowByPath from the API catalog run the DEPLOYED version of an item. Use them only when the user explicitly asks to run the deployed version, and read the item with read_workspace_item version: "deployed" first so the arguments match the deployed input schema (a draft may have different inputs)' + }. To test something you are editing or just wrote, always use test_run_script, test_run_flow, or test_run_step — they run the draft. - When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive). - When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task. - Keep context targeted.${ @@ -3645,6 +3664,19 @@ export const globalTools: Tool<{}>[] = [ showDetails: true, autoCollapseDetails: false }, + { + def: runScriptToolDef, + fn: async (ctx) => { + const parsed = runScriptSchema.parse(ctx.args) + return runDeployedScript(parsed, ctx) + }, + // No requiresConfirmation: the argument form is the confirmation, and unlike a + // yes/no card it must not be auto-accepted away by YOLO. + streamingLabel: 'Preparing the run form...', + queuedLabel: (args) => `Run ${args?.path ?? 'a script'}`, + showDetails: true, + autoCollapseDetails: false + }, { def: testRunFlowToolDef, fn: async (ctx) => { @@ -4280,15 +4312,23 @@ export const SESSION_PREVIEW_TOOL_NAMES = new Set([ 'list_artifact_versions' ]) +// Withheld from the side-panel chat: the argument form is a blocking card, and +// sessions are the only surface that renders one. +const SESSION_ONLY_TOOL_NAMES = new Set(['run_script']) + /** * The global tool set for a given chat: the full `globalTools` for a session - * chat, or `globalTools` minus the session-only preview tools for the regular - * global side-panel chat. + * chat, or `globalTools` minus the session-only tools for the regular global + * side-panel chat. */ export function globalToolsFor({ sessionPreview }: { sessionPreview: boolean }): Tool<{}>[] { const tools = sessionPreview ? globalTools - : globalTools.filter((t) => !SESSION_PREVIEW_TOOL_NAMES.has(t.def.function.name)) + : globalTools.filter( + (t) => + !SESSION_PREVIEW_TOOL_NAMES.has(t.def.function.name) && + !SESSION_ONLY_TOOL_NAMES.has(t.def.function.name) + ) // DOM capture re-renders the app through the engine's SVG-image path, which is // only faithful on Blink — Gecko/WebKit shift text spacing and wrapping (font // fallback, sub-pixel rounding). Elsewhere the tool is withheld entirely and @@ -5396,6 +5436,102 @@ async function testRunScriptByPath( }) } +/** The "do not call again" half is load-bearing: without it the model re-proposes the + * call, which re-opens the form the user just dismissed, and Stop becomes their only + * way out. */ +const RUN_FORM_CANCELLED = + 'The user cancelled the run form. The script did NOT run. Do not call run_script again unless the user asks for it.' + +/** The model only needs to see what the user changed, and a form can carry a base64 + * file argument — unbounded, this would eat the window with one attachment. */ +const MAX_SUBMITTED_ARGS_LENGTH = 4000 + +async function runDeployedScript( + args: z.infer, + ctx: WriteDraftCtx +): Promise { + const { workspace, toolId, toolCallbacks } = ctx + if (!toolCallbacks.requestRunArgs) { + return 'This chat cannot show a run form, so a deployed script cannot be run from here.' + } + + // No getDraft: this runs the script as it is live, so the form has to offer the + // inputs the live version accepts and not a draft's. + const script = await ScriptService.getScriptByPath({ workspace, path: args.path }) + const schema = (script.schema as Record) ?? {} + const conformed = conformArgsToSchema(normalizeTestRunArgs(args.args), schema) + // A secret the model picked is not consent, whatever it holds: a literal is a value + // the user never chose, a reference names something the card cannot show them. + const proposed = stripSecretArgs(conformed.args, schema as any) + const form: RunFormDisplay = { + path: args.path, + summary: script.summary || undefined, + schema, + args: proposed, + droppedKeys: conformed.droppedKeys.length ? conformed.droppedKeys : undefined + } + + toolCallbacks.setToolStatus(toolId, { + content: `Waiting for you to confirm the arguments of "${args.path}"`, + runForm: form, + isLoading: true + }) + + const submitted = await toolCallbacks.requestRunArgs(toolId, form) + if (!submitted) { + toolCallbacks.setToolStatus(toolId, { + content: `Run of "${args.path}" cancelled by user`, + isLoading: false, + isStreamingArguments: false, + error: 'Cancelled by user', + declinedByUser: true + }) + return RUN_FORM_CANCELLED + } + + // processToolCall re-gates plan mode after a standard confirmation, because it can be + // entered while a card is pending. This form is its own confirmation and never reaches + // that gate, so it repeats it here. + if (toolCallbacks.isPlanModeActive?.()) { + toolCallbacks.onToolBlockedByPlanMode?.() + toolCallbacks.setToolStatus(toolId, { + content: PLAN_MODE_MESSAGES.blockedLabel, + isLoading: false, + isStreamingArguments: false, + error: PLAN_MODE_MESSAGES.blockedResult, + blockedByPlanMode: true + }) + return PLAN_MODE_MESSAGES.blockedResult + } + + // The card's details pane must show what ran, not what was proposed. + toolCallbacks.setToolStatus(toolId, { parameters: submitted }) + + const outcome = await executeTestRun({ + jobStarter: () => + JobService.runScriptByPath({ workspace, path: args.path, requestBody: submitted }), + workspace, + toolCallbacks, + toolId, + startMessage: `Running "${args.path}"...`, + contextName: 'script', + actionNoun: 'run', + label: args.path + }) + + const dropped = conformed.droppedKeys.length + ? `\nThe deployed schema declares no ${conformed.droppedKeys.join(', ')}, so the form never offered ${conformed.droppedKeys.length > 1 ? 'them' : 'it'} and the run did not carry ${conformed.droppedKeys.length > 1 ? 'them' : 'it'}.` + : '' + // Redacted: a variable path is enough to run a job on a value the model cannot read, + // and one shown a path proposes it back on the next call. + const submittedJson = JSON.stringify(redactSecretArgs(submitted, schema as any)) + const shown = + submittedJson.length > MAX_SUBMITTED_ARGS_LENGTH + ? submittedJson.slice(0, MAX_SUBMITTED_ARGS_LENGTH) + '... (truncated)' + : submittedJson + return `Ran with arguments: ${shown}${dropped}\n${outcome}` +} + async function testRunFlowByPath( args: z.infer, ctx: WriteDraftCtx diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index c734691e29..2dc7114757 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -945,6 +945,16 @@ describe('processToolCall', () => { { requestConfirmation: vi.fn().mockResolvedValue(false) } ) ).toEqual([['ai_chat', 'tool', 'run_script:declined']]) + // A tool that runs its own consent surface (the run form) declines by settling + // the card, then returns normally — that must not read as a successful run. + expect( + await outcomeKeys({ + fn: vi.fn(async ({ toolCallbacks, toolId }: any) => { + toolCallbacks.setToolStatus(toolId, { declinedByUser: true }) + return 'cancelled' + }) + }) + ).toEqual([['ai_chat', 'tool', 'run_script:declined']]) expect(await outcomeKeys({}, { isPlanModeActive: () => true })).toEqual([ ['ai_chat', 'tool', 'run_script:blocked_plan_mode'] ]) @@ -1272,6 +1282,28 @@ describe('isActiveUserQuestion', () => { }) }) +describe('isActiveRunForm', () => { + const runForm = { path: 'f/a/b', schema: {}, args: { name: 'ada' } } + function toolMessage(overrides: Partial = {}): ToolDisplayMessage { + return { + role: 'tool', + tool_call_id: 'call_r', + content: 'waiting for arguments', + isLoading: true, + runForm, + ...overrides + } + } + + // Either flag unmounts the card, so the loop must stop waiting on it. + it('is false once submitted or cancelled', async () => { + const { isActiveRunForm } = await import('./shared') + expect(isActiveRunForm(toolMessage())).toBe(true) + expect(isActiveRunForm(toolMessage({ runForm: { ...runForm, submitted: true } }))).toBe(false) + expect(isActiveRunForm(toolMessage({ runForm: { ...runForm, canceled: true } }))).toBe(false) + }) +}) + describe('pendingUserAction', () => { const toolMessage = (overrides: Partial = {}): ToolDisplayMessage => ({ role: 'tool', @@ -1289,6 +1321,15 @@ describe('pendingUserAction', () => { expect(pendingUserAction([toolMessage({ needsConfirmation: true })])).toBe('confirmation') }) + // A run form asks for arguments rather than a yes/no, but it blocks the loop the + // same way, so the chat reports it as a confirmation. + it('reports an unsubmitted run form as a confirmation', async () => { + const { pendingUserAction } = await import('./shared') + expect( + pendingUserAction([toolMessage({ runForm: { path: 'f/a/b', schema: {}, args: {} } })]) + ).toBe('confirmation') + }) + it('is undefined for a tool the AI is running on its own', async () => { const { pendingUserAction } = await import('./shared') expect(pendingUserAction([toolMessage()])).toBe(undefined) diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 6d6794b56c..499d6ac856 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -553,6 +553,24 @@ export function answeredChoices(q: UserQuestionDisplay): string[] | undefined { return q.selectedChoices ?? (q.selectedChoice ? [q.selectedChoice] : undefined) } +/** Argument form for a deployed-script run, persisted with the transcript — every + * field has to stay plain JSON. */ +export type RunFormDisplay = { + path: string + summary?: string + /** Of the DEPLOYED script, not a draft. */ + schema: Record + /** Prefill only: the card's `parameters` records what the job started with. */ + args: Record + /** Proposed arguments the schema does not declare, so they have no field. Named on + * the card: approving a run is not consent to something it never showed. */ + droppedKeys?: string[] + /** Either one unmounts the form, so set exactly one, and only once the loop has + * stopped waiting on this card. */ + submitted?: boolean + canceled?: boolean +} + /** One page hit from a provider-side web search (OpenAI sources carry no title). */ export type WebSearchSource = { url: string @@ -578,6 +596,7 @@ export type ToolDisplayMessage = { showFade?: boolean actions?: ToolDisplayAction[] userQuestion?: UserQuestionDisplay + runForm?: RunFormDisplay webSearchSources?: WebSearchSource[] /** Data URL of an image the tool produced (e.g. take_screenshot), shown on the card. */ imageUrl?: string @@ -653,6 +672,18 @@ export function isActiveUserQuestion(message: DisplayMessage | undefined): boole ) } +export function isActiveRunForm(message: DisplayMessage | undefined): boolean { + return Boolean( + message && + message.role === 'tool' && + message.runForm && + message.isLoading && + !message.error && + !message.runForm.submitted && + !message.runForm.canceled + ) +} + // The loop is parked on the user: an unanswered askUserQuestion, or a tool call // staged for confirmation. The manager stays `loading` through both, so anything // rendering progress must ask here first or it reports "the AI is working". @@ -676,6 +707,12 @@ export function pendingUserActionDetail( if (isActiveUserQuestion(message)) { return { action: 'question', toolCallId: message.tool_call_id } } + // A run form is a confirmation carrying arguments, not a question: it parks the + // turn the same way, but Run or Cancel resolves it and typing never does — so it + // must not claim the answer affordance a pending question offers. + if (isActiveRunForm(message)) { + return { action: 'confirmation', toolCallId: message.tool_call_id } + } if (message.needsConfirmation && message.isLoading) { return { action: 'confirmation', toolCallId: message.tool_call_id } } @@ -958,6 +995,9 @@ export async function processToolCall({ } let result = '' + // A tool that asks for consent itself settles the call as declined or blocked and + // returns normally, so without this both telemeter as successful runs. + let settledInsideTool: 'declined' | 'blocked_plan_mode' | undefined = undefined try { result = await callTool({ tools, @@ -965,10 +1005,17 @@ export async function processToolCall({ args, workspace: workspaceId, helpers, - toolCallbacks, + toolCallbacks: { + ...toolCallbacks, + setToolStatus: (toolId, status) => { + if (status?.declinedByUser) settledInsideTool = 'declined' + else if (status?.blockedByPlanMode) settledInsideTool = 'blocked_plan_mode' + toolCallbacks.setToolStatus(toolId, status) + } + }, toolId: toolCall.id }) - logToolOutcome('ok') + logToolOutcome(settledInsideTool ?? 'ok') toolCallbacks.setToolStatus(toolCall.id, { isLoading: false, isStreamingArguments: false @@ -1229,6 +1276,12 @@ export interface ToolCallbacks { toolId: string, question: UserQuestionDisplay ) => Promise + /** Park the loop on an argument form and resolve with the args the user submitted, + * or undefined if they cancelled. Wired only where the form can be rendered. */ + requestRunArgs?: ( + toolId: string, + form: RunFormDisplay + ) => Promise | undefined> /** Records a workspace item the tool call created/edited/deleted, by its * canonical (itemKind, storagePath). Session chats wire this to accumulate the * chat's modified-items mask; the global side-panel chat omits it (no-op). */ @@ -1517,13 +1570,16 @@ export interface TestRunConfig { detachAfterMs?: number /** Human label for the jobs tray row (path / step id). Defaults to the job id. */ label?: string - /** Overrides the default "…test started, waiting for completion" status while the + /** Overrides the default "…started, waiting for completion" status while the * job runs inline (e.g. an SQL tool shows "SQL running…"). */ runningMessage?: string - /** Noun for the human-facing status strings (" test completed successfully"). - * Defaults to `contextName`, which also carries the jobs-tray kind and so cannot - * always name what ran: an app's path runnable queues a flow job. */ + /** The item noun in the human-facing status strings ("Flow test completed + * successfully"). Defaults to `contextName`, which also carries the jobs-tray kind + * and so cannot always name what ran: an app's path runnable queues a flow job. */ completionName?: string + /** The action noun in those same strings ("Script run completed successfully"). + * Defaults to "test", so a tool running the deployed item for real passes "run". */ + actionNoun?: string /** Custom terminal formatting for the INLINE completion path (callers whose * result isn't a plain test-run summary, e.g. exec_datatable_sql shaping rows). * Returns the string handed to the model plus the tool-card patch. When omitted, @@ -1731,12 +1787,15 @@ export function backgroundJobCompletionNote( ) } -// Main execution function for test runs export async function executeTestRun(config: TestRunConfig): Promise { // Detach-into-background is enabled only when the host wired the job hooks // (global/sessions chat). Otherwise this stays a blocking call. const detachEnabled = !!config.toolCallbacks.onJobStarted const label = config.label ?? config.contextName + const actionNoun = config.actionNoun ?? 'test' + // Stands on its own where the status strings are prefixed by the item, so its + // default carries the noun. + const failureNoun = config.actionNoun ?? 'test run' try { config.toolCallbacks.setToolStatus(config.toolId, { content: config.startMessage || `Starting ${config.contextName} test...` @@ -1761,7 +1820,8 @@ export async function executeTestRun(config: TestRunConfig): Promise { }) config.toolCallbacks.setToolStatus(config.toolId, { - content: config.runningMessage ?? `${contextName} test started, waiting for completion...` + content: + config.runningMessage ?? `${contextName} ${actionNoun} started, waiting for completion...` }) const outcome = await pollJobCompletion( @@ -1781,7 +1841,7 @@ export async function executeTestRun(config: TestRunConfig): Promise { if (outcome === 'detached') { config.toolCallbacks.onJobDetached?.(jobId) config.toolCallbacks.setToolStatus(config.toolId, { - content: `${contextName} test running in background (job ${jobId})` + content: `${contextName} ${actionNoun} running in background (job ${jobId})` }) return backgroundedSummary(jobId, label) } @@ -1800,7 +1860,7 @@ export async function executeTestRun(config: TestRunConfig): Promise { } config.toolCallbacks.setToolStatus(config.toolId, { - content: `${contextName} test ${job.success ? 'completed successfully' : 'failed'}`, + content: `${contextName} ${actionNoun} ${job.success ? 'completed successfully' : 'failed'}`, result: formatResult(job.result), logs: formatLogs(job.logs), ...(job.success ? {} : { error: getErrorMessage(job.result) }) @@ -1823,10 +1883,10 @@ export async function executeTestRun(config: TestRunConfig): Promise { // the flow it could not find — losing the one diagnostic the run exists for. const errorMessage = formatToolError(error) config.toolCallbacks.setToolStatus(config.toolId, { - content: `Test execution failed`, + content: `Execution failed`, error: errorMessage }) - throw new Error(`Failed to execute test run: ${errorMessage}`) + throw new Error(`Failed to execute ${failureNoun}: ${errorMessage}`) } } diff --git a/frontend/src/lib/components/job_args.test.ts b/frontend/src/lib/components/job_args.test.ts new file mode 100644 index 0000000000..c936b6659b --- /dev/null +++ b/frontend/src/lib/components/job_args.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { conformArgsToSchema } from './job_args' + +describe('conformArgsToSchema', () => { + it('drops what the schema does not declare, prototype names included', () => { + const { args, droppedKeys } = conformArgsToSchema( + JSON.parse('{"keep":1,"force":true,"constructor":"x","toString":"y","__proto__":{"p":1}}'), + { properties: { keep: { type: 'number' } } } + ) + expect(args).toEqual({ keep: 1 }) + expect(Object.getPrototypeOf(args)).toBe(Object.prototype) + expect(droppedKeys.sort()).toEqual(['__proto__', 'constructor', 'force', 'toString']) + }) +}) diff --git a/frontend/src/lib/components/job_args.ts b/frontend/src/lib/components/job_args.ts index ef3377081f..3cd83567fb 100644 --- a/frontend/src/lib/components/job_args.ts +++ b/frontend/src/lib/components/job_args.ts @@ -1,5 +1,109 @@ import { deepEqual } from 'fast-equals' +/** + * A field the schema disables is not the caller's to set: whatever it holds, the run + * sends the schema's default. Returns the keys it actually overwrote so the caller can + * say so — notifying is the caller's job, this stays pure. + * + * Top-level properties only; a disabled field nested in an object is not normalized. + */ +export function enforceDisabledDefaults( + args: Record, + schema: { properties?: Record } | undefined +): { args: Record; resetKeys: string[] } { + if (!schema?.properties) return { args, resetKeys: [] } + const result = { ...args } + const resetKeys: string[] = [] + for (const [key, prop] of Object.entries(schema.properties) as [string, any][]) { + if (!prop?.disabled || !('default' in prop)) continue + if (result[key] !== prop.default) resetKeys.push(key) + result[key] = prop.default + } + return { args: result, resetKeys } +} + +/** + * Conform caller-supplied arguments to what a run form can actually show: drop what the + * schema does not declare, then apply {@link enforceDisabledDefaults}. An argument with + * no field — including every argument of a script whose schema declares none — would + * otherwise be approved without ever being seen. + */ +export function conformArgsToSchema( + args: Record, + schema: { properties?: Record } | undefined +): { args: Record; resetKeys: string[]; droppedKeys: string[] } { + const properties = schema?.properties ?? {} + const known: Record = {} + const droppedKeys: string[] = [] + for (const [key, value] of Object.entries(args ?? {})) { + // hasOwn, not `in`: every object inherits `constructor`, `toString` and + // `__proto__`, so `in` would wave through arguments no schema declares — + // and assigning `__proto__` would mutate the accumulator instead of it. + if (Object.hasOwn(properties, key)) { + known[key] = value + } else { + droppedKeys.push(key) + } + } + const { args: result, resetKeys } = enforceDisabledDefaults(known, schema) + return { args: result, resetKeys, droppedKeys } +} + +/** + * Rebuild `holder` with `visit` applied to every password-typed argument the schema + * declares, at any depth; returning `undefined` removes that argument. + * + * Recursive because the form is: `ArgInput` renders a nested `SchemaForm` for any object + * property declaring properties of its own. Password props inside `items` or a `oneOf` + * branch mount through a different path and are not reached — a known limit. + */ +function mapSecretArgs( + holder: any, + properties: Record, + visit: (value: unknown) => unknown +): any { + if (holder == null || typeof holder !== 'object' || Array.isArray(holder)) return holder + const result = { ...holder } + for (const [key, prop] of Object.entries(properties)) { + // A password-typed object is a leaf, not a level: it is stored whole as one + // $jsonvar: reference rather than field by field. + if (prop?.password) { + const mapped = visit(result[key]) + if (mapped === undefined) delete result[key] + else result[key] = mapped + } else if (prop?.properties) { + result[key] = mapSecretArgs(result[key], prop.properties, visit) + } + } + return result +} + +/** + * Drop every password-typed argument, so a caller cannot propose a secret on the user's + * behalf: password fields open empty and the user fills them in. + */ +export function stripSecretArgs( + args: Record, + schema: { properties?: Record } | undefined +): Record { + const properties = schema?.properties + if (!properties) return args + return mapSecretArgs(args, properties, () => undefined) +} + +/** + * Replace every password-typed argument with a fixed marker, for text that leaves the + * form. A reference is enough to run a job on something the reader cannot see. + */ +export function redactSecretArgs( + args: Record, + schema: { properties?: Record } | undefined +): Record { + const properties = schema?.properties + if (!properties) return args + return mapSecretArgs(args, properties, (value) => (value == null ? undefined : '')) +} + export function isWindmillTooBigObject(obj: any): boolean { return ( typeof obj === 'object' &&