From f502c508fb622f258944b7018f83fe3aa572340c Mon Sep 17 00:00:00 2001 From: "ASKA C." Date: Sun, 13 Sep 2026 02:42:57 +0800 Subject: [PATCH] Preserve Windows shell executable arguments --- README.md | 5 ++-- core_version.py | 2 +- terminal_backends/local_shell.py | 10 ++++---- tests/terminal_read_smoke.py | 40 +++++++++++++++++++++++++++++++- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index db703e4..12fd540 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ to the StandTerm server process across page reloads. [Download and install StandTerm Desktop](#desktop-downloads-evaluation), or use the [browser-based Core quick start](#quick-start). -**Core 2.12.0** is a [source release](https://github.com/askac/standterm/releases/tag/v2.12.0). -It adds SSH Agent Tunnel with shared skills and helpers, Agent Panel permission +**Core 2.12.1** is a [source release](https://github.com/askac/standterm/releases/tag/v2.12.1). +It fixes Windows local shell startup when an executable path contains spaces. +Core 2.12 adds SSH Agent Tunnel with shared skills and helpers, Agent Panel permission sync, Agent Info for the current tab, and SSH host fingerprint management. IME input-line anchoring remains an [experimental PoC](docs/ime_anchor_poc.md). This release does not publish new Desktop installers: the downloads below remain diff --git a/core_version.py b/core_version.py index efc8131..508f714 100644 --- a/core_version.py +++ b/core_version.py @@ -1,3 +1,3 @@ """Core release identity, shared by source and packaged launchers.""" -CORE_VERSION = '2.12.0' +CORE_VERSION = '2.12.1' diff --git a/terminal_backends/local_shell.py b/terminal_backends/local_shell.py index 351cf75..503caa8 100644 --- a/terminal_backends/local_shell.py +++ b/terminal_backends/local_shell.py @@ -722,11 +722,13 @@ def _connect_windows(self, cols, rows): return False, {'message': str(exc), 'error_code': 'local_shell_start_failed'} def _spawn_windows_process(self, cols, rows, cwd, env): + # A legacy string config is one executable path, not a command line. + command = [self.shell_command] if isinstance(self.shell_command, str) else self.shell_command spawn_attempts = ( - lambda: WinPtyProcess.spawn(self.shell, cwd=cwd, env=env, dimensions=(rows, cols)), - lambda: WinPtyProcess.spawn(self.shell, cwd=cwd, env=env), - lambda: WinPtyProcess.spawn(self.shell, dimensions=(rows, cols)), - lambda: WinPtyProcess.spawn(self.shell), + lambda: WinPtyProcess.spawn(command, cwd=cwd, env=env, dimensions=(rows, cols)), + lambda: WinPtyProcess.spawn(command, cwd=cwd, env=env), + lambda: WinPtyProcess.spawn(command, dimensions=(rows, cols)), + lambda: WinPtyProcess.spawn(command), ) last_error = None for spawn in spawn_attempts: diff --git a/tests/terminal_read_smoke.py b/tests/terminal_read_smoke.py index da1fbd8..a3bf9b8 100644 --- a/tests/terminal_read_smoke.py +++ b/tests/terminal_read_smoke.py @@ -1,4 +1,4 @@ -"""Exercise bounded terminal reads without network connections or user shells.""" +"""Exercise terminal reads and spawn arguments without network or user shells.""" import codecs from collections import deque from pathlib import Path @@ -174,5 +174,43 @@ def test_windows_empty_live_read_waits(self): self.assertEqual(runtime.sleeps, [LOCAL_SHELL_IDLE_WAIT_SECONDS]) +class WindowsShellSpawnTests(unittest.TestCase): + def test_windows_preserves_executable_paths_and_arguments(self): + executable = r'C:\Fixture Folder\PowerShell\pwsh.exe' + for command in ['cmd.exe', executable, [executable, '-NoLogo', 'argument with spaces'], + (executable, '-NoLogo')]: + with self.subTest(command=command): + bridge, _runtime = local_bridge(None) + bridge.shell = 'PowerShell display label' + bridge.shell_command = command + expected = [command] if isinstance(command, str) else command + with patch('terminal_backends.local_shell.WinPtyProcess') as winpty: + process = bridge._spawn_windows_process(80, 24, 'fixture-cwd', {'TERM': 'fixture'}) + self.assertIs(process, winpty.spawn.return_value) + winpty.spawn.assert_called_once_with(expected, cwd='fixture-cwd', + env={'TERM': 'fixture'}, dimensions=(24, 80)) + + def test_windows_compatibility_fallbacks_keep_the_same_command(self): + command = [r'C:\Fixture Folder\shell.exe', 'argument with spaces'] + for failures in range(4): + with self.subTest(failures=failures): + bridge, _runtime = local_bridge(None) + bridge.shell_command = command + process = object() + with patch('terminal_backends.local_shell.WinPtyProcess') as winpty: + winpty.spawn.side_effect = [TypeError('Legacy keyword signature')] * failures + [process] + self.assertIs(bridge._spawn_windows_process(80, 24, 'fixture-cwd', {}), process) + self.assertEqual(winpty.spawn.call_count, failures + 1) + self.assertTrue(all(call.args == (command,) for call in winpty.spawn.call_args_list)) + + def test_windows_spawn_errors_are_not_retried_as_signature_failures(self): + bridge, _runtime = local_bridge(None) + with patch('terminal_backends.local_shell.WinPtyProcess') as winpty: + winpty.spawn.side_effect = FileNotFoundError('Missing executable') + with self.assertRaises(FileNotFoundError): + bridge._spawn_windows_process(80, 24, 'fixture-cwd', {}) + self.assertEqual(winpty.spawn.call_count, 1) + + if __name__ == '__main__': unittest.main()