unsloth/tests/test_installer_interactive_prompts.py
Daniel Han a151ac875c
Make install.ps1 work with the user's PowerShell profile loaded (#8161)
* Make install.ps1 work with the user's PowerShell profile loaded

Installing from a normal console failed where the same install from a
console started with -NoProfile succeeded. A profile runs before
`irm https://unsloth.ai/install.ps1 | iex` does and shares its scope, and
that entry point has no script file to re-launch without it, so the
individual couplings are cut instead.

install.ps1, at the top of Install-UnslothStudio:

- Set-StrictMode -Off. The script tests environment variables that are
  legitimately unset and reads $script: state only some branches assign,
  both of which a profile's `Set-StrictMode -Version Latest` turns into
  terminating errors.
- $PSDefaultParameterValues is filtered down to proxy keys. An entry like
  'Start-Process:WindowStyle' silently rebinds cmdlets here and fails the
  install with an error naming none of it. Proxy entries are kept because
  they can only ever enable a download, and on a locked-down host may be
  the only route to python.org and the uv release.
- $PSNativeCommandUseErrorActionPreference = $false. With a profile
  turning it on, the "Stop" preference makes a failing native command
  throw out of the `unsloth studio setup` handoff instead of reaching
  Exit-InstallFailure, skipping rollback and the Tauri error record.

All three assign without a scope qualifier, so they apply to the
installer and everything it calls and leave the caller's session alone.

uv is resolved once through Resolve-UvExecutable, which uses
`Get-Command uv -CommandType Application -All` and falls back to the bare
token when nothing is on PATH. PowerShell ranks aliases and functions
above PATH, so a profile `Set-Alias uv ...` was answering the version
probe and ending the install at "uv could not be installed" on machines
that had a working uv. Test-UvVersionOk pins the executable that
answered in $script:UvExe, and the 27 install scriptblocks invoke that
path. $script:UvExe and $script:UvInstallDestDir are reset per
invocation, since $script: is the caller's session under irm | iex.

unsloth_cli/commands/studio.py passes -NoProfile to setup.ps1
unconditionally. It was only added when stdout was not a tty, which is
never the case for the console install this fixes, so setup.ps1 ran
under the profile with its own bare uv calls exposed.

tests/test_installer_profile_hardening.py runs the extracted prologue and
uv probe under a hostile profile and checks the caller's session is left
intact. The four existing tests that anchored on the literal
`uv venv $VenvDir` are re-anchored past the command token.

* Plant the real-profile fixture where pwsh actually looks

test_a_real_profile_reproduces_the_same_state failed on ubuntu-latest with
every probed setting at its default, meaning the planted profile never loaded.
It passed here and on macos-14.

PowerShell resolves $PROFILE from $XDG_CONFIG_HOME when that is set and only
falls back to $HOME/.config when it is not, and GitHub's ubuntu image writes
XDG_CONFIG_HOME into /etc/environment. The fixture redirected HOME alone, so on
a hosted runner the inherited value went on naming the real account and the
profile was written to a path pwsh never opened. Setting XDG_CONFIG_HOME to the
same directory HOME already implies reproduces the CI failure exactly on this
machine, and removing it makes the failure go away.

_hostile_env now redirects XDG_CONFIG_HOME alongside HOME, so the two rules
agree whichever one the host applies, and the test asks pwsh for the path
instead of hardcoding the fallback branch. The two guards are precise rather
than blanket: a machine-wide profile loads into the real leg only, and a
$PROFILE that lands outside the fixture cannot be planted into. Neither is
reachable on Linux or macOS with the redirect in place.

The other tests in the file never shared the premise; every other pwsh launch
there passes -NoProfile and dot-sources the profile explicitly.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Close the uv wrapper hole, harden winget the same way, and restore module autoloading

Validating the profile hardening against real pwsh turned up three things the
first pass missed. Six of the nineteen hostile-profile scenarios I exercised
are genuinely broken on main and genuinely fixed by this branch, so the shape
of the fix is right -- these are gaps in its coverage, not a change of
direction.

Resolve-UvExecutable still handed back the bare token when nothing named uv
was on PATH. That was meant to keep a working non-Application uv working, but
it reopens the exact hole the function exists to close: a profile
`function uv { Write-Output "uv 99.0.0" }` clears the version gate, gets
pinned into $script:UvExe, and then receives every install command the script
runs, with the user's torch, index URL and venv path as arguments. The
existing test missed it because its hostile alias reports no version at all,
and an alias to a missing file fails loudly. Follow an alias as far as an
Application and return that resolved path, since aliasing uv at a specific
build is a legitimate thing to do; return $null for anything else, which puts
the caller back on its install-uv branch and the gate re-probes against the
real thing.

winget had the identical defect and was left untouched. It is detected with a
bare Get-Command and invoked as a bare token at five sites, and it is what
installs both Python and uv -- so a `function winget` wrapper, which people
write to inject --accept-* or pin a source, owns the whole bootstrap. Same
treatment: resolve once to an Application and invoke through the path.

A profile setting $PSModuleAutoLoadingPreference to 'None' is fatal here and
was not covered. PowerShell 7 loads no modules at startup, so that one line
removes Test-Path, Write-Host, Select-Object, ConvertFrom-Json, Get-FileHash,
Invoke-WebRequest, Expand-Archive, Start-Process and Get-Content, and the
script dies on its first step naming a cmdlet the reader assumes is always
there. Windows PowerShell 5.1 preloads Utility and Management and survives,
which is exactly what makes this reproduce on one machine and not another.

Also: use [regex]::IsMatch in the defaults filter so it leaves no $Matches
behind; add -NoProfile unconditionally in _refresh_desktop_shortcuts, which
launches install.ps1 and had it gated on the hidden branch, so the visible
console path -- the one where a profile IS loaded -- was the one that missed
it; and record that the preserved proxy defaults do not reach setup.ps1,
which is launched with -NoProfile, along with why that trade is accepted.

Tests: the assertion that the bare token must come back now asserts the
opposite, and there are new ones for a convincing uv function, an alias to a
real uv, the winget call sites and the autoloading reset. The two new pwsh
tests execute against a genuinely planted profile rather than reading source.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Carry the profile proxy across the setup handoff, not just inside install.ps1

install.ps1 deliberately keeps proxy-shaped $PSDefaultParameterValues entries out of the
profile table it discards, because on a locked-down corporate host that entry can be the
only route out. Adding -NoProfile to the setup launch unconditionally then threw them away
one process later, and setup.ps1 downloads on its own: the VC++ runtime through
Invoke-WebRequest and the uv installer through Invoke-RestMethod.

A PowerShell variable does not cross a process boundary, so the kept entries travel as
JSON in _UNSLOTH_PS_PROXY_DEFAULTS and the child re-applies them before running setup.ps1.
Nothing else from the profile comes with them. A credential is left behind on purpose:
PSCredential does not survive ConvertTo-Json, and the environment is the wrong place for
one. A stale variable is cleared when there is nothing to hand off.

Three tests, one static and two driving real pwsh, covering the round trip, the credential
and non-proxy keys being dropped, and the prelude staying silent when the variable is
absent, empty or corrupt.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Harden the proxy handoff: ordering, key casing, uri values, scope, and the standalone update

Five follow-ups, all on the handoff added last round.

The handoff serializes with ConvertTo-Json, from Microsoft.PowerShell.Utility, and ran before
the module-autoloading reset. Under a profile's $PSModuleAutoLoadingPreference = 'None' a fresh
PowerShell 7 session therefore died right there, taking out the one configuration the handoff
exists to support. The reset moves to the front of the prologue.

The key filter was a case-sensitive .NET regex, but cmdlet and parameter names bind
case-insensitively, so 'invoke-webrequest:proxy' was dropped. And [uri] is the type the Proxy
parameter actually takes, so a careful profile assigns one; the serializer accepted only string
and bool, and it disappeared at the process boundary. Both are accepted now, a uri by its
AbsoluteUri. A PSCredential is still deliberately left behind.

