Skip to content

Commit 43682d4

Browse files
authored
fix(cookbook): activate local Windows venv in bash runner (#5734)
1 parent 032967a commit 43682d4

5 files changed

Lines changed: 109 additions & 5 deletions

File tree

routes/cookbook_helpers.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,6 +1204,41 @@ def _safe_env_prefix(ep: str | None) -> str | None:
12041204
return f'[ -f "{path}" ] && source "{path}" || true'
12051205

12061206

1207+
def _local_windows_bash_env_prefix(ep: str | None) -> str | None:
1208+
"""Convert a frontend PowerShell venv prefix for the local Git Bash runner."""
1209+
if not ep:
1210+
return ep
1211+
1212+
prefix = ep.strip()
1213+
if not prefix.startswith("&"):
1214+
return ep
1215+
1216+
raw_path = prefix[1:].lstrip()
1217+
if not raw_path:
1218+
return ep
1219+
if raw_path.startswith("'"):
1220+
if len(raw_path) < 2 or not raw_path.endswith("'"):
1221+
return ep
1222+
quoted_path = raw_path[1:-1]
1223+
if "'" in quoted_path.replace("''", ""):
1224+
return ep
1225+
path = quoted_path.replace("''", "'")
1226+
else:
1227+
path = raw_path.rstrip()
1228+
if "'" in path or '"' in path:
1229+
return ep
1230+
if any(c in path for c in "\r\n;&|`$<>"):
1231+
return ep
1232+
if not path.replace("\\", "/").casefold().endswith("/scripts/activate.ps1"):
1233+
return ep
1234+
1235+
bash_path = _git_bash_path(path)
1236+
if "\\" in bash_path:
1237+
return ep
1238+
bash_path = bash_path[: -len("Activate.ps1")] + "activate"
1239+
return "source " + shlex.quote(bash_path)
1240+
1241+
12071242
def _ssh_ps(host, script_path, port=None):
12081243
"""Build SSH command to run a PowerShell script on a Windows remote."""
12091244
pf = f"-p {port} " if port and port != "22" else ""

routes/cookbook_routes.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
_SESSION_ID_RE, _validate_repo_id, _validate_serve_model_id, _validate_include, _validate_token,
5151
_validate_local_dir, _validate_gpus, _shell_path,
5252
_ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase, OLLAMA_MISSING_HINT,
53-
_safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
53+
_safe_env_prefix, _local_windows_bash_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
5454
_append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script,
5555
load_stored_hf_token,
5656
_append_vllm_linux_preflight_lines, _ollama_bind_from_cmd, _pip_install_fallback_chain,
@@ -1336,7 +1336,7 @@ async def model_download(request: Request, req: ModelDownloadRequest):
13361336
# Local: run hf download in the background (tmux on POSIX, a detached
13371337
# process + logfile on Windows where tmux doesn't exist).
13381338
if req.env_prefix:
1339-
lines.append(_safe_env_prefix(req.env_prefix))
1339+
lines.append(_safe_env_prefix(_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix))
13401340
else:
13411341
lines.append("deactivate 2>/dev/null; hash -r")
13421342
# Show whether the HF token reached this run (masked) — tells a gated
@@ -2166,7 +2166,7 @@ async def model_serve(request: Request, req: ServeRequest):
21662166
if req.gpus:
21672167
runner_lines.append(f"export CUDA_VISIBLE_DEVICES='{req.gpus}'")
21682168
if req.env_prefix:
2169-
runner_lines.append(_safe_env_prefix(req.env_prefix))
2169+
runner_lines.append(_safe_env_prefix(_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix))
21702170
else:
21712171
runner_lines.append("deactivate 2>/dev/null; hash -r")
21722172
_append_venv_nvidia_library_path_lines(runner_lines, cmd=req.cmd)

static/js/cookbookDownload.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ let _getPlatform;
1515
let _serverByVal;
1616
let _isWindows;
1717
let _buildEnvPrefix;
18+
let _psQuote;
1819
let _buildServeCmd;
1920
let _detectBackend;
2021
let _detectToolParser;
@@ -538,7 +539,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
538539
if (srv.downloadDir) payload.local_dir = srv.downloadDir;
539540
if (isWin) {
540541
if (env === 'venv' && envPath) {
541-
payload.env_prefix = '& ' + (envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
542+
payload.env_prefix = '& ' + _psQuote(envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
542543
} else if (env === 'conda' && envPath) {
543544
payload.env_prefix = 'conda activate ' + envPath;
544545
}
@@ -652,6 +653,7 @@ export function initDownload(shared) {
652653
_serverByVal = shared._serverByVal;
653654
_isWindows = shared._isWindows;
654655
_buildEnvPrefix = shared._buildEnvPrefix;
656+
_psQuote = shared._psQuote;
655657
_buildServeCmd = shared._buildServeCmd;
656658
_detectBackend = shared._detectBackend;
657659
_detectToolParser = shared._detectToolParser;

static/js/cookbookRunning.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ let _sshPrefix;
338338
let _getPlatform;
339339
let _isWindows;
340340
let _buildEnvPrefix;
341+
let _psQuote;
341342
let _loadPresets;
342343
let _savePresets;
343344
let _copyText;
@@ -1971,7 +1972,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
19711972
let envPrefix = '';
19721973
if (_isWindows()) {
19731974
if (_envState.env === 'venv' && _envState.envPath) {
1974-
envPrefix = '& ' + (_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
1975+
envPrefix = '& ' + _psQuote(_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
19751976
} else if (_envState.env === 'conda' && _envState.envPath) {
19761977
envPrefix = 'conda activate ' + _envState.envPath;
19771978
}
@@ -4402,6 +4403,7 @@ export function initRunning(shared) {
44024403
_getPlatform = shared._getPlatform;
44034404
_isWindows = shared._isWindows;
44044405
_buildEnvPrefix = shared._buildEnvPrefix;
4406+
_psQuote = shared._psQuote;
44054407
_loadPresets = shared._loadPresets;
44064408
_savePresets = shared._savePresets;
44074409
_copyText = shared._copyText;

tests/test_cookbook_helpers.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
_llama_cpp_rebuild_cmd,
1717
_append_vllm_linux_preflight_lines,
1818
_local_tooling_path_export,
19+
_local_windows_bash_env_prefix,
1920
_pip_install_attempt,
2021
_pip_install_fallback_chain,
2122
_ollama_bind_from_cmd,
@@ -107,6 +108,70 @@ def test_safe_env_prefix_accepts_powershell_activation_path():
107108
)
108109

109110

111+
@pytest.mark.parametrize(
112+
("prefix", "expected"),
113+
[
114+
("& 'C:\\Users\\me\\venv\\Scripts\\Activate.ps1'", "source /c/Users/me/venv/Scripts/activate"),
115+
(r"& C:\Users\me\venv\Scripts\Activate.ps1", "source /c/Users/me/venv/Scripts/activate"),
116+
(
117+
r"& C:\Users\me\My Envs\venv\Scripts\Activate.ps1",
118+
"source '/c/Users/me/My Envs/venv/Scripts/activate'",
119+
),
120+
(
121+
"& 'C:\\Users\\me\\My Envs\\venv\\Scripts\\Activate.ps1'",
122+
"source '/c/Users/me/My Envs/venv/Scripts/activate'",
123+
),
124+
(r"& D:/Envs/venv/Scripts/Activate.ps1", "source /d/Envs/venv/Scripts/activate"),
125+
],
126+
)
127+
def test_local_windows_bash_env_prefix_converts_powershell_venv_activation(prefix, expected):
128+
converted = _local_windows_bash_env_prefix(prefix)
129+
130+
assert converted == expected
131+
assert _safe_env_prefix(converted).startswith('[ -f "')
132+
133+
134+
@pytest.mark.parametrize(
135+
"prefix",
136+
[
137+
None,
138+
"",
139+
"source /home/me/venv/bin/activate",
140+
"conda activate qwen35",
141+
'eval "$(conda shell.bash hook)" && conda activate qwen35',
142+
r"& \\server\share\venv\Scripts\Activate.ps1",
143+
],
144+
)
145+
def test_local_windows_bash_env_prefix_leaves_other_prefixes_unchanged(prefix):
146+
assert _local_windows_bash_env_prefix(prefix) == prefix
147+
148+
149+
def test_local_windows_bash_env_prefix_handles_long_whitespace_input():
150+
prefix = "\t" * 100_000
151+
152+
assert _local_windows_bash_env_prefix(prefix) == prefix
153+
154+
155+
@pytest.mark.parametrize(
156+
"relative_path",
157+
["static/js/cookbookRunning.js", "static/js/cookbookDownload.js"],
158+
)
159+
def test_primary_windows_venv_emitters_quote_activation_path(relative_path):
160+
source = (Path(__file__).resolve().parents[1] / relative_path).read_text(encoding="utf-8")
161+
162+
assert "'& ' + _psQuote(" in source
163+
assert "_psQuote = shared._psQuote;" in source
164+
165+
166+
def test_windows_venv_conversion_stays_scoped_to_local_git_bash_runners():
167+
source = (Path(__file__).resolve().parents[1] / "routes/cookbook_routes.py").read_text(encoding="utf-8")
168+
guarded_conversion = (
169+
"_local_windows_bash_env_prefix(req.env_prefix) if local_windows else req.env_prefix"
170+
)
171+
172+
assert source.count(guarded_conversion) == 2
173+
174+
110175
def test_validate_local_dir_accepts_external_drive_paths_with_spaces():
111176
path = "/Volumes/T7 2TB/AI Models/llamacpp"
112177

0 commit comments

Comments
 (0)