Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/tools/computer-use/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,29 @@ 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 (interactive) calls skip the prompt too.
const { loadInstallState } = await import('./install-state.js');
const state = await loadInstallState(tmpHome);
expect(state?.approvedPackageSpec).toBe(deps.packageSpec);
});

it('persists approval on success', async () => {
const client = makeFakeClient();
await runBootstrap(
Expand Down
30 changes: 24 additions & 6 deletions packages/core/src/tools/computer-use/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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;
}

/** Result of a permission probe. */
Expand Down Expand Up @@ -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) {
// 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);
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,
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/tools/computer-use/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<void>,
config?: Config,
): Promise<void> {
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),
);
}
}
77 changes: 77 additions & 0 deletions packages/core/src/tools/computer-use/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -371,6 +372,82 @@ describe('ComputerUseInvocation confirmation pathway', () => {
const approved = await isPackageSpecApproved(tmpHome, packageSpec);
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.
Comment thread
LaZzyMan marked this conversation as resolved.
// - 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);
},
);

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);
});
});

// ---------------------------------------------------------------------------
Expand Down
23 changes: 19 additions & 4 deletions packages/core/src/tools/computer-use/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand All @@ -39,6 +40,7 @@ class ComputerUseInvocation extends BaseToolInvocation<
constructor(
private readonly upstreamName: ComputerUseToolName,
params: ComputerUseParams,
private readonly config?: Config,
) {
super(params);
}
Expand Down Expand Up @@ -134,9 +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.
// 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 });
// But several approval modes auto-approve the tool call and bypass that

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The same YOLO/AUTO_EDIT/AUTO-bypasses-onConfirm narrative appears in four locations: this block, BootstrapContext.autoApproveInstall JSDoc (bootstrap.ts:46-53), the inline comment inside runBootstrap (bootstrap.ts:228-232), and the registerComputerUseTools JSDoc (index.ts:36-40). When the mechanism changes, four comments need updating in lockstep.

Consider keeping the full explanation in one authoritative location (BootstrapContext.autoApproveInstall JSDoc) and replacing the other three with short references:

// See BootstrapContext.autoApproveInstall for the full rationale.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving the four comments as-is. They sit at four distinct layers (tool execute() / BootstrapContext type / runBootstrap impl / registration), and a short local explanation is more useful where a maintainer actually lands than a see X indirection. Out of scope for this bug fix.

// 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 =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The autoApproveInstall derivation hardcodes three ApprovalMode enum values — a second source of truth for "does the scheduler skip the confirmation dialog?" That question is already answered by needsConfirmation(), isAutoEditApproved(), and the AUTO classifier in coreToolScheduler.ts.

The PR's own history proves the coupling: the initial version only listed YOLO, and reviewers had to catch AUTO_EDIT and AUTO. When a new auto-approve mode is added, this file must be updated manually with no compile-time safety.

Consider extracting a shared helper (e.g., wouldSkipConfirmation(mode, type) in permissionFlow.ts) that both the scheduler and this tool call, or passing a dialogWasShown flag from the scheduler into execute().

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the explicit enumeration. The duplication is intentional and fail-closed: a shared wouldSkipConfirmation(mode, type) helper (or auto-coupling install-approval to dialog-skipping) would mean any future approval mode that skips the dialog would automatically also auto-install a ~50MB desktop-control binary. With the explicit list, a new mode defaults to NOT auto-installing — the safe direction, requiring a deliberate opt-in. Threading a dialogWasShown flag from the scheduler into execute() is a cross-cutting change to tool execution, out of scope for this bug fix.

mode === ApprovalMode.YOLO ||
mode === ApprovalMode.AUTO_EDIT ||
mode === ApprovalMode.AUTO;
await runBootstrap(client, { signal, updateOutput, autoApproveInstall });

let mcpResult: CallToolResult;
try {
Expand Down Expand Up @@ -182,6 +196,7 @@ export class ComputerUseTool extends BaseDeclarativeTool<
constructor(
private readonly upstreamName: ComputerUseToolName,
schema: ComputerUseToolSchema,
private readonly config?: Config,
) {
const qwenName = `computer_use__${upstreamName}`;
super(
Expand Down Expand Up @@ -226,7 +241,7 @@ export class ComputerUseTool extends BaseDeclarativeTool<
protected createInvocation(
params: ComputerUseParams,
): ToolInvocation<ComputerUseParams, ToolResult> {
return new ComputerUseInvocation(this.upstreamName, params);
return new ComputerUseInvocation(this.upstreamName, params, this.config);
}
}

Expand Down
Loading