From adabe623bb78c8e8bab9ab509cd45ac4e676c285 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Wed, 29 Jul 2026 09:22:16 -0700 Subject: [PATCH 1/8] fix(code-executors): detect pwsh as a PowerShell shell command The SHELL branch of UnsafeLocalCodeExecutor selected PowerShell spawn arguments with a substring test against `powershell`, so PowerShell 7+ (`pwsh`) was invoked without `-NoLogo -ExecutionPolicy Bypass -File` and its script was written with a `.sh` extension, which PowerShell refuses to run. The same substring test also misclassified unrelated commands whose path merely contains `powershell`. Detect PowerShell hosts on the executable name only (`powershell`/`pwsh`, case-insensitive, with or without `.exe`, either path separator) and use that for both the spawn arguments and the script extension. --- .../unsafe_local_code_executor.ts | 37 +++++++-- .../unsafe_local_code_executor_test.ts | 81 ++++++++++++++++++- 2 files changed, 111 insertions(+), 7 deletions(-) diff --git a/core/src/code_executors/unsafe_local_code_executor.ts b/core/src/code_executors/unsafe_local_code_executor.ts index ed57cd14a..fc59294f8 100644 --- a/core/src/code_executors/unsafe_local_code_executor.ts +++ b/core/src/code_executors/unsafe_local_code_executor.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {spawn} from 'child_process'; +import {spawn} from 'node:child_process'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -20,6 +20,23 @@ import { const IS_WINDOWS = os.platform() === 'win32'; +/** Executable names of PowerShell hosts, without any `.exe` suffix. */ +const POWERSHELL_COMMAND_NAMES = new Set(['powershell', 'pwsh']); + +/** + * Whether `commandPath` refers to a PowerShell host. + * + * Matches Windows PowerShell (`powershell`) and PowerShell 7+ (`pwsh`) on the + * executable name only, so that unrelated commands whose path merely contains + * the word (for example `/opt/pwsh-tools/bin/bash`) are not misdetected. + */ +function isPowerShellCommand(commandPath: string): boolean { + const commandName = commandPath.split(/[\\/]/).pop() ?? ''; + return POWERSHELL_COMMAND_NAMES.has( + commandName.toLowerCase().replace(/\.exe$/, ''), + ); +} + /** * Options for UnsafeLocalCodeExecutor. */ @@ -38,6 +55,11 @@ export interface UnsafeLocalCodeExecutorOptions { pythonCommandPath?: string; /** * The command to run Shell code. Default is `bash`. + * + * When the command names a PowerShell host (`powershell` or `pwsh`, with or + * without an `.exe` suffix) the script is written as a `.ps1` file and + * invoked with `-NoLogo -ExecutionPolicy Bypass -File`. When it names `cmd` + * the script is invoked with `/c`. */ shellCommandPath?: string; } @@ -45,7 +67,7 @@ export interface UnsafeLocalCodeExecutorOptions { async function createTempScriptFile( code: string, language: CodeExecutionLanguage, - shellCommandPath?: string, + shellCommandPath: string, ): Promise<{filePath: string; tempDir: string}> { const tempDir = path.join( os.tmpdir(), @@ -63,7 +85,7 @@ async function createTempScriptFile( function getExtensionForLanguage( language: CodeExecutionLanguage, - shellCommandPath?: string, + shellCommandPath: string, ): string | undefined { if (language === CodeExecutionLanguage.JAVASCRIPT) { return '.js'; @@ -82,8 +104,11 @@ function getExtensionForLanguage( } if (language === CodeExecutionLanguage.SHELL) { + if (isPowerShellCommand(shellCommandPath)) { + return '.ps1'; + } if (IS_WINDOWS) { - if (shellCommandPath && shellCommandPath.toLowerCase().includes('cmd')) { + if (shellCommandPath.toLowerCase().includes('cmd')) { return '.bat'; } return '.ps1'; @@ -101,7 +126,7 @@ function getExtensionForLanguage( * **Execution Details**: * - **JavaScript**: Executed via `node` (defaults to `process.execPath`). * - **Python**: Executed via `python3` on Unix, and `python` on Windows. - * - **Shell**: Executed via `bash` on Unix, and defaults to `powershell` (injecting ExecutionPolicy Bypass) or `cmd.exe` on Windows. + * - **Shell**: Executed via `bash` on Unix, and defaults to `powershell` or `cmd.exe` on Windows. A `shellCommandPath` naming a PowerShell host (`powershell` or `pwsh`) runs a `.ps1` script with ExecutionPolicy Bypass injected. * * WARNING: This executor runs code in the local environment without sandboxing or security restrictions. * Use with caution and only for trusted code. @@ -171,7 +196,7 @@ export class UnsafeLocalCodeExecutor extends BaseCodeExecutor { command = this.pythonCommandPath; } else if (language === CodeExecutionLanguage.SHELL) { command = this.shellCommandPath; - if (this.shellCommandPath.toLowerCase().includes('powershell')) { + if (isPowerShellCommand(this.shellCommandPath)) { args = ['-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', filePath]; } else if (this.shellCommandPath.toLowerCase().includes('cmd')) { args = ['/c', filePath]; diff --git a/core/test/code_executors/unsafe_local_code_executor_test.ts b/core/test/code_executors/unsafe_local_code_executor_test.ts index c7ef61df6..4ef7eeb1d 100644 --- a/core/test/code_executors/unsafe_local_code_executor_test.ts +++ b/core/test/code_executors/unsafe_local_code_executor_test.ts @@ -13,7 +13,29 @@ import { UnsafeLocalCodeExecutor, createSession, } from '@google/adk'; -import {beforeEach, describe, expect, it} from 'vitest'; +import type {SpawnOptions} from 'node:child_process'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +const {spawnSpy} = vi.hoisted(() => ({ + spawnSpy: vi.fn<(command: string, args: readonly string[]) => void>(), +})); + +// Records the spawn arguments while still delegating to the real `spawn`, so +// the surrounding tests keep executing actual child processes. +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: ( + command: string, + args: readonly string[], + options: SpawnOptions, + ) => { + spawnSpy(command, args); + return actual.spawn(command, args, options); + }, + }; +}); function createMockInvocationContext(): InvocationContext { const agent = new LlmAgent({ @@ -308,4 +330,61 @@ describe('UnsafeLocalCodeExecutor', () => { expect(result.outputFiles![0].contentEncoding).toBe('utf-8'); expect(result.outputFiles![0].mimeType).toBe('application/json'); }); + + describe('shell command detection', () => { + const POWERSHELL_FLAGS = ['-NoLogo', '-ExecutionPolicy', 'Bypass', '-File']; + + async function captureShellSpawn(shellCommandPath: string) { + spawnSpy.mockClear(); + const customExecutor = new UnsafeLocalCodeExecutor({shellCommandPath}); + await customExecutor.executeCode({ + invocationContext, + codeExecutionInput: { + code: 'echo "test"', + language: CodeExecutionLanguage.SHELL, + inputFiles: [], + }, + }); + expect(spawnSpy).toHaveBeenCalledTimes(1); + const [command, args] = spawnSpy.mock.calls[0]; + return {command, args}; + } + + it.each([ + 'pwsh', + 'pwsh.exe', + '/usr/bin/pwsh', + 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + 'PWSH', + 'powershell', + 'powershell.exe', + ])('runs a .ps1 script with PowerShell flags for %s', async (shell) => { + const {command, args} = await captureShellSpawn(shell); + + expect(command).toBe(shell); + expect(args).toEqual(expect.arrayContaining(POWERSHELL_FLAGS)); + expect(args.at(-1)).toMatch(/script\.ps1$/); + }); + + it.each([ + '/opt/pwsh-tools/bin/bash', + '/usr/local/powershell-helpers/run.sh', + 'bash', + ])('does not treat %s as PowerShell', async (shell) => { + const {command, args} = await captureShellSpawn(shell); + + expect(command).toBe(shell); + expect(args).toHaveLength(1); + expect(args).not.toContain('-NoLogo'); + }); + + it.each(['cmd', 'cmd.exe'])('invokes %s with /c', async (shell) => { + const {command, args} = await captureShellSpawn(shell); + + expect(command).toBe(shell); + expect(args).toHaveLength(2); + expect(args[0]).toBe('/c'); + expect(args).not.toContain('-NoLogo'); + }); + }); }); From 08d386b94890d52ae49ba0ba199390d90a70ef73 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Wed, 29 Jul 2026 09:30:45 -0700 Subject: [PATCH 2/8] refactor(code-executors): simplify PowerShell command detection Use path.win32.basename instead of a hand-rolled separator split (it splits on both separators on every platform), drop the two-element Set in favour of a direct comparison, and derive the spawn passthrough types in the test from the real spawn signature. --- .../unsafe_local_code_executor.ts | 29 +++++++++---------- .../unsafe_local_code_executor_test.ts | 24 ++++++--------- 2 files changed, 23 insertions(+), 30 deletions(-) diff --git a/core/src/code_executors/unsafe_local_code_executor.ts b/core/src/code_executors/unsafe_local_code_executor.ts index fc59294f8..d9510cd9e 100644 --- a/core/src/code_executors/unsafe_local_code_executor.ts +++ b/core/src/code_executors/unsafe_local_code_executor.ts @@ -20,21 +20,21 @@ import { const IS_WINDOWS = os.platform() === 'win32'; -/** Executable names of PowerShell hosts, without any `.exe` suffix. */ -const POWERSHELL_COMMAND_NAMES = new Set(['powershell', 'pwsh']); - /** - * Whether `commandPath` refers to a PowerShell host. + * Whether `commandPath` names Windows PowerShell (`powershell`) or + * PowerShell 7+ (`pwsh`). * - * Matches Windows PowerShell (`powershell`) and PowerShell 7+ (`pwsh`) on the - * executable name only, so that unrelated commands whose path merely contains - * the word (for example `/opt/pwsh-tools/bin/bash`) are not misdetected. + * Only the executable name is matched, so unrelated commands whose path merely + * contains the word (for example `/opt/pwsh-tools/bin/bash`) are not + * misdetected. `path.win32` is used because it splits on both separators on + * every platform. */ function isPowerShellCommand(commandPath: string): boolean { - const commandName = commandPath.split(/[\\/]/).pop() ?? ''; - return POWERSHELL_COMMAND_NAMES.has( - commandName.toLowerCase().replace(/\.exe$/, ''), - ); + const name = path.win32 + .basename(commandPath) + .toLowerCase() + .replace(/\.exe$/, ''); + return name === 'powershell' || name === 'pwsh'; } /** @@ -56,10 +56,9 @@ export interface UnsafeLocalCodeExecutorOptions { /** * The command to run Shell code. Default is `bash`. * - * When the command names a PowerShell host (`powershell` or `pwsh`, with or - * without an `.exe` suffix) the script is written as a `.ps1` file and - * invoked with `-NoLogo -ExecutionPolicy Bypass -File`. When it names `cmd` - * the script is invoked with `/c`. + * When it names `powershell` or `pwsh` (with or without an `.exe` suffix) + * the script is written as `.ps1` and invoked with + * `-NoLogo -ExecutionPolicy Bypass -File`. */ shellCommandPath?: string; } diff --git a/core/test/code_executors/unsafe_local_code_executor_test.ts b/core/test/code_executors/unsafe_local_code_executor_test.ts index 4ef7eeb1d..61e950f86 100644 --- a/core/test/code_executors/unsafe_local_code_executor_test.ts +++ b/core/test/code_executors/unsafe_local_code_executor_test.ts @@ -13,11 +13,12 @@ import { UnsafeLocalCodeExecutor, createSession, } from '@google/adk'; -import type {SpawnOptions} from 'node:child_process'; import {beforeEach, describe, expect, it, vi} from 'vitest'; +type SpawnArgs = Parameters; + const {spawnSpy} = vi.hoisted(() => ({ - spawnSpy: vi.fn<(command: string, args: readonly string[]) => void>(), + spawnSpy: vi.fn<(...args: SpawnArgs) => void>(), })); // Records the spawn arguments while still delegating to the real `spawn`, so @@ -26,13 +27,9 @@ vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - spawn: ( - command: string, - args: readonly string[], - options: SpawnOptions, - ) => { - spawnSpy(command, args); - return actual.spawn(command, args, options); + spawn: (...args: SpawnArgs) => { + spawnSpy(...args); + return actual.spawn(...args); }, }; }); @@ -371,19 +368,16 @@ describe('UnsafeLocalCodeExecutor', () => { '/usr/local/powershell-helpers/run.sh', 'bash', ])('does not treat %s as PowerShell', async (shell) => { - const {command, args} = await captureShellSpawn(shell); + const {args} = await captureShellSpawn(shell); - expect(command).toBe(shell); expect(args).toHaveLength(1); expect(args).not.toContain('-NoLogo'); }); it.each(['cmd', 'cmd.exe'])('invokes %s with /c', async (shell) => { - const {command, args} = await captureShellSpawn(shell); + const {args} = await captureShellSpawn(shell); - expect(command).toBe(shell); - expect(args).toHaveLength(2); - expect(args[0]).toBe('/c'); + expect(args).toEqual(['/c', expect.any(String)]); expect(args).not.toContain('-NoLogo'); }); }); From 271bf5e0be59a513e2b6899d263d6faad64fae48 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Wed, 29 Jul 2026 09:38:42 -0700 Subject: [PATCH 3/8] refactor(code-executors): tighten PowerShell detection and its tests Collapse the name check into a single anchored regex and drop assertions that restate behaviour already covered elsewhere in the file. --- .../unsafe_local_code_executor.ts | 19 ++++++------------- .../unsafe_local_code_executor_test.ts | 12 ++++-------- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/core/src/code_executors/unsafe_local_code_executor.ts b/core/src/code_executors/unsafe_local_code_executor.ts index d9510cd9e..6af3a29a5 100644 --- a/core/src/code_executors/unsafe_local_code_executor.ts +++ b/core/src/code_executors/unsafe_local_code_executor.ts @@ -21,20 +21,13 @@ import { const IS_WINDOWS = os.platform() === 'win32'; /** - * Whether `commandPath` names Windows PowerShell (`powershell`) or - * PowerShell 7+ (`pwsh`). - * - * Only the executable name is matched, so unrelated commands whose path merely - * contains the word (for example `/opt/pwsh-tools/bin/bash`) are not - * misdetected. `path.win32` is used because it splits on both separators on - * every platform. + * Whether `commandPath` names Windows PowerShell (`powershell`) or PowerShell + * 7+ (`pwsh`), with or without an `.exe` suffix. Only the executable name is + * matched, so `/opt/pwsh-tools/bin/bash` is not misdetected. `path.win32` + * splits on both separators on every platform. */ function isPowerShellCommand(commandPath: string): boolean { - const name = path.win32 - .basename(commandPath) - .toLowerCase() - .replace(/\.exe$/, ''); - return name === 'powershell' || name === 'pwsh'; + return /^(powershell|pwsh)(\.exe)?$/i.test(path.win32.basename(commandPath)); } /** @@ -125,7 +118,7 @@ function getExtensionForLanguage( * **Execution Details**: * - **JavaScript**: Executed via `node` (defaults to `process.execPath`). * - **Python**: Executed via `python3` on Unix, and `python` on Windows. - * - **Shell**: Executed via `bash` on Unix, and defaults to `powershell` or `cmd.exe` on Windows. A `shellCommandPath` naming a PowerShell host (`powershell` or `pwsh`) runs a `.ps1` script with ExecutionPolicy Bypass injected. + * - **Shell**: Executed via `bash` on Unix, and defaults to `powershell` or `cmd.exe` on Windows. A `powershell` or `pwsh` command injects ExecutionPolicy Bypass. * * WARNING: This executor runs code in the local environment without sandboxing or security restrictions. * Use with caution and only for trusted code. diff --git a/core/test/code_executors/unsafe_local_code_executor_test.ts b/core/test/code_executors/unsafe_local_code_executor_test.ts index 61e950f86..969b5a35b 100644 --- a/core/test/code_executors/unsafe_local_code_executor_test.ts +++ b/core/test/code_executors/unsafe_local_code_executor_test.ts @@ -343,8 +343,7 @@ describe('UnsafeLocalCodeExecutor', () => { }, }); expect(spawnSpy).toHaveBeenCalledTimes(1); - const [command, args] = spawnSpy.mock.calls[0]; - return {command, args}; + return spawnSpy.mock.calls[0][1]; } it.each([ @@ -356,9 +355,8 @@ describe('UnsafeLocalCodeExecutor', () => { 'powershell', 'powershell.exe', ])('runs a .ps1 script with PowerShell flags for %s', async (shell) => { - const {command, args} = await captureShellSpawn(shell); + const args = await captureShellSpawn(shell); - expect(command).toBe(shell); expect(args).toEqual(expect.arrayContaining(POWERSHELL_FLAGS)); expect(args.at(-1)).toMatch(/script\.ps1$/); }); @@ -368,17 +366,15 @@ describe('UnsafeLocalCodeExecutor', () => { '/usr/local/powershell-helpers/run.sh', 'bash', ])('does not treat %s as PowerShell', async (shell) => { - const {args} = await captureShellSpawn(shell); + const args = await captureShellSpawn(shell); expect(args).toHaveLength(1); - expect(args).not.toContain('-NoLogo'); }); it.each(['cmd', 'cmd.exe'])('invokes %s with /c', async (shell) => { - const {args} = await captureShellSpawn(shell); + const args = await captureShellSpawn(shell); expect(args).toEqual(['/c', expect.any(String)]); - expect(args).not.toContain('-NoLogo'); }); }); }); From f9645275942e456f118660ca89648eb9b5271b17 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Wed, 29 Jul 2026 09:47:12 -0700 Subject: [PATCH 4/8] test(code-executors): use vitest autospy for the spawn recorder Replace the hand-written passthrough mock factory with vi.mock(..., {spy: true}), which wraps the real export without replacing its implementation, and pin -File to the argument before the script path. --- .../unsafe_local_code_executor.ts | 9 +++---- .../unsafe_local_code_executor_test.ts | 25 +++++-------------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/core/src/code_executors/unsafe_local_code_executor.ts b/core/src/code_executors/unsafe_local_code_executor.ts index 6af3a29a5..0dc1473fd 100644 --- a/core/src/code_executors/unsafe_local_code_executor.ts +++ b/core/src/code_executors/unsafe_local_code_executor.ts @@ -22,9 +22,7 @@ const IS_WINDOWS = os.platform() === 'win32'; /** * Whether `commandPath` names Windows PowerShell (`powershell`) or PowerShell - * 7+ (`pwsh`), with or without an `.exe` suffix. Only the executable name is - * matched, so `/opt/pwsh-tools/bin/bash` is not misdetected. `path.win32` - * splits on both separators on every platform. + * 7+ (`pwsh`). `path.win32` splits on both separators on every platform. */ function isPowerShellCommand(commandPath: string): boolean { return /^(powershell|pwsh)(\.exe)?$/i.test(path.win32.basename(commandPath)); @@ -49,9 +47,8 @@ export interface UnsafeLocalCodeExecutorOptions { /** * The command to run Shell code. Default is `bash`. * - * When it names `powershell` or `pwsh` (with or without an `.exe` suffix) - * the script is written as `.ps1` and invoked with - * `-NoLogo -ExecutionPolicy Bypass -File`. + * When it names `powershell` or `pwsh` (with or without `.exe`) the script + * is written as `.ps1` and run with `-NoLogo -ExecutionPolicy Bypass -File`. */ shellCommandPath?: string; } diff --git a/core/test/code_executors/unsafe_local_code_executor_test.ts b/core/test/code_executors/unsafe_local_code_executor_test.ts index 969b5a35b..41a4514c1 100644 --- a/core/test/code_executors/unsafe_local_code_executor_test.ts +++ b/core/test/code_executors/unsafe_local_code_executor_test.ts @@ -13,26 +13,13 @@ import { UnsafeLocalCodeExecutor, createSession, } from '@google/adk'; +import * as childProcess from 'node:child_process'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -type SpawnArgs = Parameters; - -const {spawnSpy} = vi.hoisted(() => ({ - spawnSpy: vi.fn<(...args: SpawnArgs) => void>(), -})); - -// Records the spawn arguments while still delegating to the real `spawn`, so -// the surrounding tests keep executing actual child processes. -vi.mock('node:child_process', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - spawn: (...args: SpawnArgs) => { - spawnSpy(...args); - return actual.spawn(...args); - }, - }; -}); +// Records the spawn arguments while still running the real `spawn`, so the +// surrounding tests keep executing actual child processes. +vi.mock('node:child_process', {spy: true}); +const spawnSpy = vi.mocked(childProcess.spawn); function createMockInvocationContext(): InvocationContext { const agent = new LlmAgent({ @@ -358,13 +345,13 @@ describe('UnsafeLocalCodeExecutor', () => { const args = await captureShellSpawn(shell); expect(args).toEqual(expect.arrayContaining(POWERSHELL_FLAGS)); + expect(args.at(-2)).toBe('-File'); expect(args.at(-1)).toMatch(/script\.ps1$/); }); it.each([ '/opt/pwsh-tools/bin/bash', '/usr/local/powershell-helpers/run.sh', - 'bash', ])('does not treat %s as PowerShell', async (shell) => { const args = await captureShellSpawn(shell); From 2f6ba68547be73c1d7eb2a67e545f89cf83cdd5d Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Wed, 29 Jul 2026 09:59:29 -0700 Subject: [PATCH 5/8] test(code-executors): allow for PowerShell cold start in shell detection CI runners that ship PowerShell really launch it for these cases, and the first launch on a cold runner exceeded the default 5s test timeout. --- core/test/code_executors/unsafe_local_code_executor_test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/test/code_executors/unsafe_local_code_executor_test.ts b/core/test/code_executors/unsafe_local_code_executor_test.ts index 41a4514c1..f56f5c849 100644 --- a/core/test/code_executors/unsafe_local_code_executor_test.ts +++ b/core/test/code_executors/unsafe_local_code_executor_test.ts @@ -315,7 +315,9 @@ describe('UnsafeLocalCodeExecutor', () => { expect(result.outputFiles![0].mimeType).toBe('application/json'); }); - describe('shell command detection', () => { + // Runners that ship PowerShell really launch it, and a cold start there + // exceeds the default 5s test timeout. + describe('shell command detection', {timeout: 30_000}, () => { const POWERSHELL_FLAGS = ['-NoLogo', '-ExecutionPolicy', 'Bypass', '-File']; async function captureShellSpawn(shellCommandPath: string) { From 449368a187ebe94f98d6cad70c92146f2eb8a4d9 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 30 Jul 2026 22:09:48 -0700 Subject: [PATCH 6/8] fix(code-executors): keep shellCommandPath helper params optional Reverts an unrelated signature tightening so the diff stays scoped to the pwsh detection fix, per review feedback. The new PowerShell check guards against undefined the same way the cmd check on the next line does. --- core/src/code_executors/unsafe_local_code_executor.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/code_executors/unsafe_local_code_executor.ts b/core/src/code_executors/unsafe_local_code_executor.ts index 0dc1473fd..be858149d 100644 --- a/core/src/code_executors/unsafe_local_code_executor.ts +++ b/core/src/code_executors/unsafe_local_code_executor.ts @@ -56,7 +56,7 @@ export interface UnsafeLocalCodeExecutorOptions { async function createTempScriptFile( code: string, language: CodeExecutionLanguage, - shellCommandPath: string, + shellCommandPath?: string, ): Promise<{filePath: string; tempDir: string}> { const tempDir = path.join( os.tmpdir(), @@ -74,7 +74,7 @@ async function createTempScriptFile( function getExtensionForLanguage( language: CodeExecutionLanguage, - shellCommandPath: string, + shellCommandPath?: string, ): string | undefined { if (language === CodeExecutionLanguage.JAVASCRIPT) { return '.js'; @@ -93,11 +93,11 @@ function getExtensionForLanguage( } if (language === CodeExecutionLanguage.SHELL) { - if (isPowerShellCommand(shellCommandPath)) { + if (shellCommandPath && isPowerShellCommand(shellCommandPath)) { return '.ps1'; } if (IS_WINDOWS) { - if (shellCommandPath.toLowerCase().includes('cmd')) { + if (shellCommandPath && shellCommandPath.toLowerCase().includes('cmd')) { return '.bat'; } return '.ps1'; From 24a1be29b76430f0ef869e1f63d6420a3acfead9 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Fri, 31 Jul 2026 15:06:45 -0700 Subject: [PATCH 7/8] chore: park pwsh detection files at upstream state for merge Temporary: lets the upstream merge complete without conflicts so the merge auto-commit does not trip the pre-commit hook over unrelated files. The fix is restored in the next commit. --- .../unsafe_local_code_executor.ts | 38 ++-- .../unsafe_local_code_executor_test.ts | 162 +++++++++++++----- 2 files changed, 142 insertions(+), 58 deletions(-) diff --git a/core/src/code_executors/unsafe_local_code_executor.ts b/core/src/code_executors/unsafe_local_code_executor.ts index be858149d..97061719a 100644 --- a/core/src/code_executors/unsafe_local_code_executor.ts +++ b/core/src/code_executors/unsafe_local_code_executor.ts @@ -21,12 +21,22 @@ import { const IS_WINDOWS = os.platform() === 'win32'; /** - * Whether `commandPath` names Windows PowerShell (`powershell`) or PowerShell - * 7+ (`pwsh`). `path.win32` splits on both separators on every platform. + * Prepended to every PowerShell invocation; `-NoProfile` keeps ambient profile + * state (PATH, aliases, preference variables, stray output) out of the script. */ -function isPowerShellCommand(commandPath: string): boolean { - return /^(powershell|pwsh)(\.exe)?$/i.test(path.win32.basename(commandPath)); -} +const POWERSHELL_BASE_ARGS = [ + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', +] as const; + +/** + * Prepended to every cmd.exe invocation; `/D` skips the registry AutoRun + * commands, the `-NoProfile` analogue. + */ +const CMD_BASE_ARGS = ['/D', '/c'] as const; /** * Options for UnsafeLocalCodeExecutor. @@ -46,9 +56,6 @@ export interface UnsafeLocalCodeExecutorOptions { pythonCommandPath?: string; /** * The command to run Shell code. Default is `bash`. - * - * When it names `powershell` or `pwsh` (with or without `.exe`) the script - * is written as `.ps1` and run with `-NoLogo -ExecutionPolicy Bypass -File`. */ shellCommandPath?: string; } @@ -93,9 +100,6 @@ function getExtensionForLanguage( } if (language === CodeExecutionLanguage.SHELL) { - if (shellCommandPath && isPowerShellCommand(shellCommandPath)) { - return '.ps1'; - } if (IS_WINDOWS) { if (shellCommandPath && shellCommandPath.toLowerCase().includes('cmd')) { return '.bat'; @@ -115,7 +119,7 @@ function getExtensionForLanguage( * **Execution Details**: * - **JavaScript**: Executed via `node` (defaults to `process.execPath`). * - **Python**: Executed via `python3` on Unix, and `python` on Windows. - * - **Shell**: Executed via `bash` on Unix, and defaults to `powershell` or `cmd.exe` on Windows. A `powershell` or `pwsh` command injects ExecutionPolicy Bypass. + * - **Shell**: Executed via `bash` on Unix, and defaults to `powershell` (injecting `-NoProfile` and `-ExecutionPolicy Bypass`) or `cmd.exe` (injecting `/D`) on Windows. * * WARNING: This executor runs code in the local environment without sandboxing or security restrictions. * Use with caution and only for trusted code. @@ -185,17 +189,17 @@ export class UnsafeLocalCodeExecutor extends BaseCodeExecutor { command = this.pythonCommandPath; } else if (language === CodeExecutionLanguage.SHELL) { command = this.shellCommandPath; - if (isPowerShellCommand(this.shellCommandPath)) { - args = ['-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', filePath]; + if (this.shellCommandPath.toLowerCase().includes('powershell')) { + args = [...POWERSHELL_BASE_ARGS, filePath]; } else if (this.shellCommandPath.toLowerCase().includes('cmd')) { - args = ['/c', filePath]; + args = [...CMD_BASE_ARGS, filePath]; } } else if (language === CodeExecutionLanguage.POWERSHELL) { command = IS_WINDOWS ? 'powershell' : 'pwsh'; - args = ['-NoLogo', '-ExecutionPolicy', 'Bypass', '-File', filePath]; + args = [...POWERSHELL_BASE_ARGS, filePath]; } else if (language === CodeExecutionLanguage.WINDOWS_CMD) { command = 'cmd.exe'; - args = ['/c', filePath]; + args = [...CMD_BASE_ARGS, filePath]; } if (params.codeExecutionInput.args) { diff --git a/core/test/code_executors/unsafe_local_code_executor_test.ts b/core/test/code_executors/unsafe_local_code_executor_test.ts index f56f5c849..82a0d2d01 100644 --- a/core/test/code_executors/unsafe_local_code_executor_test.ts +++ b/core/test/code_executors/unsafe_local_code_executor_test.ts @@ -13,13 +13,37 @@ import { UnsafeLocalCodeExecutor, createSession, } from '@google/adk'; -import * as childProcess from 'node:child_process'; +import {EventEmitter} from 'node:events'; +import * as os from 'node:os'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -// Records the spawn arguments while still running the real `spawn`, so the -// surrounding tests keep executing actual child processes. -vi.mock('node:child_process', {spy: true}); -const spawnSpy = vi.mocked(childProcess.spawn); +// Only `spawn` is mocked; it defaults to the real implementation (see +// `beforeEach`) so the pre-existing tests still execute real scripts. +const spawnMock = vi.hoisted(() => vi.fn()); +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + spawn: spawnMock, +})); + +const {spawn: realSpawn} = + await vi.importActual( + 'node:child_process', + ); + +const POWERSHELL_COMMAND = os.platform() === 'win32' ? 'powershell' : 'pwsh'; + +const POWERSHELL_FLAGS = [ + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', +]; + +const EXPECTED_POWERSHELL_ARGS = [ + ...POWERSHELL_FLAGS, + expect.stringMatching(/script\.ps1$/), +]; function createMockInvocationContext(): InvocationContext { const agent = new LlmAgent({ @@ -45,6 +69,8 @@ describe('UnsafeLocalCodeExecutor', () => { const invocationContext = createMockInvocationContext(); beforeEach(() => { + spawnMock.mockReset(); + spawnMock.mockImplementation(realSpawn); executor = new UnsafeLocalCodeExecutor(); }); @@ -315,55 +341,109 @@ describe('UnsafeLocalCodeExecutor', () => { expect(result.outputFiles![0].mimeType).toBe('application/json'); }); - // Runners that ship PowerShell really launch it, and a cold start there - // exceeds the default 5s test timeout. - describe('shell command detection', {timeout: 30_000}, () => { - const POWERSHELL_FLAGS = ['-NoLogo', '-ExecutionPolicy', 'Bypass', '-File']; + describe('spawn arguments', () => { + beforeEach(() => { + // Return a child process that immediately exits with code 0, so the + // interpreters under test need not be installed on the host. + spawnMock.mockImplementation(() => { + const child = new EventEmitter(); + setImmediate(() => child.emit('close', 0, null)); + return child; + }); + }); + + it('should pass -NoProfile when shell code runs through powershell', async () => { + const shellExecutor = new UnsafeLocalCodeExecutor({ + shellCommandPath: 'powershell', + }); - async function captureShellSpawn(shellCommandPath: string) { - spawnSpy.mockClear(); - const customExecutor = new UnsafeLocalCodeExecutor({shellCommandPath}); - await customExecutor.executeCode({ + await shellExecutor.executeCode({ invocationContext, codeExecutionInput: { - code: 'echo "test"', + code: 'Write-Output "hi"', language: CodeExecutionLanguage.SHELL, inputFiles: [], }, }); - expect(spawnSpy).toHaveBeenCalledTimes(1); - return spawnSpy.mock.calls[0][1]; - } - - it.each([ - 'pwsh', - 'pwsh.exe', - '/usr/bin/pwsh', - 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', - 'PWSH', - 'powershell', - 'powershell.exe', - ])('runs a .ps1 script with PowerShell flags for %s', async (shell) => { - const args = await captureShellSpawn(shell); - - expect(args).toEqual(expect.arrayContaining(POWERSHELL_FLAGS)); - expect(args.at(-2)).toBe('-File'); - expect(args.at(-1)).toMatch(/script\.ps1$/); + + expect(spawnMock).toHaveBeenCalledWith( + 'powershell', + // The extension follows the host platform, the command follows + // `shellCommandPath`. + [...POWERSHELL_FLAGS, expect.stringMatching(/script\.(ps1|sh)$/)], + expect.anything(), + ); }); - it.each([ - '/opt/pwsh-tools/bin/bash', - '/usr/local/powershell-helpers/run.sh', - ])('does not treat %s as PowerShell', async (shell) => { - const args = await captureShellSpawn(shell); + it('should pass -NoProfile for the powershell language, appending user args after the script path without accumulating them across executions', async () => { + await executor.executeCode({ + invocationContext, + codeExecutionInput: { + code: 'Write-Output $args', + language: CodeExecutionLanguage.POWERSHELL, + inputFiles: [], + args: ['first-run-only'], + }, + }); + await executor.executeCode({ + invocationContext, + codeExecutionInput: { + code: 'Write-Output "hi"', + language: CodeExecutionLanguage.POWERSHELL, + inputFiles: [], + }, + }); - expect(args).toHaveLength(1); + expect(spawnMock).toHaveBeenNthCalledWith( + 1, + POWERSHELL_COMMAND, + [...EXPECTED_POWERSHELL_ARGS, 'first-run-only'], + expect.anything(), + ); + expect(spawnMock).toHaveBeenNthCalledWith( + 2, + POWERSHELL_COMMAND, + EXPECTED_POWERSHELL_ARGS, + expect.anything(), + ); }); - it.each(['cmd', 'cmd.exe'])('invokes %s with /c', async (shell) => { - const args = await captureShellSpawn(shell); + it('should pass /D when shell code runs through cmd', async () => { + const shellExecutor = new UnsafeLocalCodeExecutor({ + shellCommandPath: 'cmd.exe', + }); + + await shellExecutor.executeCode({ + invocationContext, + codeExecutionInput: { + code: 'echo hi', + language: CodeExecutionLanguage.SHELL, + inputFiles: [], + }, + }); + + expect(spawnMock).toHaveBeenCalledWith( + 'cmd.exe', + ['/D', '/c', expect.stringMatching(/script\.(bat|sh)$/)], + expect.anything(), + ); + }); + + it('should pass /D for the windows_cmd language', async () => { + await executor.executeCode({ + invocationContext, + codeExecutionInput: { + code: 'echo hi', + language: CodeExecutionLanguage.WINDOWS_CMD, + inputFiles: [], + }, + }); - expect(args).toEqual(['/c', expect.any(String)]); + expect(spawnMock).toHaveBeenCalledWith( + 'cmd.exe', + ['/D', '/c', expect.stringMatching(/script\.bat$/)], + expect.anything(), + ); }); }); }); From 7aeb38e5801fb6a3caece2db95cfd6a34092f576 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Fri, 31 Jul 2026 15:13:04 -0700 Subject: [PATCH 8/8] fix(code-executors): detect pwsh as a PowerShell shell command Restores the fix on top of the upstream merge. The SHELL branch selected PowerShell spawn arguments with a substring test against 'powershell', so PowerShell 7+ (pwsh) got neither the PowerShell flags nor a .ps1 script extension, and unrelated commands whose path merely contains the word were misclassified. Detection now matches the executable name only. Rebased onto the -NoProfile / /D change: the PowerShell branch reuses POWERSHELL_BASE_ARGS, and the tests reuse the existing spawn mock and EXPECTED_POWERSHELL_ARGS instead of the separate harness they used before. --- .../unsafe_local_code_executor.ts | 17 ++++++- .../unsafe_local_code_executor_test.ts | 44 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/core/src/code_executors/unsafe_local_code_executor.ts b/core/src/code_executors/unsafe_local_code_executor.ts index 97061719a..56da44a93 100644 --- a/core/src/code_executors/unsafe_local_code_executor.ts +++ b/core/src/code_executors/unsafe_local_code_executor.ts @@ -38,6 +38,14 @@ const POWERSHELL_BASE_ARGS = [ */ const CMD_BASE_ARGS = ['/D', '/c'] as const; +/** + * Whether `commandPath` names Windows PowerShell (`powershell`) or PowerShell + * 7+ (`pwsh`). `path.win32` splits on both separators on every platform. + */ +function isPowerShellCommand(commandPath: string): boolean { + return /^(powershell|pwsh)(\.exe)?$/i.test(path.win32.basename(commandPath)); +} + /** * Options for UnsafeLocalCodeExecutor. */ @@ -56,6 +64,10 @@ export interface UnsafeLocalCodeExecutorOptions { pythonCommandPath?: string; /** * The command to run Shell code. Default is `bash`. + * + * When it names `powershell` or `pwsh` (with or without `.exe`) the script + * is written as `.ps1` and run through PowerShell rather than as a bare + * shell script. */ shellCommandPath?: string; } @@ -100,6 +112,9 @@ function getExtensionForLanguage( } if (language === CodeExecutionLanguage.SHELL) { + if (shellCommandPath && isPowerShellCommand(shellCommandPath)) { + return '.ps1'; + } if (IS_WINDOWS) { if (shellCommandPath && shellCommandPath.toLowerCase().includes('cmd')) { return '.bat'; @@ -189,7 +204,7 @@ export class UnsafeLocalCodeExecutor extends BaseCodeExecutor { command = this.pythonCommandPath; } else if (language === CodeExecutionLanguage.SHELL) { command = this.shellCommandPath; - if (this.shellCommandPath.toLowerCase().includes('powershell')) { + if (isPowerShellCommand(this.shellCommandPath)) { args = [...POWERSHELL_BASE_ARGS, filePath]; } else if (this.shellCommandPath.toLowerCase().includes('cmd')) { args = [...CMD_BASE_ARGS, filePath]; diff --git a/core/test/code_executors/unsafe_local_code_executor_test.ts b/core/test/code_executors/unsafe_local_code_executor_test.ts index 82a0d2d01..341913ece 100644 --- a/core/test/code_executors/unsafe_local_code_executor_test.ts +++ b/core/test/code_executors/unsafe_local_code_executor_test.ts @@ -445,5 +445,49 @@ describe('UnsafeLocalCodeExecutor', () => { expect.anything(), ); }); + + describe('shell command detection', () => { + async function runShellCode(shellCommandPath: string) { + await new UnsafeLocalCodeExecutor({shellCommandPath}).executeCode({ + invocationContext, + codeExecutionInput: { + code: 'echo "test"', + language: CodeExecutionLanguage.SHELL, + inputFiles: [], + }, + }); + } + + it.each([ + 'pwsh', + 'pwsh.exe', + '/usr/bin/pwsh', + 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + 'PWSH', + 'powershell', + 'powershell.exe', + ])('runs a .ps1 script through PowerShell for %s', async (shell) => { + await runShellCode(shell); + + expect(spawnMock).toHaveBeenCalledWith( + shell, + EXPECTED_POWERSHELL_ARGS, + expect.anything(), + ); + }); + + it.each([ + '/opt/pwsh-tools/bin/bash', + '/usr/local/powershell-helpers/run.sh', + ])('does not treat %s as PowerShell', async (shell) => { + await runShellCode(shell); + + expect(spawnMock).toHaveBeenCalledWith( + shell, + [expect.stringMatching(/script\.(sh|ps1)$/)], + expect.anything(), + ); + }); + }); }); });