Under "irm ... | iex" the prologue runs in the caller's own session, so writing the environment
variable there outlived the install on every path, early returns included, and a later
`unsloth studio update` from that console would reapply stale JSON over a proxy that had since
changed. The prologue now holds the value and it is published around the setup child only,
saved and restored beside the other child-scoped variables.

A standalone `unsloth studio update` has no installer above it, so there was nothing to
restore and -NoProfile left it with no route out. It now asks: a throwaway PowerShell that does
load the profile prints just the proxy-shaped defaults as JSON, validated before use, entirely
best effort. Same filter as install.ps1's.

Five tests, two driving real pwsh, including one against a profile with strict mode on,
autoloading off, a lowercase key and a uri value.

* Ask the profile the caller actually has, and follow a uv alias first

- the standalone update probed powershell.exe only, so a proxy living in the
  PowerShell 7 profile never reached the -NoProfile child; both editions are
  asked now, the caller's first, and their answers merged.
- Resolve-UvExecutable checked PATH before the alias, which is the reverse of
  PowerShell's own resolution and made the alias branch unreachable on any
  machine with some uv on PATH.
- the parity workflow did not run this suite when unsloth_cli/commands/studio.py
  changed, though the suite asserts that module directly. Its own path-filter
  parser also treated a comment inside the list as the end of it, which would
  have hidden the addition.

* Give the parity job the imports it needs, and fold proxy keys the way PowerShell does

Three tests in the profile-hardening suite import unsloth_cli.commands.studio to drive
the profile probe directly, and that pulls typer, pyyaml, pydantic and click. The job
installed pip and pytest only, so on a clean setup-python both matrix legs died with
ModuleNotFoundError before a single test ran. Installed, with a test that keeps the step
in step with what the suite imports.

$PSDefaultParameterValues keys are case-insensitive and a Python dict is not, so
"Invoke-WebRequest:Proxy" from the caller's own host and "invoke-webrequest:proxy" from
the other one both crossed over; the prelude then replayed them in order and the
lower-priority host's value landed last, reversing the earlier-host-wins rule this merge
exists for. Keys are folded now, first spelling seen wins, within one profile's answer
as well as across two.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Frame the proxy record, and quote the union so 3.9 can still import the CLI

The probe runs after the profile, and the profile is free to print: a MOTD, a "loading
personal and system profiles took 812ms" line, a corporate banner. With the record bare,
that arrived ahead of the JSON, the parse threw and the whole answer was dropped -- so
the locked-down host that needed the proxy handed the -NoProfile child nothing and every
download failed, which is worse than before, since the old visible-console path loaded
the profile itself. The record is emitted between two markers now and cut out of
whatever else was said.

And `str | list[str]` was evaluated at def time in a module with no postponed
annotations, so on the 3.9 this project still supports it raised TypeError and took the
whole CLI import with it. Quoted, with a test that walks every annotation in the module
for an unquoted PEP 604 union.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Let the uv gate move past a stale alias, and decode the probe output lossily

An alias pointing at a real but stale uv was the only binary the version gate ever
probed, so a current uv already on PATH -- or one winget or the pinned release had just
installed -- could not rescue the run and the install ended at "uv could not be
installed" on a machine that had one. The resolver hands back every candidate in the
order the bare token would pick them, alias first, and the gate walks them until one
passes, pinning the one that answered.

The profile probe decoded its child with text=True alone, which is the locale codec with
STRICT errors. A UTF-8 banner on an ANSI console then raised UnicodeDecodeError, which is
neither OSError nor SubprocessError, so it escaped the handler and took the update down
before the -NoProfile child ever ran -- and before the framing could discard the banner.
UTF-8 with replacement now; the record itself is ASCII.

rich is named in the parity job's install line too. It arrives through typer today, but
unsloth_cli imports it directly, and this suite's imports should not rest on somebody
else's dependency list.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Assert the proxy handoff by parsing it, not by substring

CodeQL reads the bare membership test as an incomplete URL sanitization, which
is a fair reading of the shape even though this is an assertion on a compressed
JSON payload rather than a check on untrusted input. Parsing it and comparing
the value exactly is the stronger assertion anyway.

* Read the caller edition by order, pin the probe's encoding, claim cmdlets whole

A machine can carry both PowerShell module trees on PSModulePath at once, so
inferring the caller from the absence of the other edition handed precedence to
the wrong profile and let its proxy override the console the command was typed
into. Each host puts its own module directory first, so the earliest tree names
the caller; neither present keeps the previous order.

Windows PowerShell 5.1 writes redirected output in the console code page while
this process decodes UTF-8, so a non-ASCII proxy value came back with
replacement characters, still parsed as JSON, and handed setup a proxy that does
not resolve. The probe pins its own output encoding first.

And the merge claims a cmdlet whole rather than filling missing companion
parameters from the other profile, which built a configuration neither host had
-- one profile's proxy with the other's credential forwarding.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Evaluate script-block proxy defaults, read the caller's host profile, drop the secret

A profile can set a dynamic default as a script block, which is PowerShell's
supported form and which Invoke-WebRequest evaluates per call. Both
serializers dropped it, so the caller downloaded fine and the -NoProfile
setup child got no proxy at all. Both now invoke the block and hand over the
resulting URI or string; executable code does not cross the handoff.

The probe spawns pwsh.exe or powershell.exe, which load the CONSOLEHOST
profile. A caller in the VS Code Integrated Console or the ISE keeps its
defaults in Microsoft.VSCode_profile.ps1 or Microsoft.PowerShellISE_profile.ps1
instead, so the probe reported no proxy on exactly the host that needed one.
It dot-sources the caller's other CurrentUser host profiles, from their own
directory, before reading the table.

And the prelude clears _UNSLOTH_PS_PROXY_DEFAULTS the moment it has read it.
A profile proxy routinely carries credentials, and every native process
setup.ps1 starts inherited the environment it was launched with.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Probe only the caller's own host profile, on one shared budget

Sourcing every Microsoft.*_profile.ps1 in the profile directory ran profiles
belonging to hosts nobody was using: they can overwrite the console's own
$PSDefaultParameterValues, have side effects, or exit before the framed
record is written. The probe now sources exactly one, named by
_UNSLOTH_PS_HOST_PROFILE, and only when the caller identifies itself (VS Code
does, via TERM_PROGRAM). A host we cannot name gets no extra profile rather
than someone else's.

install.ps1 removed the handoff variable when it had no proxy to pass, and
its absence is precisely how the CLI recognises a standalone update -- so an
installer launch, including one started with -NoProfile or by the desktop
app, went and reloaded the profiles it had deliberately discarded. It
publishes an explicit empty handoff instead, and the CLI keys on presence.

And the probe's timeout is one budget for the whole call rather than one per
host, so two installed editions with two hung profiles no longer cost twice
the documented best-effort delay before setup starts.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Run the proxy probe with -NoProfile and dot-source the caller's own two

Without -NoProfile the probe host loaded its own ConsoleHost profile before
the script ran, so an unrelated profile could print, rewrite
$PSDefaultParameterValues or exit before the record was written -- and it
still was not the profile a VS Code caller keeps its defaults in.

The child runs with -NoProfile now and dot-sources exactly the two the
caller's session would have loaded: $PROFILE.CurrentUserAllHosts and either
the host profile named in _UNSLOTH_PS_HOST_PROFILE or
$PROFILE.CurrentUserCurrentHost. $PROFILE is fully populated under
-NoProfile, since the paths are computed rather than loaded, so this is exact
rather than incidental.

Checked against pwsh with a fixture profile directory: a VS Code caller picks
up its own profile plus the all-hosts one and never runs the console
profile's banner, and a plain console caller picks up the console profile
plus the all-hosts one.

