fix: open a run form the bypass posture has no answer for

This commit is contained in:
AlexRV12
2026-09-03 16:31:56 +02:00
parent 43205a5365
commit 157cd7e851
2 changed files with 89 additions and 16 deletions
@@ -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 () => {
@@ -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<string, any> | 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<string, any>) => Promise<string>
}
/** 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<string, any>,
proposed: Record<string, any>,
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<string> {
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<st
schema as any,
strippedKeys
)
// `autoAccepted` is decided above, before the form is attached rather than once it is
// waiting: a form answered a tick after it mounts flashes its fields at a user who was
// never going to fill them in. Settled from the start, the same card renders without ever
// showing a field, and the schema it would have built them from never reaches the transcript.
// The posture answers for consent, not for information: a field the model is barred from
// filling and a required one it left empty are questions, and the form is the only place
// they get answered. Decided before the form is attached rather than once it is waiting —
// a form answered a tick after it mounts flashes its fields at a user who was never going
// to fill them in.
const needsUser = formNeedsUser(schema, proposed, strippedKeys)
const autoAccepted = postureAnswers && (!toolCallbacks.requestRunArgs || !needsUser)
const form: RunFormDisplay = {
path: spec.path,
summary: spec.summary || undefined,
@@ -5704,9 +5729,9 @@ async function runDeployedScript(
startMessage: `Running "${args.path}"...`,
contextName: 'script',
// Bypassable like a test run: the posture is the user's standing answer, and a form
// it parks on is a card nobody is watching. What the form would have asked for is
// still withheld — a secret or a file opens empty, so an auto-answered run carries
// neither, and the model is told which it left behind.
// it parks on is a card nobody is watching. It still opens when the form is the only
// source for a value the run needs — a secret, a file, a required field the model
// left empty — since the posture answers for consent and not for information.
autoAcceptable: true,
startJob: (submitted) =>
JobService.runScriptByPath({ workspace, path: args.path, requestBody: submitted })