From 27db07f5873b3ee5175d63a868887f098e6e2a73 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 4 Jun 2026 11:36:00 +0800 Subject: [PATCH 1/3] fix(computer-use): auto-approve install under YOLO instead of declining MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In YOLO mode the tool scheduler auto-approves the tool call and bypasses ComputerUseTool's confirmation dialog, so the dialog's onConfirm — which records install approval — never runs. runBootstrap then reached its headless fallback (promptInstallApproval), which refuses unless QWEN_COMPUTER_USE_AUTO_APPROVE=1, and threw "Computer Use install declined by user" even though the user never declined. Thread Config into ComputerUseTool so execute() can read the approval mode and set a new BootstrapContext.autoApproveInstall flag when YOLO is active. runBootstrap honors it by skipping the prompt and persisting the approval (so later non-YOLO calls also skip it). Non-YOLO behavior is unchanged: the confirmation dialog still records approval, and headless/SDK contexts still use the QWEN_COMPUTER_USE_AUTO_APPROVE fallback. Per-action permission is untouched (getDefaultPermission still returns 'ask'). AUTO (auto-edit) mode is intentionally NOT auto-approved: unlike YOLO's explicit "approve everything", silently installing a ~50MB binary that can control the desktop should still surface the dialog. --- packages/core/src/config/config.ts | 2 +- .../src/tools/computer-use/bootstrap.test.ts | 25 ++++++++++++++ .../core/src/tools/computer-use/bootstrap.ts | 30 ++++++++++++---- packages/core/src/tools/computer-use/index.ts | 10 +++++- .../core/src/tools/computer-use/tool.test.ts | 34 +++++++++++++++++++ packages/core/src/tools/computer-use/tool.ts | 18 +++++++--- 6 files changed, 107 insertions(+), 12 deletions(-) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index f7c8f7c7318..9ae76cf5d74 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -4135,7 +4135,7 @@ export class Config { const { registerComputerUseTools } = await import( '../tools/computer-use/index.js' ); - await registerComputerUseTools(registerLazy); + await registerComputerUseTools(registerLazy, this); } // Register monitor tool diff --git a/packages/core/src/tools/computer-use/bootstrap.test.ts b/packages/core/src/tools/computer-use/bootstrap.test.ts index 5adf7fb351c..d80a10de6be 100644 --- a/packages/core/src/tools/computer-use/bootstrap.test.ts +++ b/packages/core/src/tools/computer-use/bootstrap.test.ts @@ -90,6 +90,31 @@ describe('runBootstrap', () => { expect(client.start).not.toHaveBeenCalled(); }); + it('auto-approves install under YOLO (ctx.autoApproveInstall) without prompting', async () => { + // First use (no install state). In YOLO mode the scheduler bypasses + // the confirmation dialog, so its onConfirm never records approval. + // promptInstallApproval is set to REFUSE here to prove the YOLO path + // skips it entirely rather than coincidentally returning true. + deps.promptInstallApproval = vi.fn(async () => false); + const client = makeFakeClient(); + + await runBootstrap( + client as never, + { signal: new AbortController().signal, autoApproveInstall: true }, + deps, + ); + + // Did not throw "declined", and never consulted the headless prompt. + expect(deps.promptInstallApproval).not.toHaveBeenCalled(); + expect(client.start).toHaveBeenCalledOnce(); + // Approval is persisted so later (non-YOLO) calls skip the prompt too. + const { loadInstallState } = await import('./install-state.js'); + const state = await loadInstallState(tmpHome); + expect(state?.approvedPackageSpec).toBe( + '@qwen-code/open-computer-use@^0.3.0', + ); + }); + it('persists approval on success', async () => { const client = makeFakeClient(); await runBootstrap( diff --git a/packages/core/src/tools/computer-use/bootstrap.ts b/packages/core/src/tools/computer-use/bootstrap.ts index cbbf66b2134..fb1cc6d38d9 100644 --- a/packages/core/src/tools/computer-use/bootstrap.ts +++ b/packages/core/src/tools/computer-use/bootstrap.ts @@ -41,6 +41,16 @@ const execFileAsync = promisify(execFile); export interface BootstrapContext { signal: AbortSignal; updateOutput?: (output: string) => void; + /** + * Treat the first-use install as pre-approved, skipping the + * promptInstallApproval gate. Set by the caller when the active approval + * mode auto-approves tool calls (YOLO): in that mode the scheduler + * bypasses ComputerUseTool's confirmation dialog, so its onConfirm never + * records install approval — without this flag the headless fallback + * below would refuse and throw "install declined by user". The approval + * is still persisted, so later non-YOLO calls also skip the prompt. + */ + autoApproveInstall?: boolean; } /** Result of a permission probe. */ @@ -215,12 +225,20 @@ export async function runBootstrap( // Step 1: install approval gate. const approved = await isPackageSpecApproved(deps.homeDir, deps.packageSpec); if (!approved) { - ctx.updateOutput?.('Computer Use needs to be installed (first use).'); - const ok = await deps.promptInstallApproval(deps.packageSpec); - if (!ok) { - throw new Error( - `Computer Use install declined by user. Re-invoke the tool to be prompted again.`, - ); + if (ctx.autoApproveInstall) { + // YOLO (or equivalent auto-approve mode): the scheduler bypassed the + // confirmation dialog whose onConfirm would have recorded approval, + // so honor the auto-approve intent here instead of falling through to + // the headless prompt (which refuses and throws). + ctx.updateOutput?.('Computer Use install auto-approved (YOLO mode).'); + } else { + ctx.updateOutput?.('Computer Use needs to be installed (first use).'); + const ok = await deps.promptInstallApproval(deps.packageSpec); + if (!ok) { + throw new Error( + `Computer Use install declined by user. Re-invoke the tool to be prompted again.`, + ); + } } await saveInstallState(deps.homeDir, { approvedPackageSpec: deps.packageSpec, diff --git a/packages/core/src/tools/computer-use/index.ts b/packages/core/src/tools/computer-use/index.ts index 3f6fe0ff212..1b8bc5d3658 100644 --- a/packages/core/src/tools/computer-use/index.ts +++ b/packages/core/src/tools/computer-use/index.ts @@ -13,6 +13,7 @@ import { ComputerUseTool } from './tool.js'; import { COMPUTER_USE_SCHEMAS, COMPUTER_USE_TOOL_NAMES } from './schemas.js'; import type { ToolFactory } from '../tool-registry.js'; import type { ToolName } from '../../utils/tool-utils.js'; +import type { Config } from '../../config/config.js'; /** * Register all 9 computer-use tools as lazy factories. Each tool is @@ -30,16 +31,23 @@ import type { ToolName } from '../../utils/tool-utils.js'; * review. * * Should only be called when `Config.isComputerUseEnabled()` is true. + * + * `config` is forwarded to each tool so execute() can read the active + * approval mode. In YOLO the scheduler auto-approves the tool call and skips + * the install-confirmation dialog (whose onConfirm records install approval), + * so the tool must auto-approve the first-use install itself instead of + * letting the bootstrap fallback refuse with "install declined by user". */ export async function registerComputerUseTools( registerLazy: (name: ToolName, factory: ToolFactory) => Promise, + config?: Config, ): Promise { for (const upstreamName of COMPUTER_USE_TOOL_NAMES) { const schema = COMPUTER_USE_SCHEMAS[upstreamName]; const qwenName = `computer_use__${upstreamName}` as ToolName; await registerLazy( qwenName, - async () => new ComputerUseTool(upstreamName, schema), + async () => new ComputerUseTool(upstreamName, schema, config), ); } } diff --git a/packages/core/src/tools/computer-use/tool.test.ts b/packages/core/src/tools/computer-use/tool.test.ts index c0321503a1f..56522fcddda 100644 --- a/packages/core/src/tools/computer-use/tool.test.ts +++ b/packages/core/src/tools/computer-use/tool.test.ts @@ -13,6 +13,7 @@ import { COMPUTER_USE_SCHEMAS } from './schemas.js'; import { saveInstallState, isPackageSpecApproved } from './install-state.js'; import { resolveComputerUsePackageSpec } from './constants.js'; import { ToolConfirmationOutcome } from '../tools.js'; +import { ApprovalMode, type Config } from '../../config/config.js'; import type { Part } from '@google/genai'; function makeFakeClient( @@ -371,6 +372,39 @@ describe('ComputerUseInvocation confirmation pathway', () => { const approved = await isPackageSpecApproved(tmpHome, packageSpec); expect(approved).toBe(true); }); + + it('execute() under YOLO auto-approves install instead of declining (no dialog, no env var)', async () => { + // Reproduces the YOLO bug: the scheduler auto-approves the tool call and + // skips the confirmation dialog, so onConfirm never records install + // approval. With QWEN_COMPUTER_USE_AUTO_APPROVE unset, the bootstrap + // fallback used to refuse and surface "install declined by user". + const fake = makeFakeClient(async () => ({ + content: [{ type: 'text', text: '[]' }], + isError: false, + })); + ComputerUseClient.setSharedForTest(fake); + + const yoloConfig = { + getApprovalMode: () => ApprovalMode.YOLO, + } as unknown as Config; + + const tool = new ComputerUseTool( + 'list_apps', + COMPUTER_USE_SCHEMAS.list_apps, + yoloConfig, + ); + const invocation = tool.build({}); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error).toBeUndefined(); + expect(fake.callTool).toHaveBeenCalledWith('list_apps', {}); + // Approval persisted so later non-YOLO calls also skip the prompt. + const approved = await isPackageSpecApproved( + tmpHome, + resolveComputerUsePackageSpec(), + ); + expect(approved).toBe(true); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/tools/computer-use/tool.ts b/packages/core/src/tools/computer-use/tool.ts index 3f4a8e20a29..64856a47f50 100644 --- a/packages/core/src/tools/computer-use/tool.ts +++ b/packages/core/src/tools/computer-use/tool.ts @@ -23,6 +23,7 @@ import { safeJsonStringify } from '../../utils/safeJsonStringify.js'; import { runBootstrap } from './bootstrap.js'; import { isPackageSpecApproved, saveInstallState } from './install-state.js'; import { resolveComputerUsePackageSpec } from './constants.js'; +import { ApprovalMode, type Config } from '../../config/config.js'; import { homedir } from 'node:os'; type ComputerUseParams = Record; @@ -39,6 +40,7 @@ class ComputerUseInvocation extends BaseToolInvocation< constructor( private readonly upstreamName: ComputerUseToolName, params: ComputerUseParams, + private readonly config?: Config, ) { super(params); } @@ -134,9 +136,16 @@ class ComputerUseInvocation extends BaseToolInvocation< // If the user confirmed through the pre-execution dialog, the install state // was already written by onConfirm — runBootstrap will skip promptInstallApproval. - // For headless / SDK contexts (no dialog), fall back to the env-var path - // already built into bootstrap's default promptInstallApproval. - await runBootstrap(client, { signal, updateOutput }); + // In YOLO mode the scheduler auto-approves the tool call WITHOUT showing + // that dialog (onConfirm never runs), so pass autoApproveInstall to honor + // the auto-approve intent instead of letting bootstrap refuse. For headless + // / SDK contexts (no dialog, no YOLO), it falls back to the env-var path in + // bootstrap's default promptInstallApproval. + await runBootstrap(client, { + signal, + updateOutput, + autoApproveInstall: this.config?.getApprovalMode() === ApprovalMode.YOLO, + }); let mcpResult: CallToolResult; try { @@ -182,6 +191,7 @@ export class ComputerUseTool extends BaseDeclarativeTool< constructor( private readonly upstreamName: ComputerUseToolName, schema: ComputerUseToolSchema, + private readonly config?: Config, ) { const qwenName = `computer_use__${upstreamName}`; super( @@ -226,7 +236,7 @@ export class ComputerUseTool extends BaseDeclarativeTool< protected createInvocation( params: ComputerUseParams, ): ToolInvocation { - return new ComputerUseInvocation(this.upstreamName, params); + return new ComputerUseInvocation(this.upstreamName, params, this.config); } } From f03ca1a5d1863b543f7862288ec57355097820bd Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 4 Jun 2026 16:45:23 +0800 Subject: [PATCH 2/3] fix(computer-use): also auto-approve install under AUTO_EDIT and AUTO The YOLO install-auto-approve fix missed two sibling scheduler paths that trigger the identical "install declined by user" error: AUTO_EDIT auto-approves info-type tools via isAutoEditApproved() (all computer_use__* tools are type 'info'), and AUTO auto-approves classifier-approved calls. Both bypass the confirmation dialog, so its onConfirm never records install approval and runBootstrap's headless fallback refuses. Broaden execute()'s autoApproveInstall to YOLO || AUTO_EDIT || AUTO and parametrize the regression test over all three modes. Also use deps.packageSpec instead of a hard-coded literal in the bootstrap test assertion (review nit). --- .../src/tools/computer-use/bootstrap.test.ts | 6 +- .../core/src/tools/computer-use/bootstrap.ts | 20 +++--- .../core/src/tools/computer-use/tool.test.ts | 70 ++++++++++--------- packages/core/src/tools/computer-use/tool.ts | 25 ++++--- 4 files changed, 65 insertions(+), 56 deletions(-) diff --git a/packages/core/src/tools/computer-use/bootstrap.test.ts b/packages/core/src/tools/computer-use/bootstrap.test.ts index d80a10de6be..4a15962ecdf 100644 --- a/packages/core/src/tools/computer-use/bootstrap.test.ts +++ b/packages/core/src/tools/computer-use/bootstrap.test.ts @@ -107,12 +107,10 @@ describe('runBootstrap', () => { // Did not throw "declined", and never consulted the headless prompt. expect(deps.promptInstallApproval).not.toHaveBeenCalled(); expect(client.start).toHaveBeenCalledOnce(); - // Approval is persisted so later (non-YOLO) calls skip the prompt too. + // Approval is persisted so later (interactive) calls skip the prompt too. const { loadInstallState } = await import('./install-state.js'); const state = await loadInstallState(tmpHome); - expect(state?.approvedPackageSpec).toBe( - '@qwen-code/open-computer-use@^0.3.0', - ); + expect(state?.approvedPackageSpec).toBe(deps.packageSpec); }); it('persists approval on success', async () => { diff --git a/packages/core/src/tools/computer-use/bootstrap.ts b/packages/core/src/tools/computer-use/bootstrap.ts index fb1cc6d38d9..5d977ae9073 100644 --- a/packages/core/src/tools/computer-use/bootstrap.ts +++ b/packages/core/src/tools/computer-use/bootstrap.ts @@ -44,11 +44,11 @@ export interface BootstrapContext { /** * Treat the first-use install as pre-approved, skipping the * promptInstallApproval gate. Set by the caller when the active approval - * mode auto-approves tool calls (YOLO): in that mode the scheduler - * bypasses ComputerUseTool's confirmation dialog, so its onConfirm never - * records install approval — without this flag the headless fallback - * below would refuse and throw "install declined by user". The approval - * is still persisted, so later non-YOLO calls also skip the prompt. + * mode auto-approves tool calls and bypasses ComputerUseTool's confirmation + * dialog (YOLO / AUTO_EDIT / AUTO): in those modes the dialog's onConfirm + * never records install approval, so without this flag the headless + * fallback below would refuse and throw "install declined by user". The + * approval is still persisted, so later interactive calls skip the prompt. */ autoApproveInstall?: boolean; } @@ -226,11 +226,11 @@ export async function runBootstrap( const approved = await isPackageSpecApproved(deps.homeDir, deps.packageSpec); if (!approved) { if (ctx.autoApproveInstall) { - // YOLO (or equivalent auto-approve mode): the scheduler bypassed the - // confirmation dialog whose onConfirm would have recorded approval, - // so honor the auto-approve intent here instead of falling through to - // the headless prompt (which refuses and throws). - ctx.updateOutput?.('Computer Use install auto-approved (YOLO mode).'); + // An auto-approve mode (YOLO / AUTO_EDIT / AUTO) already approved the + // tool call and bypassed the confirmation dialog whose onConfirm would + // have recorded approval, so honor that intent here instead of falling + // through to the headless prompt (which refuses and throws). + ctx.updateOutput?.('Computer Use install auto-approved (approval mode).'); } else { ctx.updateOutput?.('Computer Use needs to be installed (first use).'); const ok = await deps.promptInstallApproval(deps.packageSpec); diff --git a/packages/core/src/tools/computer-use/tool.test.ts b/packages/core/src/tools/computer-use/tool.test.ts index 56522fcddda..48ad2339514 100644 --- a/packages/core/src/tools/computer-use/tool.test.ts +++ b/packages/core/src/tools/computer-use/tool.test.ts @@ -373,38 +373,44 @@ describe('ComputerUseInvocation confirmation pathway', () => { expect(approved).toBe(true); }); - it('execute() under YOLO auto-approves install instead of declining (no dialog, no env var)', async () => { - // Reproduces the YOLO bug: the scheduler auto-approves the tool call and - // skips the confirmation dialog, so onConfirm never records install - // approval. With QWEN_COMPUTER_USE_AUTO_APPROVE unset, the bootstrap - // fallback used to refuse and surface "install declined by user". - const fake = makeFakeClient(async () => ({ - content: [{ type: 'text', text: '[]' }], - isError: false, - })); - ComputerUseClient.setSharedForTest(fake); - - const yoloConfig = { - getApprovalMode: () => ApprovalMode.YOLO, - } as unknown as Config; - - const tool = new ComputerUseTool( - 'list_apps', - COMPUTER_USE_SCHEMAS.list_apps, - yoloConfig, - ); - const invocation = tool.build({}); - const result = await invocation.execute(new AbortController().signal); - - expect(result.error).toBeUndefined(); - expect(fake.callTool).toHaveBeenCalledWith('list_apps', {}); - // Approval persisted so later non-YOLO calls also skip the prompt. - const approved = await isPackageSpecApproved( - tmpHome, - resolveComputerUsePackageSpec(), - ); - expect(approved).toBe(true); - }); + // Every approval mode where the scheduler auto-approves the tool call and + // bypasses the confirmation dialog — so its onConfirm never records install + // approval. With QWEN_COMPUTER_USE_AUTO_APPROVE unset, the bootstrap fallback + // used to refuse and surface "install declined by user": + // - YOLO → needsConfirmation() returns false, dialog never built. + // - AUTO_EDIT → isAutoEditApproved() approves info-type tools, skips onConfirm. + // - AUTO → classifier-approved calls skip onConfirm. + it.each([ApprovalMode.YOLO, ApprovalMode.AUTO_EDIT, ApprovalMode.AUTO])( + 'execute() under %s auto-approves install instead of declining (no dialog, no env var)', + async (mode) => { + const fake = makeFakeClient(async () => ({ + content: [{ type: 'text', text: '[]' }], + isError: false, + })); + ComputerUseClient.setSharedForTest(fake); + + const config = { + getApprovalMode: () => mode, + } as unknown as Config; + + const tool = new ComputerUseTool( + 'list_apps', + COMPUTER_USE_SCHEMAS.list_apps, + config, + ); + const invocation = tool.build({}); + const result = await invocation.execute(new AbortController().signal); + + expect(result.error).toBeUndefined(); + expect(fake.callTool).toHaveBeenCalledWith('list_apps', {}); + // Approval persisted so later (interactive) calls also skip the prompt. + const approved = await isPackageSpecApproved( + tmpHome, + resolveComputerUsePackageSpec(), + ); + expect(approved).toBe(true); + }, + ); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/tools/computer-use/tool.ts b/packages/core/src/tools/computer-use/tool.ts index 64856a47f50..ea051869047 100644 --- a/packages/core/src/tools/computer-use/tool.ts +++ b/packages/core/src/tools/computer-use/tool.ts @@ -136,16 +136,21 @@ class ComputerUseInvocation extends BaseToolInvocation< // If the user confirmed through the pre-execution dialog, the install state // was already written by onConfirm — runBootstrap will skip promptInstallApproval. - // In YOLO mode the scheduler auto-approves the tool call WITHOUT showing - // that dialog (onConfirm never runs), so pass autoApproveInstall to honor - // the auto-approve intent instead of letting bootstrap refuse. For headless - // / SDK contexts (no dialog, no YOLO), it falls back to the env-var path in - // bootstrap's default promptInstallApproval. - await runBootstrap(client, { - signal, - updateOutput, - autoApproveInstall: this.config?.getApprovalMode() === ApprovalMode.YOLO, - }); + // But several approval modes auto-approve the tool call and bypass that + // dialog entirely (so onConfirm never runs and install state is never + // written): YOLO (needsConfirmation() returns false), AUTO_EDIT + // (isAutoEditApproved() auto-approves info-type tools — all computer_use__* + // tools are info), and AUTO (classifier-approved calls). In those modes + // pass autoApproveInstall so the bootstrap honors the already-granted call + // approval instead of refusing with "install declined by user". DEFAULT + // still shows the dialog; PLAN blocks. Headless / SDK contexts (no config) + // fall back to the env-var path in bootstrap's default promptInstallApproval. + const mode = this.config?.getApprovalMode(); + const autoApproveInstall = + mode === ApprovalMode.YOLO || + mode === ApprovalMode.AUTO_EDIT || + mode === ApprovalMode.AUTO; + await runBootstrap(client, { signal, updateOutput, autoApproveInstall }); let mcpResult: CallToolResult; try { From 8e7866e3878b37dea5e566df4329aa28a116595a Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Fri, 5 Jun 2026 00:42:03 +0800 Subject: [PATCH 3/3] test(computer-use): guard DEFAULT mode against install auto-approve widening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a negative test asserting execute() under ApprovalMode.DEFAULT still gates the first-use install. The it.each only covered the true-branch (YOLO/AUTO_EDIT/AUTO); this locks the false-branch so a future widening of the condition (e.g. `mode !== PLAN`) — which would silently auto-install a desktop-control binary under DEFAULT — fails CI. Verified the guard catches that exact regression before landing. --- .../core/src/tools/computer-use/tool.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/core/src/tools/computer-use/tool.test.ts b/packages/core/src/tools/computer-use/tool.test.ts index 48ad2339514..6ff6d0ec4b7 100644 --- a/packages/core/src/tools/computer-use/tool.test.ts +++ b/packages/core/src/tools/computer-use/tool.test.ts @@ -411,6 +411,43 @@ describe('ComputerUseInvocation confirmation pathway', () => { expect(approved).toBe(true); }, ); + + it('execute() under DEFAULT mode does NOT auto-approve install (still gated)', async () => { + // Negative guard for the false-branch of autoApproveInstall: DEFAULT (and + // PLAN) must NOT be auto-approved — DEFAULT shows the install dialog. If the + // condition were ever widened (e.g. `mode !== ApprovalMode.PLAN`), DEFAULT + // would silently auto-install a desktop-control binary; this test locks + // that lower boundary so such a regression fails CI. + delete process.env['QWEN_COMPUTER_USE_AUTO_APPROVE']; + const fake = makeFakeClient(async () => ({ + content: [{ type: 'text', text: '[]' }], + isError: false, + })); + ComputerUseClient.setSharedForTest(fake); + + const config = { + getApprovalMode: () => ApprovalMode.DEFAULT, + } as unknown as Config; + + const tool = new ComputerUseTool( + 'list_apps', + COMPUTER_USE_SCHEMAS.list_apps, + config, + ); + const invocation = tool.build({}); + + // No install state + no env var → bootstrap's headless fallback refuses + // rather than auto-approving. + await expect( + invocation.execute(new AbortController().signal), + ).rejects.toThrow(/declined/i); + expect(fake.callTool).not.toHaveBeenCalled(); + const approved = await isPackageSpecApproved( + tmpHome, + resolveComputerUsePackageSpec(), + ); + expect(approved).toBe(false); + }); }); // ---------------------------------------------------------------------------