* Probe the all-users profiles too, and clear profile defaults before emitting

A machine-managed proxy commonly lives in AllUsersAllHosts on a domain-joined
box while the user's own profile never mentions it, so sourcing only the
current-user pair reported no proxy on exactly the host that has one. The probe
now walks PowerShell's own startup order, all-users first, so the user's profile
still gets the last word.

The profile's $PSDefaultParameterValues was also still active when the record
was serialized. ConvertTo-Json:AsArray = $true is a legitimate setting and turns
the payload into a JSON array, which the reader rejects for not being a
dictionary. $out already holds copies by then, so the table is cleared first.

* Harden the proxy probe against profile overrides, and drop the handoff copy

Five fixes from the review round:

install.ps1 kept the serialized proxy defaults in $script:, which under the
documented irm | iex path IS the caller's session scope, so an authenticated
proxy URI stayed readable in that console after the installer returned. Cleared
in the same finally that restores the environment handoff.

A profile setting [Console]::OutputEncoding overrode the probe's UTF-8 pin, and
the parent decodes that stream as UTF-8, so the framed record could come back
corrupted. Re-pinned after the last profile is sourced.

The record was emitted through bare Write-Output and ConvertTo-Json, which a
profile alias or function shadows; clearing $PSDefaultParameterValues does not
cover a command override. Both are module-qualified now.

TERM_PROGRAM=vscode is set by every VS Code integrated terminal, not only the
PowerShell extension's host, so substituting Microsoft.VSCode_profile.ps1 for
the current-host profile missed the proxy a plain pwsh terminal there actually
has. The named host profile is added rather than substituted, with the
current-host profile last.

The per-cmdlet ownership check compared command strings literally, so a wildcard
key from one host and a literal key for a matching cmdlet from the other were
both merged, which is how one invocation ends up configured from two profiles.
Overlap is matched in either direction now.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Hold the proxy handoff in the frame, and treat two wildcards as one family

Three fixes from the review round:

The serialized handoff lived in $script:, which under the documented irm | iex
path is the caller's session scope, and the only cleanup ran after the setup
child. Dozens of exits return earlier -- ShortcutsOnly, an argument error, lock
contention, a failed dependency install -- so an authenticated proxy URI stayed
readable in that console. It is a function-local now, which dies with the frame
on every path including a throw.

install.ps1 serialized that record through a bare ConvertTo-Json, which a
profile alias or function shadows exactly as it does in the probe. Module
qualified.

The cmdlet-ownership check compared two wildcard patterns as strings, and
Invoke-Web* and *-WebRequest both apply to Invoke-WebRequest while neither
matches the other. Two patterns are now assumed to overlap: the cost is a second
host's unrelated wildcard entry going unmerged, against handing setup a
credential setting from a profile that never asked for one.

* Tighten the profile-hardening comments

Comments, docstrings and whitespace only; no code changes. Each comment keeps
the reason it records and drops the retelling.

* Cut the profile-hardening comments down again

Comments, docstrings and whitespace only. The install.ps1 prologue and the
studio.py proxy probe kept one causal claim per decision, with the probe's
profile-loading essay split into a short note beside each line it justifies.

* Pin the probe's add-both profile order in its own test name

* Keep disjoint wildcard proxy families, and give each probed host its own module path

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: Daniel Han <moonshotaisubstack@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-08-09 04:27:28 -07:00

