From 399685962e5c6bc5a4b7c711fd5afab20c775cd7 Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <1051233107@qq.com> Date: Sat, 17 Oct 2026 23:13:27 +0800 Subject: [PATCH] fix(skills): preserve escapes in Python workspace shell wrapper (#2666) * fix(skills): preserve escapes in Python workspace shell wrapper * style(skills): retain original end-of-file formatting --- src/langbot/pkg/utils/python_workspace.py | 2 +- .../unit_tests/utils/test_python_workspace.py | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 tests/unit_tests/utils/test_python_workspace.py diff --git a/src/langbot/pkg/utils/python_workspace.py b/src/langbot/pkg/utils/python_workspace.py index 20a23ffa2..96794eb9e 100644 --- a/src/langbot/pkg/utils/python_workspace.py +++ b/src/langbot/pkg/utils/python_workspace.py @@ -49,7 +49,7 @@ def wrap_python_command_with_env( writable_state_path = state_path or mount_path bootstrap = textwrap.dedent( - f""" + rf""" set -e _LB_VENV_DIR="{writable_state_path}/.venv" diff --git a/tests/unit_tests/utils/test_python_workspace.py b/tests/unit_tests/utils/test_python_workspace.py new file mode 100644 index 000000000..6bccb2ef9 --- /dev/null +++ b/tests/unit_tests/utils/test_python_workspace.py @@ -0,0 +1,78 @@ +"""Exercise generated shell/Python syntax without installing dependencies.""" + +import hashlib +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys +import tempfile +import unittest + +from langbot.pkg.utils.python_workspace import wrap_python_command_with_env + + +class TestPythonWorkspaceCommand(unittest.TestCase): + def test_generated_command_contains_no_null_bytes(self): + command = wrap_python_command_with_env('python main.py') + self.assertNotIn('\0', command) + + @unittest.skipUnless(shutil.which('sh'), 'POSIX shell required') + def test_generated_command_has_valid_shell_syntax(self): + command = wrap_python_command_with_env('python main.py') + result = subprocess.run(['sh', '-n', '-c', command], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + + @unittest.skipUnless(shutil.which('sh'), 'POSIX shell required') + def test_cached_environment_executes_command_and_hashes_manifest(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + mount = root / 'project' + state = root / 'state' + bin_dir = root / 'bin' + venv_bin = state / '.venv' / 'bin' + meta_dir = state / '.langbot' + for path in (mount, bin_dir, venv_bin, meta_dir): + path.mkdir(parents=True) + + manifest = b'# local fixture\nexample==1.0\n' + (mount / 'requirements.txt').write_bytes(manifest) + # Run the real metadata interpreter, but fail closed if bootstrap is attempted. + system_python = bin_dir / 'python3' + system_python.write_text( + '#!/bin/sh\n' + '[ "$#" -eq 1 ] && [ "$1" = "-" ] || exit 99\n' + f'exec {shlex.quote(sys.executable)} "$@"\n', + encoding='utf-8', + ) + system_python.chmod(0o755) + venv_python = venv_bin / 'python' + venv_python.write_text('#!/bin/sh\nprintf "%s\\n" "$VIRTUAL_ENV"\n', encoding='utf-8') + venv_python.chmod(0o755) + metadata = json.dumps( + { + 'python_executable': sys.executable, + 'python_version': list(sys.version_info[:3]), + 'manifest_files': ['requirements.txt'], + 'manifest_sha256': hashlib.sha256(b'requirements.txt\0' + manifest + b'\0').hexdigest(), + }, + sort_keys=True, + ) + meta_file = meta_dir / 'python-env.json' + meta_file.write_text(metadata, encoding='utf-8') + + command = wrap_python_command_with_env('python', mount_path=str(mount), state_path=str(state)) + result = subprocess.run( + ['sh', '-c', command], + env={**os.environ, 'PATH': f'{bin_dir}{os.pathsep}{os.environ.get("PATH", "")}'}, + capture_output=True, + text=True, + timeout=10, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, f'{state / ".venv"}\n') + self.assertEqual(meta_file.read_text(encoding='utf-8'), metadata) + self.assertFalse((meta_dir / 'python-env.lock').exists())