From 157cd7e85166d9de3bce5bdaf8f12bc391d0adc3 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:56:50 +0200 Subject: [PATCH] fix: open a run form the bypass posture has no answer for --- .../copilot/chat/global/core.test.ts | 48 ++++++++++++++++ .../components/copilot/chat/global/core.ts | 57 +++++++++++++------ 2 files changed, 89 insertions(+), 16 deletions(-) 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 218e1bdc80..824e37c1b3 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -4469,6 +4469,54 @@ describe('global AI tools', () => { ) }) + // The posture answers for consent, not for information. A secret is never the model's to + // send and a required field it left empty was never answered, so the form still opens under + // the bypass — otherwise the run starts on a value nobody supplied. + it('opens a run form under yolo when only the user can fill it', async () => { + const yolo = (statuses: any[]) => ({ + ...toolCallbacks, + setToolStatus: (_toolId: string, status: any) => statuses.push(status), + shouldAutoAcceptToolConfirmations: () => true, + requestRunArgs: async (_toolId: string, form: any) => form.args + }) + + // A required field the model did not send. + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + path: 'f/scripts/needs', + schema: { properties: { name: { type: 'string' } }, required: ['name'] } + } as any) + const missing: any[] = [] + await withCompletedTestJob(() => + callGlobalTool('run_script', { path: 'f/scripts/needs', args: {} }, yolo(missing)) + ) + expect(missing.find((x) => x.runForm)?.runForm.submitted).toBeUndefined() + + // A secret, which is stripped from the proposal whatever the posture. + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + path: 'f/scripts/secret', + schema: { + properties: { token: { type: 'string', password: true } }, + required: ['token'] + } + } as any) + const secret: any[] = [] + await withCompletedTestJob(() => + callGlobalTool('run_script', { path: 'f/scripts/secret', args: { token: 'hunter2' } }, yolo(secret)) + ) + expect(secret.find((x) => x.runForm)?.runForm.submitted).toBeUndefined() + + // Nothing outstanding: the posture answers and no field is ever mounted. + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + path: 'f/scripts/ready', + schema: { properties: { name: { type: 'string' } }, required: ['name'] } + } as any) + const ready: any[] = [] + await withCompletedTestJob(() => + callGlobalTool('run_script', { path: 'f/scripts/ready', args: { name: 'Ada' } }, yolo(ready)) + ) + expect(ready.find((x) => x.runForm)?.runForm.submitted).toBe(true) + }) + // The bypass is the user's standing answer, not a licence for the host to skip asking: // a chat with nowhere to put a form still refuses the run under any other posture. it('run_script refuses a host with no form unless the posture answers for it', async () => { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index d5be63292e..f21bcfe2e2 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -3682,8 +3682,9 @@ export const globalTools: Tool<{}>[] = [ return runDeployedScript(parsed, ctx) }, // No requiresConfirmation: the argument form is the confirmation, and the bypass posture - // answers it as it answers any other. One thing does run before Run — see the note on - // the form's SchemaForm. + // answers it — unless it holds a field only the user can fill, which is a question and + // not a confirmation. One thing does run before Run — see the note on the form's + // SchemaForm. bypassedByAutoAccept: true, confirmationMessage: 'Run a deployed script', streamingLabel: 'Preparing the run form...', @@ -5476,8 +5477,9 @@ async function testRunScriptByPath( proposed: args.args, startMessage: `Running test for script "${args.path}"...`, contextName: 'script', - // Its own loop: the model is told to test and iterate, so YOLO answers the form - // with what it opened with rather than parking the loop on a card. + // Its own loop: the model is told to test and iterate, so the posture answers the + // form with what it opened with rather than parking the loop on a card. Same + // exception as a deployed run — a field only the user can fill still stops it. autoAcceptable: true, background: args.background, detachAfterMs: waitSecondsToDetachMs(args.wait_seconds), @@ -5526,25 +5528,45 @@ type FormRunSpec = { proposed: Record | null | undefined startMessage: string contextName: 'script' | 'flow' - /** Whether YOLO may answer this form with what it opened with. Only a run the user can - * undo by editing the code may set it: a deployed run is not one, which is why its form - * is the confirmation YOLO cannot skip. */ + /** Whether the bypass posture may answer this form with what it opened with. Answering it + * is still refused when the form holds something only the user can give — see + * `formNeedsUser`. */ autoAcceptable?: boolean background?: boolean detachAfterMs?: number startJob: (submitted: Record) => Promise } +/** Whether the form holds something the model could not have supplied, so the bypass posture + * has nothing to answer with. Either it was stripped for being the user's to give — a secret, + * a file — or the schema requires it and neither the proposal nor a default carries a value. */ +function formNeedsUser( + schema: Record, + proposed: Record, + strippedKeys: string[] +): boolean { + if (strippedKeys.length > 0) return true + const required = schema?.required + if (!Array.isArray(required)) return false + const properties = schema?.properties ?? {} + return required.some((key) => { + if (typeof key !== 'string') return false + if (proposed[key] !== undefined && proposed[key] !== null) return false + const declared = Object.hasOwn(properties, key) ? properties[key] : undefined + return declared?.default === undefined + }) +} + async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise { const { workspace, toolId, toolCallbacks } = ctx // Asked of the posture, not of the tool: every run tool is auto-acceptable now, so a // host with no form would otherwise run one on the model's arguments alone, in any // posture. What a bypass answers is a decision the user already made; without it there // is no consent to be had here and nothing to fall back on. - const autoAccepted = Boolean( + const postureAnswers = Boolean( spec.autoAcceptable && toolCallbacks.shouldAutoAcceptToolConfirmations?.(spec.toolName) ) - if (!toolCallbacks.requestRunArgs && !autoAccepted) { + if (!toolCallbacks.requestRunArgs && !postureAnswers) { return 'This chat cannot show a run form, so a script cannot be run from here.' } @@ -5559,10 +5581,13 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise JobService.runScriptByPath({ workspace, path: args.path, requestBody: submitted })