1087 lines
45 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Refuse new interactive yes/no prompts in the installer and setup scripts.
#7016 added `Open Unsloth Studio in your default browser after launch?
[Y/n]` and had to be reverted in #8040: extra questions stall a piped
install and persist an answer nobody can find again. The only preference
setup may ask about is whether to start Studio when it finishes.
Allowlist, not a ban: every prompt in the tree is listed in
`APPROVED_PROMPTS` with its reason, so a new one fails with instructions
instead of landing quietly. Two passes catch it: literal `[Y/n]` markers,
and interactive read sites (shell `read` and `select`, `Read-Host` and
the console reads, `input()`, `set /p`) for a marker built from a
variable, as #7016's was. Text only, so it runs on every platform in the
parity matrix.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
# What a user runs to install, update or remove Unsloth. Everything these launch
# in turn is scanned too (`unsloth studio update` runs setup.sh, which builds
# whisper.cpp; install.sh fetches and runs the WSL bootstrap), since a question
# down there stalls the same install.
ENTRY_POINTS = (
"install.sh",
"install.ps1",
"studio/setup.sh",
"studio/setup.ps1",
"studio/setup.bat",
)
SCANNED_SCRIPTS = ENTRY_POINTS + (
"scripts/build_whisper_cpp.sh",
"scripts/install_gemma4_mlx.sh",
"scripts/install_qwen3_6_mlx.sh",
"scripts/install_rocm_wsl_strixhalo.sh",
"scripts/uninstall.sh",
"scripts/uninstall.ps1",
"studio/install_llama_prebuilt.py",
"studio/install_manifest.py",
"studio/install_node_prebuilt.py",
"studio/install_python_stack.py",
"studio/install_sd_cpp_prebuilt.py",
"studio/install_whisper_prebuilt.py",
# install_python_stack runs this one with sys.executable.
"studio/backend/requirements/single-env/patch_metadata.py",
)
# Every question these scripts may ask, keyed by (script, normalised
# question) because line numbers move and wording does not. Do NOT add an
# entry just to turn a build green: decide the prompt is wanted first.
APPROVED_PROMPTS: dict[tuple[str, str], str] = {
("install.sh", "start unsloth studio now?"): (
"The one sanctioned preference prompt: launch Studio after install."
),
("install.ps1", "start unsloth studio now?"): (
"Windows half of the sanctioned launch prompt above."
),
("install.sh", "accept?"): (
"Consent before `sudo apt-get install` of missing system packages, "
"not a preference. Declining prints the command to run by hand."
),
("studio/setup.sh", "accept?"): (
"Same sudo consent, for the packages llama.cpp needs to build. "
"Declining skips the build rather than aborting."
),
}
_MARKER = re.compile(
r"\[\s*[yn]\s*/\s*[yn]\s*\]|\(\s*[yn]\s*/\s*[yn]\s*\)|\byes\s*/\s*no\b", re.IGNORECASE
)
# Anything that blocks waiting on a human. `-p` is matched as an option word, not
# anchored on whitespace: it may be bundled (`read -rp`) or follow `read` directly.
# The scan stops at `;|&`, since a `mkdir -p` later on the line is another command.
_POSIX_READ = re.compile(
r"(?:^|[\s;&|(])read\s+(?![a-zA-Z_]+=)"
r"(?:-[a-zA-Z]*p(?=[\s\"']|$)|[^\n;|&]*?(?:<\s*/dev/tty|\s-[a-zA-Z]*p(?=[\s\"']|$)))"
)
# An unredirected `read` takes the terminal it inherited, so it blocks too. Loop
# and pipeline reads are fed by the `done < ...` or the pipe: data, not questions.
# Options then variable names to end of line, in command position, so the word
# `read` in a heredoc of prose is not a prompt.
_BARE_READ = re.compile(
r"(?:^|[;&(]|\b(?:if|elif|then|else)\b)\s*!?\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*"
# No variable name at all is valid: the answer lands in $REPLY.
r"read(?:\s+(?:-[a-zA-Z0-9]+|[\d.]+|\"\"|''))*(?:\s+[A-Za-z_][A-Za-z0-9_]*)*"
r"(?:\s*(?:;|\|\||&&).*)?\s*(?:#.*)?$"
)
_LOOP = re.compile(r"\b(?:while|until|for)\b")
# `select reply in Yes No` is the other builtin that blocks for an answer; its
# question is the PS3 assignment above it, which the nearby scan already reads.
_SELECT = re.compile(r"(?:^|[;&(])\s*select\s+[A-Za-z_][A-Za-z0-9_]*\s+in\s")
_PWSH_READ = re.compile(
r"Read-Host|PromptForChoice|(?:Console\]::(?:In\.)?|UI\.)Read(?:Line|Key)?\b|ReadKey\s*\(",
re.IGNORECASE,
)
# The Python helpers setup runs, and the batch launcher.
_PY_READ = re.compile(
r"(?<![.\w])(?:input|getpass)\s*\(|getpass\.getpass\s*\("
r"|click\.confirm\s*\(|sys\.stdin(?:\.buffer)?\.read(?:line)?\s*\("
)
# In command position: starting the line or a `&`-joined command, after `do`, or
# as the body of a single-line `if`. Echoing the word `choice` is not a prompt.
# `pause` asks nothing but waits for a keypress, which stalls setup just the same.
_BAT_READ = re.compile(
r"(?:^\s*|[&(]\s*|\bdo\s+)@?\s*(?:set\s+/p\b|choice\b|pause\b)"
r"|^\s*@?\s*(?:if|else)\b.*?\s(?:set\s+/p\b|choice\b|pause\b)",
re.IGNORECASE,
)
# A helper filename, with or without its `scripts/` prefix: sibling invocations
# such as "$SCRIPT_DIR/build_deps.sh" carry no prefix. Resolved against the repo
# before it counts, so a name that is not a real script is ignored.
_HELPER_REF = re.compile(r"(?<![$\w])((?:[A-Za-z0-9_.-]+[\\/])*[A-Za-z0-9_.-]+\.(?:sh|ps1|py|bat))")
_QUOTED = re.compile(r'"([^"\\]*(?:\\.[^"\\]*)*)"' r"|'([^']*)'")
# An f-string field that calls something.
_FIELD = re.compile(r"\{[^}]*\(")
# `<# ... #>` opened and closed on one line.
_INLINE_BLOCK = re.compile(r"<#.*?#>", re.DOTALL)
# `@' ... '@` is literal, unlike the expandable `@" ... "@`. The uninstaller prints
# its help from one and install.ps1 embeds source in the other.
_HERESTRING_OPEN = re.compile(r"@'\s*$")
_HERESTRING_CLOSE = re.compile(r"^\s*'@")
# Regex/glob syntax: a `sed` pattern next to a prompt also contains a `?`.
_REGEXY = re.compile(r"\\|\(\?|\^|\$\(|\[0|\{[0-9]|\.\*|\|")
_ESCAPE = re.compile(r"\\[nrte]")
# A conversion or variable becomes a placeholder rather than vanishing, so wording
# spliced into the middle of an approved question cannot normalise back onto it.
_SUBSTITUTION = re.compile(r"%[-#0 +]*\d*(?:\.\d+)*[sdfxbq%]|\$\{?[A-Za-z_][A-Za-z0-9_]*\}?")
_TRAILING_SUBSTITUTION = re.compile(r"(?:\s*<var>)+$")
def _is_comment(line: str) -> bool:
"""Whole-line `#` comment. Inline ones need a parser to tell from a `#` inside
a string, and a false positive only costs an allowlist entry."""
return line.lstrip().startswith("#")
def _blank_strings(line: str, script: str = "") -> str:
"""Blank quoted text so a message naming an input API is not one. A string that
still executes is kept: a `$(...)` in a double-quoted shell or PowerShell string,
a field in an f-string. Single quotes and a missing f prefix are literal."""
def keep(match: re.Match) -> str:
text = match.group(0)
if script.endswith(".py"):
# Past the quotes, so a triple-quoted f-string keeps its prefix.
prefix = line[: match.start()].rstrip("\"'")[-2:].lower()
expands = "f" in prefix and _FIELD.search(text)
else:
expands = text.startswith('"') and "$(" in text
return text if expands else '""'
return _QUOTED.sub(keep, line)
def _is_interactive_read(
line: str,
script: str,
*,
loop_input: bool = False,
) -> bool:
"""A read that waits on a person: a prompt option, /dev/tty, `select`, `input()`,
`set /p`, or plain inherited stdin. Redirected and loop reads consume a file, not
a person. Quoted text is blanked first, so a message naming `Read-Host` is not
one."""
code = _blank_strings(line, script)
if script.endswith(".ps1"):
return bool(_PWSH_READ.search(code))
if script.endswith(".py"):
return bool(_PY_READ.search(code))
if script.endswith(".bat"):
# `set /p version=<VERSION.txt` reads the file, not the user, but the
# redirection belongs to that command alone.
return any("<" not in part and _BAT_READ.search(part) for part in code.split("&"))
if _POSIX_READ.search(code) or _SELECT.search(code):
return True
# Inside a file-fed loop only a bare read consumes the file: an explicit
# /dev/tty or -p read above overrode it and still waits on the terminal.
if loop_input:
return False
# Per command: in `read -r reply; echo done | tee log` the pipe is the echo's.
# `||` is a fallback, not a pipeline, and leaves the read on the terminal.
for command in code.split(";"):
piped = "|" in command.replace("||", "")
if "<" not in command and not piped and _BARE_READ.search(command):
return True
return False
# `<<EOF`, not the `<<<` here-string, and not inside a quoted string: install.sh
# prints a shell-profile marker containing `# <<< Unsloth ... <<<`.
_HEREDOC = re.compile(r"<<(?!<)-?\s*[\"']?([A-Za-z_][A-Za-z0-9_]*)[\"']?")
_INTERPRETER = re.compile(r"\b(?:python[0-9.]*|node|perl|ruby|osascript)\b[^<]*<<")
def _blank_heredocs(lines: list[str]) -> list[str]:
"""Blank heredoc bodies, keeping the line count. A `read -r reply` shown in a
help text is documentation: scripts/uninstall.sh already prints one."""
out = list(lines)
terminator = ""
start = 0
for index, line in enumerate(lines):
if terminator:
if line.strip() == terminator:
out[start:index] = [""] * (index - start)
terminator = ""
continue
# Openers come from code: not from a string, not from an inline comment.
code = _blank_strings(line).split("#")[0]
match = _HEREDOC.search(code)
# `python - <<PY` runs its body. Blanking that would hide real code, which
# is the one thing worse than scanning it as shell.
if match and not _INTERPRETER.search(code):
terminator, start = match.group(1), index + 1
# An unterminated opener was not one. Blanking to EOF would silently blind the
# scan for the rest of the file, prompts included.
return out
def _redirected_loop_bodies(lines: list[str]) -> set[int]:
"""Indices inside a `do ... done < file` block. The redirection feeds every read
in the body, but it sits on the `done`, so the read line alone looks interactive."""
inside: set[int] = set()
opened: list[int] = []
for index, line in enumerate(lines):
if re.search(r"(?:^|[;&])\s*do\b|\bdo\s*$", line):
opened.append(index)
if re.match(r"\s*done\b", line):
if not opened:
continue
start = opened.pop()
# Only stdin feeds them: `done | tee` pipes the output away, and
# `done 3<config` opens a spare descriptor.
if re.search(r"(?<![1-9])<", line):
inside.update(range(start, index))
return inside
def _outside_docstring(line: str, delimiter: str) -> tuple[str, str]:
"""Strip triple-quoted regions from a Python line, returning what is left and the
delimiter still open. A docstring showing an `input()` example documents the
script, it does not prompt."""
while True:
if delimiter:
end = line.find(delimiter)
if end == -1:
return "", delimiter
line, delimiter = line[end + 3 :], ""
continue
opener = min((i for i in (line.find('"""'), line.find("'''")) if i != -1), default = -1)
if opener == -1:
return line, ""
# An f-string field executes, so this one is code, not documentation.
if "f" in line[max(0, opener - 2) : opener].lower():
return line, ""
head, delimiter = line[:opener], line[opener : opener + 3]
rest, delimiter = _outside_docstring(line[opener + 3 :], delimiter)
return head + rest, delimiter
def blank_comments(source: str, script: str) -> str:
"""Blank comments, keeping the line count so numbers still line up. `.ps1` files
carry `<# ... #>` blocks, where a documented Read-Host example would otherwise
fail CI over a prompt that cannot run. `.bat` comments are REM or `::`."""
lines = []
in_block = False
in_docstring = ""
powershell = script.endswith(".ps1")
batch = script.endswith(".bat")
python = script.endswith(".py")
opened = -1
in_herestring = False
for index, line in enumerate(source.splitlines()):
if powershell and in_block:
# Code can follow the terminator on the same line.
lines.append(line.split("#>", 1)[1] if "#>" in line else "")
in_block = "#>" not in line
continue
# A comment naming a delimiter is not one: reading it as an opener would
# blank the rest of the file and take every prompt in it out of the scan.
if _is_comment(line) or (batch and re.match(r"\s*(?:REM\b|::)", line, re.IGNORECASE)):
lines.append("")
continue
if python:
line, in_docstring = _outside_docstring(line, in_docstring)
if in_docstring and opened < 0:
opened = index
elif not in_docstring:
opened = -1
if powershell and in_herestring:
lines.append("")
in_herestring = not _HERESTRING_CLOSE.match(line)
continue
if powershell:
if _HERESTRING_OPEN.search(_blank_strings(line)):
in_herestring = True
line = _INLINE_BLOCK.sub("", line)
if "<#" in line:
lines.append(line.split("<#", 1)[0])
in_block, opened = True, index
continue
lines.append(line)
if in_block or in_docstring:
# Unterminated, so it was never a comment or a docstring. Put the lines back.
original = source.splitlines()
lines[opened + 1 :] = original[opened + 1 :]
return "\n".join(lines)
def _quoted_strings(line: str) -> list[str]:
"""Outer strings first, then anything quoted inside an executable region."""
found = []
for match in _QUOTED.finditer(line):
for group in match.groups():
if not group:
continue
found.append(group)
if "$(" in group or _FIELD.search(group):
found.extend(_quoted_strings(group))
return found
def normalise_question(text: str) -> str:
"""Reduce a prompt string to a stable allowlist key: drop the marker and edge
punctuation, placeholder the substitutions, then lowercase."""
text = _MARKER.sub(" ", text)
text = _ESCAPE.sub(" ", text)
text = _SUBSTITUTION.sub("<var>", text)
text = re.sub(r"\s+", " ", text).strip()
# A trailing one is the `[Y/n]` hint the marker pass already read.
text = _TRAILING_SUBSTITUTION.sub("", text)
return text.strip(" -:>*_=").lower()
def _looks_like_question(text: str) -> bool:
"""Prose with a question mark, not a `sed`/`-match` pattern that has one."""
return "?" in text and not _REGEXY.search(text)
def _nearby_questions(lines: list[str], index: int, *, direction: int) -> list[str]:
"""Readable questions around `lines[index]`, nearest first. Walk back (-1) from a
read site to the `printf` that drew it, forward (+1) from a bare `[Y/n]` hint
assigned into a variable and interpolated below (the #7016 shape)."""
candidates = list(_quoted_strings(lines[index]))
for step in range(1, 9):
neighbour = index + direction * step
if not 0 <= neighbour < len(lines):
break
line = lines[neighbour]
if not line.strip():
continue
candidates.extend(_quoted_strings(line))
questions = []
for text in candidates:
if not _looks_like_question(text):
continue
normalised = normalise_question(text)
if normalised and normalised not in questions:
questions.append(normalised)
return questions
def _questions_for_read(lines: list[str], index: int) -> list[str]:
"""Allowlist keys for a read site. Every question in reach, not just the nearest:
one read can serve a branch each, and validating only the closest lets the other
branch through. Falls back to the read line so an unlabelled prompt still has to
be allowlisted rather than ignored."""
return _nearby_questions(lines, index, direction = -1) or [
f"<unlabelled read: {normalise_question(lines[index])}>"
]
def find_prompts(script: str, source: str) -> list[tuple[str, int, str]]:
"""Return (script, line_number, normalised question) for every prompt site."""
lines = blank_comments(source, script).splitlines()
if not script.endswith((".ps1", ".py", ".bat")):
lines = _blank_heredocs(lines)
redirected = _redirected_loop_bodies(lines)
found: dict[tuple[str, str], tuple[str, int, str]] = {}
for index, line in enumerate(lines):
line_number = index + 1
for text in _quoted_strings(line):
if not _MARKER.search(text):
continue
# A bare `[Y/n]` is a hint variable; its question is printed below.
forward = _nearby_questions(lines, index, direction = 1)
question = (
normalise_question(text)
or (forward[0] if forward else "")
or f"<yes/no marker with no question: {text.strip()}>"
)
found.setdefault((script, question), (script, line_number, question))
if _is_interactive_read(line, script, loop_input = index in redirected):
for question in _questions_for_read(lines, index):
found.setdefault((script, question), (script, line_number, question))
return sorted(found.values(), key = lambda item: item[1])
def _failure_message(script: str, line_number: int, question: str) -> str:
return (
f"\n"
f"A new interactive yes/no prompt was added to {script} (line {line_number}):\n"
f"\n"
f" {question!r}\n"
f"\n"
f"Installers and setup scripts must not grow new questions. The only\n"
f"preference the setup is allowed to ask about is 'Start Unsloth Studio\n"
f"now?'; the remaining approved prompts are sudo consent before we\n"
f"elevate. A prompt added here stalls `curl ... | sh` installs and\n"
f"persists an answer the user cannot easily find again. This is what\n"
f"#7016 did and why it was reverted in #8040.\n"
f"\n"
f"VERIFY THAT THIS PROMPT IS SUPPOSED TO BE HERE.\n"
f"\n"
f" - If it is not: remove it. Take the setting as a flag or an\n"
f" environment variable with a non-interactive default instead.\n"
f" - If it genuinely is: add\n"
f"\n"
f' ({script!r}, {question!r}): "why this prompt is needed",\n'
f"\n"
f" to APPROVED_PROMPTS in tests/test_installer_interactive_prompts.py\n"
f" in the same PR, so the decision shows up in the diff.\n"
)
@pytest.mark.parametrize("script", SCANNED_SCRIPTS)
def test_no_unapproved_interactive_prompts(script: str):
path = REPO_ROOT / script
assert path.is_file(), f"{script} is missing -- update SCANNED_SCRIPTS if it moved"
for found_script, line_number, question in find_prompts(
script, path.read_text(encoding = "utf-8")
):
if (found_script, question) not in APPROVED_PROMPTS:
pytest.fail(_failure_message(found_script, line_number, question), pytrace = False)
def test_approved_prompts_all_still_exist():
"""Delete a prompt, delete its entry: a stale one waves through a future prompt
that happens to reuse the wording."""
live = set()
for script in SCANNED_SCRIPTS:
source = (REPO_ROOT / script).read_text(encoding = "utf-8")
live.update(
(found_script, question) for found_script, _, question in find_prompts(script, source)
)
stale = sorted(key for key in APPROVED_PROMPTS if key not in live)
assert not stale, (
f"APPROVED_PROMPTS lists prompts that no longer exist: {stale}. "
f"Remove the entries from tests/test_installer_interactive_prompts.py."
)
def test_every_installer_script_is_scanned():
"""A prompt in a script nobody scans is the same regression, one step removed."""
patterns = (
"install*.sh",
"install*.ps1",
"studio/setup*.sh",
"studio/setup*.ps1",
"studio/setup*.bat",
"setup*.sh",
"setup*.ps1",
"setup*.bat",
"install*.bat",
"install*.py",
"studio/install_*.py",
"scripts/install*.sh",
"scripts/install*.ps1",
"uninstall*.sh",
"uninstall*.ps1",
"uninstall*.bat",
"uninstall*.py",
"scripts/uninstall*.sh",
"scripts/uninstall*.ps1",
)
on_disk = {
path.relative_to(REPO_ROOT).as_posix()
for pattern in patterns
for path in REPO_ROOT.glob(pattern)
}
unscanned = sorted(on_disk - set(SCANNED_SCRIPTS))
assert not unscanned, (
f"installer/setup scripts not covered by the prompt guard: {unscanned}. "
f"Add them to SCANNED_SCRIPTS in tests/test_installer_interactive_prompts.py "
f"and to the paths filter in .github/workflows/cross-platform-parity-ci.yml."
)
def test_helpers_the_installers_invoke_are_scanned():
"""Naming the helpers by hand only ever covers the ones we thought of, so read
them back out instead: whatever the installers reach, the guard scans. Every
scanned script is a source, not just the entry points, so a helper that grows
a helper of its own is caught as soon as the first one is listed here."""
referenced = set()
for script in SCANNED_SCRIPTS:
path = REPO_ROOT / script
source = blank_comments(path.read_text(encoding = "utf-8"), script)
for match in _HELPER_REF.finditer(source):
# Below the script that names it, below the repo, or in scripts/, by
# full path and by name so a URL still resolves to the local copy.
# What resolves nowhere is a filename in a message, not an invocation.
reference = match.group(1).replace("\\", "/")
name = reference.rsplit("/", 1)[-1]
for candidate in (
path.parent / reference,
REPO_ROOT / reference,
path.parent / name,
REPO_ROOT / "scripts" / name,
):
if candidate.is_file():
referenced.add(candidate.resolve().relative_to(REPO_ROOT).as_posix())
unscanned = sorted(referenced - set(SCANNED_SCRIPTS))
assert not unscanned, (
f"helpers invoked by the installers but not scanned: {unscanned}. "
f"Add them to SCANNED_SCRIPTS in tests/test_installer_interactive_prompts.py "
f"and to the paths filter in .github/workflows/cross-platform-parity-ci.yml."
)
def test_the_workflow_runs_for_every_scanned_script():
"""A scanned script whose path filter is missing is only checked on the PR that
registers it. Read the filters back rather than keeping two lists in step by
hand. Parsed with a regex, not yaml: the parity runner installs pytest only."""
workflow = (REPO_ROOT / ".github/workflows/cross-platform-parity-ci.yml").read_text(
encoding = "utf-8"
)
blocks, collecting = [], None
for line in workflow.splitlines():
entry = re.match(r"^\s*-\s*'([^']+)'\s*$", line)
if collecting is not None and entry:
collecting.append(entry.group(1))
continue
# A comment or a blank line inside the list does not end it. Treating one as the end
# dropped every filter after it, so this guard passed while reading nothing.
if collecting is not None and (not line.strip() or line.strip().startswith("#")):
continue
if collecting:
blocks.append(collecting)
collecting = [] if line.strip() == "paths:" else None
if collecting:
blocks.append(collecting)
assert blocks, "no paths: filters found in cross-platform-parity-ci.yml"
def matcher(pattern: str) -> re.Pattern:
# GitHub's `*` stops at a path separator; `**` does not.
body = (
re.escape(pattern)
.replace(r"\*\*", "\x00")
.replace(r"\*", "[^/]*")
.replace("\x00", ".*")
)
return re.compile(body + "$")
def covered(script: str, patterns: list[str]) -> bool:
# GitHub applies `!` exclusions in order, so the last match decides.
included = False
for pattern in patterns:
negated = pattern.startswith("!")
if matcher(pattern.lstrip("!")).match(script):
included = not negated
return included
# Each event on its own: a filter present only on push leaves pull requests bare.
for patterns in blocks:
uncovered = sorted(script for script in SCANNED_SCRIPTS if not covered(script, patterns))
assert not uncovered, (
f"scanned scripts with no path filter in cross-platform-parity-ci.yml: {uncovered}. "
f"A later PR touching only one of these would not run this guard."
)
def test_approved_prompts_are_documented():
for key, reason in APPROVED_PROMPTS.items():
assert reason.strip(), f"APPROVED_PROMPTS[{key}] needs a reason, not an empty string"
# Detector self-tests: a scan that silently stops matching passes everything.
def test_detects_literal_marker_prompt():
source = 'printf " Enable telemetry? [Y/n] "\nread -r _reply </dev/tty || _reply="n"\n'
assert find_prompts("install.sh", source) == [("install.sh", 1, "enable telemetry?")]
def test_detects_prompt_whose_marker_comes_from_a_variable():
"""The #7016 shape: only the read site gives it away."""
source = (
'_browser_hint="[Y/n]"\n'
'printf " Open Unsloth Studio in your default browser after launch? %s " "$_browser_hint"\n'
'read -r _browser_reply </dev/tty || _browser_reply=""\n'
)
questions = [question for _, _, question in find_prompts("install.sh", source)]
assert "open unsloth studio in your default browser after launch?" in questions
def test_detects_powershell_prompt_whose_marker_comes_from_a_variable():
source = (
"$_browserHint = if ($_existingPref -eq '0') { '[y/N]' } else { '[Y/n]' }\n"
'$_browserReply = Read-Host " Open Unsloth Studio in your default browser after launch? $_browserHint"\n'
)
assert [question for _, _, question in find_prompts("install.ps1", source)] == [
"open unsloth studio in your default browser after launch?",
]
def test_detects_read_dash_p():
source = 'read -p "Keep existing config? [y/N] " _reply\n'
assert [question for _, _, question in find_prompts("install.sh", source)] == [
"keep existing config?",
]
@pytest.mark.parametrize(
"read_line",
(
'read -p " Build llama.cpp with CUDA support? " _reply',
'read -rp " Build llama.cpp with CUDA support? " _reply',
'read -rsp " Build llama.cpp with CUDA support? " _reply',
'read -r -p " Build llama.cpp with CUDA support? " _reply',
'read -p" Build llama.cpp with CUDA support? " _reply',
),
)
def test_detects_read_dash_p_without_a_marker(read_line: str):
"""`-p` carries the prompt itself, so there is no marker and only the read pass
stands between these and a stalled install. Bundling is the usual bash
spelling, so it must not hinge on a space before `-p`."""
assert [question for _, _, question in find_prompts("studio/setup.sh", read_line + "\n")] == [
"build llama.cpp with cuda support?",
]
def test_ignores_dash_p_belonging_to_a_later_command():
"""`mkdir -p` after a non-interactive read is not a prompt: matching it fails CI
on an ordinary installer edit."""
source = (
'read -r _line < "$config_file"; mkdir -p "$_dest"\n'
'while IFS= read -r _root; do mkdir -p "$_root"; done < "$manifest"\n'
'read -r _v < "$_pci_vendor" && install -p "$_v" "$_dest"\n'
)
assert find_prompts("install.sh", source) == []
def test_detects_powershell_read_host():
source = '$reply = Read-Host " Install desktop shortcuts? [Y/n]"\n'
assert [question for _, _, question in find_prompts("install.ps1", source)] == [
"install desktop shortcuts?",
]
def test_detects_powershell_console_readline():
"""Read-Host is the usual spelling, but the console reads block just as hard and
can carry their question in a variable, out of the marker pass's reach."""
source = (
'Write-Host " Install desktop shortcuts? $_hint" -NoNewline\n'
"$reply = [Console]::ReadLine()\n"
)
assert [question for _, _, question in find_prompts("install.ps1", source)] == [
"install desktop shortcuts?",
]
@pytest.mark.parametrize("read_line", ("read -r _reply", "read _reply", " read -r _reply"))
def test_detects_a_bare_read_from_inherited_stdin(read_line: str):
"""The commonest prompt of all: a question printed with no marker, answered by a
read with no redirection. It takes the terminal the installer inherited."""
source = f'printf " Continue with installation? "\n{read_line}\n'
assert [question for _, _, question in find_prompts("install.sh", source)] == [
"continue with installation?",
]
def test_detects_a_bare_read_with_a_fallback():
"""`|| reply=n` is the house style for an EOF default, and a fallback is not a
pipeline: the read still takes the terminal."""
source = 'printf " Continue with installation? "\nread -r _reply || _reply=n\n'
assert [question for _, _, question in find_prompts("install.sh", source)] == [
"continue with installation?",
]
@pytest.mark.parametrize(
"read_line", ("read -r -n 1 _reply", "read -r -t 10 _reply", "read -rn1 _reply")
)
def test_detects_a_bare_read_with_option_arguments(read_line: str):
"""A one-character or timed confirmation is still a confirmation."""
source = f'printf " Continue with installation? "\n{read_line}\n'
assert [question for _, _, question in find_prompts("install.sh", source)] == [
"continue with installation?",
]
def test_detects_an_assignment_prefixed_read():
"""`IFS= read -r reply` is one command, not an assignment."""
source = 'printf " Continue with installation? "\nIFS= read -r _reply\n'
assert [question for _, _, question in find_prompts("install.sh", source)] == [
"continue with installation?",
]
def test_a_comment_naming_a_delimiter_opens_nothing():
"""The heredoc lesson applied to the other two languages: reading a delimiter out
of a comment blanks the rest of the file and takes every prompt in it with it."""
python_source = '# opening delimiter is """\nprint(" Enable telemetry? ")\n_reply = input()\n'
assert [
question for _, _, question in find_prompts("studio/install_python_stack.py", python_source)
] == ["enable telemetry?"]
pwsh_source = '# block comments begin with <#\n$reply = Read-Host " Enable telemetry? "\n'
assert [question for _, _, question in find_prompts("install.ps1", pwsh_source)] == [
"enable telemetry?",
]
def test_an_unterminated_docstring_blanks_nothing():
source = '_text = """open\nprint(" Enable telemetry? ")\n_reply = input()\n'
questions = [
question for _, _, question in find_prompts("studio/install_python_stack.py", source)
]
assert "enable telemetry?" in questions
def test_detects_a_read_with_a_trailing_comment():
source = 'printf " Enable telemetry? "\nread -r _reply # use the inherited terminal\n'
assert [question for _, _, question in find_prompts("install.sh", source)] == [
"enable telemetry?",
]
def test_an_inline_comment_does_not_open_a_heredoc():
source = ': # example uses <<EOF\nprintf " Enable telemetry? "\nread -r _reply\n'
questions = [question for _, _, question in find_prompts("install.sh", source)]
assert "enable telemetry?" in questions
def test_an_unterminated_heredoc_opener_blanks_nothing():
"""Blanking to end of file would silently blind the scan for the rest of it."""
source = 'cat <<EOF\nstuff\nprintf " Enable telemetry? "\nread -r _reply\n'
questions = [question for _, _, question in find_prompts("install.sh", source)]
assert "enable telemetry?" in questions
def test_a_pipe_on_a_later_command_does_not_feed_the_read():
source = 'printf " Enable telemetry? "\nread -r _reply; echo done | tee install.log\n'
questions = [question for _, _, question in find_prompts("install.sh", source)]
assert "enable telemetry?" in questions
def test_a_spare_descriptor_on_the_done_is_not_stdin():
source = (
'while true; do\n printf " Enable telemetry? "\n read -r _reply\ndone 3<config\n'
)
questions = [question for _, _, question in find_prompts("install.sh", source)]
assert "enable telemetry?" in questions
def test_ignores_code_shaped_text_in_a_literal_string():
"""Single quotes do not expand in PowerShell, and a plain Python string has no
fields. Keeping those would fail CI on a diagnostic message."""
assert find_prompts("install.ps1", "Write-Host 'Example: $(Read-Host \"Q?\")'\n") == []
assert (
find_prompts("studio/install_python_stack.py", "print(\"Example: {input('Q?')}\")\n") == []
)
def test_ignores_a_batch_read_from_a_file():
assert find_prompts("studio/setup.bat", "set /p version=<VERSION.txt\n") == []
def test_detects_a_read_followed_by_another_command():
source = 'printf " Continue with installation? "\nread -r _reply; echo done\n'
assert [question for _, _, question in find_prompts("install.sh", source)] == [
"continue with installation?",
]
def test_keeps_a_loop_read_when_only_the_output_is_piped():
"""`done | tee` consumes the loop's stdout. Its reads still take the terminal."""
source = 'while true; do\n printf " Enable telemetry? "\n read -r _reply\ndone | tee install.log\n'
questions = [question for _, _, question in find_prompts("install.sh", source)]
assert "enable telemetry?" in questions
def test_ignores_a_command_documented_in_a_heredoc():
source = "_usage() {\n cat <<EOF\nExample: read -r reply\nEOF\n}\n"
assert find_prompts("scripts/uninstall.sh", source) == []
def test_a_here_string_does_not_open_a_heredoc():
"""`<<<` is a here-string, and install.sh prints one inside a profile marker.
Reading it as a heredoc blanked the rest of the file, prompts included."""
source = (
"printf '# <<< Unsloth marker <<<\\n'\nprintf \" Enable telemetry? \"\nread -r _reply\n"
)
questions = [question for _, _, question in find_prompts("install.sh", source)]
assert "enable telemetry?" in questions
def test_detects_a_prompt_inside_a_triple_quoted_f_string():
source = 'print(f"""Answer: {input(\'Enable telemetry? \')}""")\n'
questions = [
question for _, _, question in find_prompts("studio/install_python_stack.py", source)
]
assert "enable telemetry?" in questions
def test_detects_a_directly_imported_getpass():
source = 'print(" Continue with installation? ")\n_reply = getpass()\n'
assert [
question for _, _, question in find_prompts("studio/install_python_stack.py", source)
] == ["continue with installation?"]
def test_detects_a_batch_pause():
"""It asks nothing, but an unattended install still stops dead on it."""
source = "echo Review the notes above.\npause\n"
assert len(find_prompts("studio/setup.bat", source)) == 1
def test_detects_a_batch_prompt_in_a_conditional():
source = 'if exist config choice /M "Enable telemetry?"\n'
assert [question for _, _, question in find_prompts("studio/setup.bat", source)] == [
"enable telemetry?",
]
def test_detects_a_negated_read():
"""`if ! cmd` is house style in these scripts; a read is no different."""
source = 'printf " Enable telemetry? "\nif ! read -r _reply; then _reply=n; fi\n'
assert [question for _, _, question in find_prompts("install.sh", source)] == [
"enable telemetry?",
]
def test_an_interpreter_heredoc_is_not_blanked():
"""`python - <<PY` runs its body. Hiding real code is worse than reading it as
shell, which still leaves the marker pass looking at it."""
source = 'python - <<PY\nprint("Enable telemetry? [Y/n]")\nPY\n'
questions = [question for _, _, question in find_prompts("install.sh", source)]
assert "enable telemetry?" in questions
def test_detects_a_read_used_as_a_condition():
source = 'printf " Continue with installation? "\nif read -r _reply; then :; fi\n'
assert [question for _, _, question in find_prompts("install.sh", source)] == [
"continue with installation?",
]
def test_keeps_an_explicit_terminal_read_inside_a_fed_loop():
"""`</dev/tty` overrides the loop's stdin, so only a bare read consumes the file."""
source = (
'while true; do\n printf " Enable telemetry? "\n'
" read -r _reply </dev/tty\ndone < manifest\n"
)
questions = [question for _, _, question in find_prompts("install.sh", source)]
assert "enable telemetry?" in questions
def test_a_batch_redirection_belongs_to_its_own_command():
source = 'choice /M "Enable telemetry?" & set /p version=<VERSION.txt\n'
questions = [question for _, _, question in find_prompts("studio/setup.bat", source)]
assert "enable telemetry?" in questions
def test_ignores_a_powershell_literal_here_string():
"""`@' ... '@` cannot expand, and the uninstaller prints its help from one."""
source = "Write-Host @'\nRead-Host \"Enable telemetry? [Y/n]\"\n'@\n"
assert find_prompts("scripts/uninstall.ps1", source) == []
def test_ignores_a_read_in_a_loop_redirected_at_the_done():
"""The redirection feeds the read, it just sits three lines below it."""
source = 'while true; do\n read -r _line || break\ndone < "$manifest"\n'
assert find_prompts("install.sh", source) == []
def test_detects_a_python_stdin_read():
source = 'print(" Continue with installation? ")\n_reply = sys.stdin.readline()\n'
assert [
question for _, _, question in find_prompts("studio/install_python_stack.py", source)
] == [
"continue with installation?",
]
def test_detects_a_prompt_inside_an_f_string():
"""An f-string field executes, so blanking the string cannot blank it."""
source = "print(f\"Answer: {input('Enable telemetry? ')}\")\n"
questions = [
question for _, _, question in find_prompts("studio/install_python_stack.py", source)
]
assert "enable telemetry?" in questions
def test_ignores_a_python_function_named_confirm():
source = "def confirm(value):\n return value\n"
assert find_prompts("studio/install_python_stack.py", source) == []
def test_detects_a_batch_prompt_with_echo_suppressed():
source = '@choice /M "Enable telemetry?"\n'
assert [question for _, _, question in find_prompts("studio/setup.bat", source)] == [
"enable telemetry?",
]
def test_detects_a_read_with_no_variable():
"""`read -r` on its own is valid: the answer lands in $REPLY."""
source = 'printf " Continue with installation? "\nread -r\n'
assert [question for _, _, question in find_prompts("install.sh", source)] == [
"continue with installation?",
]
def test_detects_a_prompt_inside_an_expandable_string():
"""A `$(...)` subexpression executes, so blanking quoted text cannot blank it."""
source = "Write-Host \"Response: $(Read-Host 'Enable telemetry?')\"\n"
assert [question for _, _, question in find_prompts("install.ps1", source)] == [
"enable telemetry?",
]
def test_ignores_a_batch_input_command_named_in_a_message():
source = 'echo choice /M "Enable telemetry?" is not supported here\n'
assert find_prompts("studio/setup.bat", source) == []
def test_ignores_a_python_docstring_example():
source = '"""Doc.\n\nreply = input("Enable telemetry? [Y/n]")\n"""\nvalue = 1\n'
assert find_prompts("studio/install_python_stack.py", source) == []
def test_helper_reference_keeps_a_windows_subdirectory():
assert _HELPER_REF.findall(r'& "$PSScriptRoot\helpers\setup_extra.ps1"') == [
r"helpers\setup_extra.ps1",
]
def test_detects_a_python_helper_prompt():
"""setup.sh runs these with `python <helper>.py`, so `input()` blocks the update."""
source = 'reply = input("Enable telemetry? [Y/n] ")\n'
assert [question for _, _, question in find_prompts("studio/install_python_stack.py", source)][
0
] == "enable telemetry?"
def test_detects_a_batch_prompt():
source = 'set /p _reply="Enable telemetry? [Y/n] "\n'
assert [question for _, _, question in find_prompts("studio/setup.bat", source)] == [
"enable telemetry?",
]
def test_keeps_code_after_a_block_comment_terminator():
source = '<#\n.SYNOPSIS\n#> $reply = Read-Host "Enable telemetry? [Y/n]"\n'
assert [question for _, _, question in find_prompts("install.ps1", source)] == [
"enable telemetry?",
]
def test_detects_powershell_host_ui_readline():
source = 'Write-Host " Continue? " -NoNewline\n$reply = $Host.UI.ReadLine()\n'
assert [question for _, _, question in find_prompts("install.ps1", source)] == ["continue?"]
def test_helper_reference_keeps_its_subdirectory():
"""A nested helper resolves by its path, a URL by its name."""
assert _HELPER_REF.findall('sh "$SCRIPT_DIR/helpers/build_deps.sh"') == [
"helpers/build_deps.sh",
]
def test_detects_a_select_loop():
"""The other builtin that blocks for an answer. PS3 holds the question."""
source = 'PS3=" Continue with installation? "\nselect _reply in Yes No; do break; done\n'
assert [question for _, _, question in find_prompts("install.sh", source)] == [
"continue with installation?",
]
def test_interpolation_cannot_rewrite_an_approved_question():
"""Splicing new wording into an approved prompt is a new question. A substitution
at the end is only the marker hint, so that one still normalises away."""
assert normalise_question("Start Unsloth Studio %s now? [Y/n]") == (
"start unsloth studio <var> now?"
)
assert normalise_question(" Start Unsloth Studio now? %s ") == "start unsloth studio now?"
def test_ignores_an_input_api_named_in_a_message():
source = 'Write-Host "Read-Host is unavailable on this terminal"\n'
assert find_prompts("install.ps1", source) == []
def test_ignores_a_single_line_powershell_block_comment():
source = '<# Read-Host "Enable telemetry? [Y/n]" #>\nWrite-Host "done"\n'
assert find_prompts("install.ps1", source) == []
def test_detects_powershell_console_read():
source = 'Write-Host " Continue? " -NoNewline\n$key = [Console]::Read()\n'
assert [question for _, _, question in find_prompts("install.ps1", source)] == ["continue?"]
def test_validates_every_question_a_read_can_ask():
"""One read serving a branch each: approving the nearest question would wave the
other one through under an allowlisted key."""
source = (
'if [ "$_telemetry" = ask ]; then\n'
' printf " Enable telemetry? [Y/n] "\n'
"else\n"
' printf " Start Unsloth Studio now? [Y/n] "\n'
"fi\n"
'read -r _reply </dev/tty || _reply="n"\n'
)
questions = {question for _, _, question in find_prompts("install.sh", source)}
assert "enable telemetry?" in questions
def test_ignores_a_powershell_block_comment():
"""Comment-based help documents what the script does, sometimes by example. A
prompt that cannot run must not fail CI: studio/setup.ps1 opens with such a block."""
source = (
"<#\n"
".SYNOPSIS\n"
' Historically this asked Read-Host "Enable telemetry? [Y/n]" here.\n'
"#>\n"
'Write-Host "done"\n'
)
assert find_prompts("install.ps1", source) == []
def test_helper_reference_matches_a_sibling_path():
"""Helpers invoke each other by their own directory, with no scripts/ prefix."""
assert _HELPER_REF.findall('run_quiet "$SCRIPT_DIR/build_deps.sh" --yes') == ["build_deps.sh"]
def test_detects_unlabelled_read():
source = 'read -r _reply </dev/tty || _reply="n"\n'
questions = [question for _, _, question in find_prompts("install.sh", source)]
assert len(questions) == 1 and questions[0].startswith("<unlabelled read:")
def test_ignores_comments_and_non_interactive_reads():
source = (
"# Ask the user [Y/n] before doing anything.\n"
'read -r _line < "$config_file"\n'
'while IFS= read -r _entry; do :; done < "$manifest"\n'
"read _major _minor <<EOF\n1 2\nEOF\n"
)
assert find_prompts("install.sh", source) == []
def test_reports_the_prompt_in_the_failure_message():
message = _failure_message("install.sh", 42, "enable telemetry?")
assert "enable telemetry?" in message
assert "APPROVED_PROMPTS" in message
assert "#8040" in message