Compare commits

..

83 commits

Author SHA1 Message Date
danielhanchen
236e89d0c9 Harden sandbox: workdir-vetter alias / namespace / MRO / sys.modules gaps, network client entry points, request() URL arg, sqlite URI mode parsing
Close eight gaps Codex found on the round-60 branch (seven inline P1 plus one
review-body P1). Five are in the runtime workdir-module import vetter (a planted
workdir helper is not scanned by the outer static pass), two are in the static
network scanner, and one is the runtime sqlite URI parser.

- assigned os aliases in the vetter: the pre-pass only recorded `import os as o`
  aliases, so a workdir helper doing `import os; o = os; o.system(...)` passed
  vetting and spawned an unguarded child. Follow simple whole-module assignments
  (o = os; b = builtins; s = sys) to a fixpoint before the sink checks.

- module namespace-dict subscripts in the vetter: `os.__dict__['system'](...)`
  reached the sink because __dict__ was not a gadget and the subscript branch only
  failed closed for builtins. Fail closed on a `<module>.__dict__[...]` subscript
  for os / posix / deserializer / sys / importlib, like vars(<module>).

- module __getattribute__ / __getattr__ in the vetter: `os.__getattribute__(
  'system')(...)` reached the sink because only the builtin getattr(...) form was
  recognized. Classify bound `module.__getattribute__('name')` and unbound
  `object.__getattribute__(module, 'name')` lookups the same way as getattr.

- MRO base recovery in the vetter: `io.FileIO.__mro__[1]('/tmp/x','w')` /
  `sqlite3.Connection.__mro__[1](...)` recovered an unguarded base class because the
  helper gadget set omitted __mro__ / mro. Add both to the gadget attributes.

- sys.modules in the vetter: `sys.modules['os'].system(...)` recovered the
  guard-cached os module without an import, bypassing the denied-import path. Deny
  sys.modules access (direct attribute and getattr form) in a vetted workdir module.

- public network client entry points: `requests.api.get(...)`, `ftplib.FTP(...)`,
  and `smtplib.SMTP(...)` were not in the network prefix table, so a metadata /
  untrusted host reached through them bypassed the allowlist. Add requests.api.*,
  ftplib.FTP / FTP_TLS, and smtplib.SMTP / SMTP_SSL / LMTP (the ftplib / smtplib
  clients take a bare host), and track ftplib / smtplib import aliases.

- request(method, url) URL argument: module-level requests.request / httpx.request /
  urllib3.request (and requests.api.request) carry the URL at arg1, but the code
  passed arg0 (the HTTP method) to the host check, so the URL was never inspected.
  Read the URL from arg1 for these method-first APIs, like the client-instance
  .request() branch.

- sqlite URI mode=memory parsing: a `file:/tmp/escape.db?xmode=memory` URI was
  treated as in-memory by a substring test and skipped path confinement, but SQLite
  ignores the unknown xmode key and opens the on-disk file. Parse the query exactly
  (split on &, first occurrence of a repeated key, percent-decoded) and treat only a
  genuine mode=memory parameter as in-memory, in the runtime guard and the two static
  sqlite operand checks.

Regression coverage: TestRound61Bypasses in tests/test_sandbox_tools.py (request()
URL at arg1 for requests / httpx / urllib3 / requests.api, network client entry
points against metadata and untrusted hosts, sqlite shell xmode=memory on an
escaping absolute path, plus a benign-allowed set: trusted-host requests / api /
ftplib / smtplib, a genuine in-memory URI, and a workdir-relative db) and, in
tests/test_sandbox_runtime_backstop.py, workdir-module denials for the assigned os
alias, os.__dict__ subscript, os.__getattribute__, io.FileIO.__mro__, and
sys.modules['os'] forms, a benign os-alias helper that still imports, and the sqlite
URI xmode=memory escape denial plus a genuine mode=memory allowance.
2026-07-11 05:29:35 +00:00
danielhanchen
5343e2e993 Harden sandbox: const-fold loader-table getattr names, block type(instance) / sqlite Connection MRO recovery
Close three MRO / dynamic-attribute recovery gaps Codex found on the round-59
branch (all P1).

- const-folded loader-table getattr names: the sys.meta_path / sys.modules
  recognizers accepted only a raw string literal, so getattr(sys, 'meta_' +
  'path').pop(0) (or the sys.modules equivalent) removed the sandbox
  workdir-module vetter / dropped a guarded module before importing an unguarded
  workdir child that runs os.system / subprocess outside the session workdir.
  Add _extract_folded_string and use it for the getattr / __getattribute__ /
  __getattr__ attribute-name checks, so a folded or const-var name is recognized
  exactly like the literal. A benign read of the finder chain stays allowed.

- type(<guarded instance>).mro(): the whole-MRO recovery guard only recognized
  io.FileIO / obj.__class__ receivers, so type(io.FileIO('/dev/null','r')).mro()
  (or a same-name alias of that type(...)) iterated the guarded subclass's MRO to
  recover the original unguarded _io.FileIO base and read / write outside the
  workdir. Recognize type(<x>) where <x> constructs a guarded file / sqlite
  instance as a recovery receiver, and resolve a single-assignment alias of it.

- sqlite3.Connection MRO base recovery: the exported sqlite3.Connection is the
  guarded subclass whose MRO still exposes the unguarded _sqlite3.Connection base,
  so iterating sqlite3.Connection.mro() / .__mro__ recovered it and instantiated it
  with an absolute path, bypassing connect() and the guarded __init__. Treat the
  guarded sqlite3.Connection / _sqlite3.Connection / sqlite3.dbapi2.Connection as a
  recovery receiver so a whole-MRO walk of it is blocked like io.FileIO. The
  subscripted / popped forms were already caught; this closes the iteration form.

Regression coverage: TestRound60Bypasses in tests/test_sandbox_tools.py (folded
meta_path / sys.modules pop / clear / __getattribute__ and del-subscript
mutations, type(io.FileIO(...)) / type(sqlite3.connect(...)) whole-MRO access and
its alias, sqlite3.Connection / dbapi2.Connection MRO walks, plus a round60
benign-allowed set: reading sys.meta_path, a benign sys getattr, int.mro() /
type(42).mro(), a plain class access, an in-memory connect, and a plain dict pop).
2026-07-11 04:27:30 +00:00
danielhanchen
7e15a8bca9 Harden sandbox: exec namespace-dict aliases, bare-host + client-instance network calls, shuf/uniq writers, docstring guard splice
Close five gaps Codex found on the round-58 branch (four inline P1 plus one
review-body P1).

- exec/eval namespace-dict aliases: the outward-reference model for an exec /
  eval payload only collected ast.Name loads, so a constant-key namespace lookup
  (exec("globals()['f']('...')"), and the locals() / vars() forms) referenced the
  caller's f = os.system without a Name node and slipped past the alias check.
  Treat a literal key of a bare globals() / locals() / vars() subscript as a free
  outward reference so the caller-alias resolution runs on it.

- bare-host network APIs: http.client.HTTPConnection / HTTPSConnection and the
  socket name-resolution helpers (getaddrinfo, gethostbyname, gethostbyname_ex)
  take a HOST, not a URL, so their literal first arg (or host= keyword) was parsed
  as a scheme://host URL, found no scheme, and was never checked. Check the literal
  directly against the metadata denylist / allowlist for these callees, and add the
  forward name-resolution helpers to the scanned set.

- client-instance network calls: a request chained off a client constructor
  (requests.Session().get(url), httpx.Client().get(url), build_opener().open(url),
  or s.get(url) where s was bound to such a constructor) has a fully-qualified name
  of just the method, so it never matched a module-rooted network prefix and the
  host went unchecked. Match these by the receiver being a client-ctor call or a
  same-name single-assignment alias, extract the host (arg1 for .request), and run
  the same host check. A .get / .open on a plain dict or file receiver is excluded.

- shuf / uniq output writers: shuf -o / --output and a uniq second (output) operand
  write outside the workdir in an unguarded child that the realpath backstop cannot
  see, so they are blocked like the existing sort -o full-block. uniq skip-field /
  skip-char counts are not treated as output operands.

- module docstring under the guard splice: a leading string literal is the module
  docstring only while it is the first statement, so prepending the runtime guard
  ahead of it made __doc__ None. Splice the guard after a leading docstring (as is
  already done for future imports) so the docstring stays first; a same-line
  "\"\"\"doc\"\"\"; write" tail still moves after the guard and is confined.

Regression coverage: TestRound59Bypasses in tests/test_sandbox_tools.py (exec /
eval namespace-dict aliases, bare-host metadata / allowlist checks, client-instance
and aliased-instance request calls, shuf / uniq output writers, plus a round59
benign-allowed set: trusted-host requests / sessions / HTTPConnection / getaddrinfo,
dict.get and file .open, a benign exec payload, and shuf / uniq without an output
operand) and, in tests/test_sandbox_runtime_backstop.py, a module-docstring-preserved
case and a docstring same-line write-escape denial.
2026-07-11 03:46:40 +00:00
danielhanchen
bb1d6c9c7e Harden sandbox: bash comment / newline tokenization, sqlite .backup/.open operands, iconv -o, timeit strings, PATH / git command substitutions
Close eight shell-scanner and dynamic-execution gaps Codex found on the round-57 branch (all P1).

- bash # comments and unquoted newlines: the command scanner rewrote unquoted
  newlines to ` ; ` before shlex, and shlex's own # handling fired mid-word, so
  `echo ok #\nsed -i ...` (a comment swallowing the synthesized separator) and
  `echo ok#; sed -i ...` (a mid-word # treated as a comment) hid the second
  command. Strip bash comments at their real physical-line boundaries first, then
  rewrite newlines, and clear shlex's commenters so its non-bash-accurate # parsing
  cannot re-introduce the miss.

- shell newlines in the sensitive-read scan: the read scanner never rewrote
  newlines, so `echo ok\ncat /etc/passwd` read `cat /etc/passwd` as arguments of
  the non-reader `echo`. Apply the same comment-strip + newline-rewrite so each
  physical line starts a fresh command context.

- sqlite dot-command file operands: the dot-file check only inspected the first
  operand and omitted .open, so `.backup main /tmp/x` (the file is the LAST
  operand, after an optional schema name) and `.open /tmp/x` created databases
  outside the workdir. Scan .backup / .save / .open by their last operand and add
  .open to the file-operand set.

- iconv output files: iconv writes its converted output to -o / --output in an
  unguarded child, so `printf x | iconv -o /tmp/p` escaped. Block an escaping
  -o FILE / --output FILE / --output=FILE / -oFILE operand.

- timeit string execution: timeit.timeit / .repeat / Timer(...) compile and
  execute their stmt / setup STRING arguments, so
  `timeit.timeit("import os; os.system('...')")` ran outside the eval/exec gate.
  Analyze the stmt / setup strings like exec payloads (a benign body passes, a
  callable stmt carries no source and is left alone).

- command substitutions in PATH assignments: the PATH-entry check only recognized
  $VAR expansions, so `PATH=$(pwd) evil` was treated as a trusted expansion and a
  planted workdir executable could be resolved through it. Treat a $() / backtick
  command substitution in a PATH value as a dynamic, unsafe entry.

- dynamic git path operands: path-valued git operands only resolved same-command
  $VAR assignments, so `git init $(printf /tmp/x)` and the backtick form were
  accepted and native git created the path outside the workdir. Treat a $() /
  backtick command substitution in a git path operand as escaping (tokenization
  splits the substitution into separators, so the fragments are re-detected).

Regression coverage: TestRound58Bypasses in tests/test_sandbox_tools.py (comment /
newline command positions for the write and read scans, sqlite .backup / .save /
.open operands, iconv -o forms, PATH and git command substitutions, timeit
stmt / setup string execution) plus a round58 benign-allowed set (a real trailing
comment, a benign second line, workdir-relative git, iconv with no output file,
trusted PATH expansions, workdir-local sqlite .backup / .open, a benign timeit
body, and timeit.default_timer with no code string).
2026-07-11 03:02:05 +00:00
danielhanchen
cb243a23f1 Harden sandbox: taskset wrapper, sqlite3.Connection ctor + durable authorizer, namespace-dict sink aliases, injected subprocess, shelve reads
Close six issues Codex found on the round-56 branch (all P1).

- taskset exec wrapper: taskset [options] <mask | -c cpu-list> <command> execs the
  following command, but taskset was not a command-prefix wrapper, so taskset 1
  touch /tmp/p resolved to nothing and the write slipped. Add taskset to the
  wrapper set with its -c / --cpu-list (and -p / --pid) operand flags, and extend
  the wrapper numeric-arg skip to cover a hex affinity mask (0x3) and a cpu-list
  (0,1 / 0-3), so the wrapped command is resolved and scanned.

- sqlite3.Connection constructor confinement: wrapping only connect() left the
  public constructors unconfined, so sqlite3.Connection('/tmp/escape.db') /
  _sqlite3.Connection(...) created a database outside the workdir via the native
  extension. Route construction through a guarded Connection subclass whose
  __init__ confines the database path, and replace the module Connection attribute
  with it (isinstance stays valid); connect() forces the subclass as its factory.

- durable ATTACH / VACUUM authorizer: installing the authorizer once on the
  returned connection was not durable -- sandboxed code could call
  conn.set_authorizer(None) and then ATTACH DATABASE '/tmp/escape.db' / VACUUM
  INTO an outside file. The guarded Connection overrides set_authorizer to compose
  the workdir confinement ahead of any caller callback and keep it on
  set_authorizer(None), so the confinement cannot be removed.

- namespace-dict sink aliases: globals()/locals()/vars()[key] only blocked literal
  builtins / dangerous-module keys, so import os; f = os.system;
  globals()['f']('touch /tmp/p') passed. Resolve the key through the scope alias
  index too -- a shell / exec-builtin / deserializer sink alias makes the
  namespace-dict lookup the sink itself.

- dependency-injected subprocess / pty: a workdir helper receiving the module as
  an argument (def f(subprocess): subprocess.run([...])) has no import to reject,
  and the vetter's call check only rooted os / posix. Reject a subprocess / pty
  child-spawn method rooted at a receiver named subprocess / pty in the vetter, and
  -- robust to the callee's parameter name -- flag the subprocess / pty module
  passed by reference (f(subprocess)) as a first-class dangerous value in the
  submitted code, mirroring the existing os.system-by-reference block.

- shelve reads as pickle deserialization: shelve is a dbm-backed dict that
  unpickles a value on every read (shelf[key], shelf.get(key)), so shelve.open()
  on an attacker-planted dbm runs a pickle reduce payload just like pickle.load
  (which is already blocked). Model shelve.open as a deserialization sink. The
  read can be aliased (d = shelve.open(...); d[k]), so the open() gateway call is
  flagged; a pure write-only shelf never unpickles, so blocking it is an accepted
  narrow tradeoff.

Regression coverage: TestRound57Bypasses in tests/test_sandbox_tools.py (taskset
mask / cpu-list / nested wrappers + benign taskset; namespace-dict sink aliases
via globals / locals / vars incl. folded key and a pickle alias; injected
subprocess / pty module by reference incl. an aliased import; shelve.open read /
aliased read / get / import alias / from-import; and a round57 benign-allowed set)
and test_sandbox_runtime_backstop.py (sqlite3.Connection and _sqlite3.Connection
constructor escape denied + local allowed; set_authorizer(None) ATTACH / VACUUM
escape still denied; a caller authorizer still composes).
2026-07-11 02:19:01 +00:00
danielhanchen
570c3ff2f2 Harden sandbox: indirect eval/exec callees, native FFI imports, non-literal network targets; scope workdir exec-method calls to os
Close four issues Codex found on the round-55 branch (3 P1 + 1 P2 false positive).

- indirect eval / exec callees: the eval/exec/compile callee resolver only
  matched a bare name, a builtins attribute, an inline container, or an
  aliased name, so a callee EXPRESSION that evaluates to an exec builtin ran
  its payload unanalyzed: a ternary ((eval if c else exec)('...')), a boolean
  fallback ((getattr(__builtins__, 'ev', None) or eval)('...')), and a
  __builtins__['exec'] subscript. Resolution is refactored into
  _resolve_exec_callee, which peels the ternary / and-or composites (failing
  closed when ANY branch can be an exec builtin) and resolves the builtins
  subscript, after which the recovered payload is analyzed as usual (so a
  destructive os.system('touch ...') payload behind the indirect callee is
  caught while eval('1 + 2') stays allowed).

- native FFI imports: importing ctypes / _ctypes / cffi gives the snippet
  UNGUARDED libc / syscall access (ctypes CDLL('libc.so.6').system, a raw
  write() that never routes through the patched open / os.open), bypassing the
  filesystem confinement entirely. Refuse the import (statement and from form)
  in the static analyzer, mirroring the runtime workdir-module vetter which
  already refuses these. numpy / compiled wheels are NOT included: they expose
  no raw-syscall FFI surface.

- non-literal network targets: the host allowlist only inspected a literal URL
  / (host, port) tuple, so a target bound to a variable (url =
  'http://169.254.169.254/'; requests.get(url)) or built from an f-string /
  concat slipped past the metadata / allowlist check even though the literal
  form is blocked. Fold a non-literal target to its concrete host (a
  single-assignment constant, a foldable concat) or reduce it to the leading
  literal host prefix (f'https://hf.co/{path}', 'https://hf.co/' + p) when a
  / ? # terminates the host inside the literal so a dynamic tail cannot extend
  it. A target that stays fully opaque fails closed, since there is no runtime
  network filter to catch it. A const var / literal-prefix pointing at a
  trusted host still resolves and is allowed.

- workdir-module exec-method calls scoped to os / posix (P2 false positive):
  the runtime workdir-module vetter refused ANY attribute CALL whose method
  name matched an os exec sink (system / popen / spawn*) regardless of
  receiver, so a benign helper calling platform.system() or its own
  obj.system() method could not be imported. Root the call rejection at an
  os / posix receiver, exactly like the sink-attribute REFERENCE check beside
  it; os.system(...) in a workdir helper is still refused.

Regression coverage: TestRound56Bypasses in tests/test_sandbox_tools.py
(indirect ternary / boolop / builtins-subscript exec callees; ctypes / _ctypes
/ cffi imports; const-var / f-string / fully-opaque / raw-socket / create_
connection network targets; and a round56 benign-allowed set: literal exec,
os.system('id'), os / numpy / platform imports, and trusted host via literal /
const-var / f-string-dynamic-path / concat / raw socket). TestUntrustedHostBlock
is updated for the tightened const-var folding (untrusted host blocked, trusted
host allowed) plus a fully-dynamic fail-closed case, and
test_sandbox_runtime_backstop.py adds the platform.system() / obj.system()
workdir-helper allow and the os.system workdir-helper still-denied cases.
2026-07-11 01:22:01 +00:00
danielhanchen
5172f6893d Harden sandbox: sqlite3 stdin SQL, sed -f scripts, git EDITOR/VISUAL + object-dir + exec-env command + marks-file, find {} exec, openssl -out=; scope assignment prefixes
Close nine command-scanner gaps Codex found on the round-54 branch (8 P1 + 1 P2).

- sqlite3 stdin SQL: sqlite3 [OPTIONS] [FILENAME [SQL]] reads SQL from stdin when
  no SQL argv is given, so printf '.shell touch /tmp/p' | sqlite3 :memory: ran an
  unscanned dot-command in the unguarded child. Fail closed when sqlite3 has no
  inline SQL and a stdin source (a pipe target or a < / heredoc redirect).

- sed -f script files: the sed mutating-script check only scanned -e / positional
  scripts, so sed -n -f evil.sed loaded w / e commands from an uninspectable
  workdir file. Fail closed on any -f / --file form (separated, glued, combined
  short group).

- git EDITOR / VISUAL fallbacks: the git exec-env allowlist covered GIT_EDITOR but
  not the standard EDITOR / VISUAL fallbacks git uses for commit/tag messages.
  Treat EDITOR / VISUAL like the git exec-env vars when the command is git (or the
  value is exported).

- git object-directory env vars: GIT_OBJECT_DIRECTORY / GIT_COMMON_DIR /
  GIT_ALTERNATE_OBJECT_DIRECTORIES point git's object store outside the workdir
  (GIT_OBJECT_DIRECTORY=/tmp git hash-object -w), but only GIT_DIR / GIT_WORK_TREE
  / GIT_INDEX_FILE were path-checked. Add them to the escaping-path env check.

- assignment prefixes are now command-position aware (P2 false positive): a
  NAME=value shaped token is treated as an environment assignment only in the
  command-prefix position of its segment (or as an export / declare arg), so echo
  GIT_CONFIG_COUNT=0 and printf %s PATH=.:/bin are no longer rejected while a real
  PATH=. prefix and export PATH=.:/bin still block.

- find {} exec placeholder: find substitutes {} with each matched path, so find .
  -name evil -exec {} ';' executes a planted workdir file, but the reconstructed
  exec-segment scan saw only the harmless-looking {}. Fail closed when the exec
  command word is (or starts with) {}.

- openssl glued -out=FILE: the OpenSSL write check only handled a separated -out
  FILE operand, so openssl rand -out=/tmp/p slipped. Parse the glued -out=... /
  -writerand=... forms alongside the separated form.

- commands in git exec-env vars: the exec-env check only rejected a local
  executable path, so GIT_EXTERNAL_DIFF='touch /tmp/p' git diff (a bare system
  command that writes outside) passed. Run the value through the command scanner,
  which flags the write / escaping command.

- git marks-file options: git fast-export --export-marks=/tmp/marks (and
  fast-import --import-marks) write / read an escaping path outside the small
  _GIT_PATH_VALUE_OPTIONS list. Add the marks-file options to the path check.

Regression coverage: TestRound55Bypasses in tests/test_sandbox_tools.py (sqlite3
stdin, sed -f, git EDITOR/VISUAL incl. export, GIT_OBJECT_DIRECTORY / GIT_COMMON_DIR,
find {} exec, openssl -out=, git exec-env command values, git marks-file options,
the assignment-prefix position-awareness matrix, and a round55 benign-allowed set:
inline-SQL sqlite3, sed -e / bare script, EDITOR=vim, relative GIT_OBJECT_DIRECTORY,
find -exec cat {}, openssl -out=key, GIT_PAGER=cat, relative export-marks, export
of a benign var).
2026-07-11 00:40:34 +00:00
danielhanchen
8bb6ed501a Harden sandbox: order/scope-aware exec caller aliases, explicit exec namespaces, non-literal + non-assignment env mutations
Close five bypasses Codex found on the round-53 branch (all P1).

- exec/eval caller-alias order + scope: the caller-alias check removed a payload
  name from the free set if it was stored ANYWHERE, so f('touch /tmp/pwn');
  f = None still called the caller's f = os.system before the rebind. Replace
  the flat loaded-minus-bound with an order-aware, scope-aware analysis: a
  module-top-level Load before the name's first top-level binding (source order)
  resolves outward, as does a free / global Load inside a nested function or
  class scope (it can run after a later rebind); a name bound at module top
  level is payload-local (its own binding shadows the caller, and a
  payload-local sink is caught by the inner scan). symtable computes the
  nested-scope free / global references.

- explicit exec/eval namespace: exec("f('touch /tmp/p')", {'f': os.system})
  resolves the payload's free names from the supplied namespace, not the caller
  scope, so it was treated as a safe literal. Inspect a literal-dict namespace
  precisely (a free name mapped to a shell / exec / deserialize / import sink
  blocks) and fail closed on an opaque namespace when a non-builtin free name is
  called.

- subprocess env PATH via non-literal / bytes value: the env={'PATH': ...} check
  only read an inline str constant, missing P='.:/usr/bin'; env={'PATH': P}, a
  concatenation, and a POSIX bytes value. Const-fold / decode the value (via the
  now-folding _extract_env_scalar) and fall back to the dynamic-PATH analysis for
  a non-literal value, mirroring the os.environ['PATH'] handling.

- non-assignment env mutations: only Assign targets (plus update / setdefault)
  were covered, so os.environ['PATH'] += ':.' (AugAssign), del
  os.environ['GIT_CONFIG_COUNT'] (Delete), os.environ.pop('GIT_CONFIG_COUNT') /
  .clear(), and os.unsetenv('GIT_CONFIG_COUNT') slipped through. Add
  visit_AugAssign (modeled as old-value + appended), visit_Delete, and pop /
  clear / unsetenv handling; removing a GIT_CONFIG* var (or clearing the env)
  drops the sandbox git hook suppression.

- opaque env mapping for git children: the missing-GIT_CONFIG_COUNT check only
  fired for a fully inspectable mapping, and the non-literal fallback was scoped
  to shell children, so env={**d} / env=f() for a git child (which can evaluate
  to {} and drop the injected core.hooksPath suppression) was accepted. Fail
  closed for a git child on an opaque or non-literal env mapping unless a literal
  GIT_CONFIG_COUNT is present.

Regression coverage: TestRound54Bypasses in tests/test_sandbox_tools.py
(caller-alias-before-rebind, explicit-namespace alias, non-literal / bytes env
PATH, augmented / del / pop / clear / unsetenv env mutations, opaque git env,
plus a round54 benign-allowed set: store-only payload, benign literal namespace,
absolute PATH via const var, benign augmented / pop env var, non-git opaque env,
git with no env).
2026-07-11 00:03:45 +00:00
danielhanchen
975ef64d8d Harden sandbox: sqlite ATTACH/VACUUM confinement, asyncio subprocess creators, os.putenv escapes, str-fold DoS, socket.connect_ex
Close five bypasses Codex found on the round-52 branch (4 P1 + 1 P2).

- sqlite ATTACH / VACUUM INTO: a connection to an in-workdir DB could still
  create/open a file outside the session via ATTACH DATABASE '/tmp/x' or
  VACUUM main INTO '/tmp/x' -- the native extension writes those paths without
  passing the wrapped connect / open. Install a connection authorizer in the
  runtime guard: both fire the SQLITE_ATTACH action with the target filename,
  so deny a target that escapes the workdir (URI-decoded when uri mode is on)
  while an in-workdir / :memory: / temp attach and ordinary queries stay
  allowed. Refactored the URI-to-path decode into a shared helper.

- asyncio subprocess creators: asyncio.create_subprocess_shell(cmd) /
  create_subprocess_exec(prog, *args) start the same unguarded child as
  subprocess.run/Popen but were not classified. Rewrite them to the equivalent
  subprocess.run(cmd, shell=True) / subprocess.run([prog, *args]) node (carrying
  cwd= / env=) and reuse the full child-process command analysis. Covers the
  module-attribute form, an import alias, and a from-import bare alias.

- os.putenv: os.putenv('BASH_ENV', 'evil.sh') sets an inherited env var through
  the C setter (not os.environ), so the subscript / update checks missed the
  later-child startup / PATH escape. Run the (key, value) pair through the same
  mutation policy in visit_Call; covers os.putenv and a from-import alias.

- str()-of-container fold DoS: str(['x' * 65536] * 4096) is a small aliased
  container whose repr is hundreds of MB, and _fold_cap only checks the length
  AFTER str() materializes it, OOMing the Studio parent before the child
  rlimits apply. Estimate the repr length with a cheap bounded walk and refuse
  the fold (leaving the payload opaque, which already fails closed) before
  building it.

- socket.connect_ex: connect_ex((host, port)) opens the same outbound
  connection as connect but returns an errno instead of raising, bypassing the
  metadata / untrusted-host allowlist. Classify it identically to connect.

Regression coverage: TestRound53Bypasses in tests/test_sandbox_tools.py
(asyncio subprocess shell/exec + alias / from-import / awaited forms, os.putenv
startup+PATH escapes, connect_ex metadata/untrusted host, str-container fold
DoS, plus a round53 benign-allowed set: benign asyncio echo child, benign
putenv var, small str fold, connect_ex to a trusted host) and, in
tests/test_sandbox_runtime_backstop.py, the sqlite ATTACH and VACUUM INTO
escape denials with a benign local-ATTACH allowed.
2026-07-10 23:11:09 +00:00
danielhanchen
10962c482c Harden sandbox: env=dict/**-splat child env, aliased os.environ, git helper env vars, relative native output under an escaping cwd
Close five bypasses Codex found on the round-51 branch.

- env=dict(...) / env={**mapping} child-env mappings: the subprocess env=
  analysis only walked a literal dict and a dict() call, so a git child with
  env=dict(PATH=...) still dropped the injected hook suppression and a
  env={**{'BASH_ENV': ...}} splat hid the startup var. Flatten the mapping
  (literal dict, dict() call, and nested ** splats) into (key, value) pairs
  once via _env_mapping_pairs and run the same PATH / BASH_ENV / GIT_* policy;
  an opaque computed key on a shell child fails closed.

- aliased os.environ mutations: e = os.environ (or from os import environ as e)
  binds a new name to the same inherited environment, so a later
  e['BASH_ENV'] = ... escaped the subscript check. Track the alias
  (from-import, and a single-assignment e = os.environ / os.environb) and treat
  it as the environ mapping in _is_environ_receiver.

- git helper-command env vars: GIT_EXTERNAL_DIFF / GIT_ASKPASS / GIT_SSH /
  GIT_SSH_COMMAND / GIT_PROXY_COMMAND / GIT_EDITOR / GIT_SEQUENCE_EDITOR /
  GIT_PAGER name a program git executes; a workdir-local / ~ target
  (GIT_EXTERNAL_DIFF=./evil git diff) runs unreviewed code in the unguarded git
  child. Block an assignment-prefix helper var whose command is a local
  executable path or a ~ path; an absolute system tool (GIT_SSH=/usr/bin/ssh)
  stays allowed.

- relative native output under an escaping cwd: openssl -out FILE and a
  sqlite3 DBFILE / dot-file / -init operand only checked the literal operand,
  so a RELATIVE operand under an escaping env -C DIR (or a subprocess cwd=,
  reconstructed as env -C DIR) -- env -C /tmp openssl rand -out key,
  subprocess.run(['sqlite3','db.sqlite',...], cwd='/tmp') -- wrote outside the
  session. Scan back for an escaping chdir wrapper (_cwd_wrapper_escapes) and
  combine it with a relative operand (_operand_relative_local); a workdir-subdir
  env -C and a no-chdir relative operand stay allowed.

Regression coverage: TestRound52Bypasses in tests/test_sandbox_tools.py
(env=dict / ** splat, aliased environ, git helper env vars, relative native
output under an escaping cwd, plus a round52 benign-allowed set: non-git
env=dict, benign splat / aliased-env var, GIT_PAGER=cat, no-chdir and
workdir-subdir native output).
2026-07-10 22:24:26 +00:00
danielhanchen
81bb65f4e0 Harden sandbox: dynamic/environb/update PATH mutations; sqlite URI decode + shell/pipe dot-commands; getattr gadget dunders; find -exec in argv
Close seven bypasses Codex found on the round-50 branch.

- dynamic PATH assignment: os.environ['PATH'] = '.:' + os.environ['PATH'] (or an
  f-string) was accepted because the value is non-literal. Fold what we can and fail
  closed when a COMPLETE, fully-literal PATH entry the value contributes is a
  relative / cwd / empty entry; a dynamic ABSOLUTE extension ('/usr/local/bin:' +
  $PATH, venv + ':' + $PATH) stays allowed.

- os.environb mutations: os.environb[b'PATH'] = b'.:...' updates the same inherited
  environment, but only os.environ[...] was recognized. Match environ / environb
  (attribute and bare) and decode a bytes key / value before the policy check.

- os.environ.update / setdefault: a mapping mutator
  (os.environ.update({'PATH': '.:...'}), .update(PATH=...), .setdefault('PATH', ...))
  never hit the subscript check. Run each (key, value) pair through the mutation
  policy in visit_Call.

- sqlite URI percent-decode: sqlite3.connect('file:%2Ftmp%2Fescape.db', uri=True)
  passed the runtime guard as a relative-looking string while SQLite decodes the
  filename and opens /tmp/escape.db. Percent-decode the URI path (with the guard's
  captured chr/int) before the workdir check.

- sqlite shell / pipe dot-commands: the CLI scanner only path-checked file
  dot-commands, but .shell CMD / .system CMD run a system shell and .output |CMD
  opens a pipe. Block a .shell / .system / .excel dot-command and an .output/.once
  target that begins with '|'.

- getattr gadget dunders in the workdir vetter: a helper module could call
  getattr(open, '__closure__') / getattr(cell, 'cell_contents') to recover the guard
  wrapper's original unguarded open, because the getattr branch only rejected a few
  sensitive receivers. Reject a gadget-dunder name on ANY receiver (mirrors the
  direct-attribute check).

- find -exec in subprocess argv: the read scanner flattened the argv and checked
  each element independently, missing subprocess.run(['find','/etc',...,'-exec',
  'cat','{}',';']) reading /etc/passwd (the {} placeholder loses the escaping search
  root). Reconstruct a find child-exec argv into a shell string and run it through
  the read scanner, which carries the find-root + -exec logic.

Regression coverage: TestRound51Bypasses in tests/test_sandbox_tools.py (dynamic /
environb / update PATH mutations, sqlite .shell/.system/.output-pipe, find -exec
argv, plus a round51 benign-allowed set: absolute dynamic PATH, benign env vars,
local sqlite .output/.dump, workdir find -exec) and, in
tests/test_sandbox_runtime_backstop.py, the sqlite percent-encoded URI escape (with
a benign local URI) and the getattr gadget-dunder workdir-module denial.
2026-07-10 21:49:54 +00:00
pre-commit-ci[bot]
fa15071e14 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-10 21:10:34 +00:00
danielhanchen
01f96315ca Harden sandbox exec/eval analysis: assigned-container sinks; shadowed fold helpers; namespace-dict writes; caller-alias payloads; unknown PATH vars
Close five bypasses Codex found on the round-49 branch (all in the static
exec/eval analyzer, plus one PATH case).

- assigned-container sink: a subscript into a container bound to a single-
  assignment NAME (d = {'e': exec}; d['e'](payload), xs = [eval]; xs[0](...))
  reached exec/eval, but the container resolvers only handled INLINE literals, so
  the Name form returned None and the payload was never scanned. Resolve a Name
  container through the const-prop env in the exec / deserialize / shell-sink
  resolvers (the last also caught d = [os.system]; d[0]('rm -rf /')).

- shadowed fold helper: the constant folder called the real builtin / stdlib
  module even when the snippet rebinds the name, so
  str = lambda _: "__import__('os').system('touch /tmp/x')"; eval(str(1)) folded
  through the real str and was marked safe. Track names rebound away from their
  canonical builtin / module (assignment, def, param, from-import, aliased import)
  and refuse to fold them, leaving the payload opaque -> eval/exec fails closed. A
  plain `import base64` keeps the canonical module and still folds.

- namespace-dict write: const-prop only tracked Name stores, so
  x = '2+2'; globals()['x'] = BAD; eval(x) folded x as the safe literal. Invalidate
  a name written through globals()/vars()/locals()[key] = ... (constant key), and
  fail closed on a dynamic key or a bulk update()/setdefault()/__setitem__.

- caller-alias payload: exec()/eval() run in the CALLER namespace, but the payload
  was scanned as a fresh module, so import os; f = os.system; exec("f('rm -rf /')")
  saw f as unknown and passed. When a payload FREE name resolves, in the caller
  scope, to a shell / exec / deserialize / import alias, fail closed. A payload that
  references only builtins (exec("print(1)")) or binds its own names stays allowed.

- unknown PATH variable: in the sandbox an unset $VAR expands to EMPTY, so
  PATH=$EVIL is an empty component that makes the shell search the cwd; a snippet
  can drop a local executable and run os.system('PATH=$EVIL evil'). Model an
  unknown/unset $VAR in a PATH entry as empty and fail closed when the entry then
  collapses to an empty or relative path; $PATH and an entry that stays absolute
  ($CONDA_PREFIX/bin -> /bin) are still trusted.

Regression coverage: TestRound50Bypasses in tests/test_sandbox_tools.py (assigned
container exec/eval + shell sinks, shadowed str/chr fold, globals/vars/update
invalidation, exec/eval caller alias, PATH=$UNKNOWN) plus a round50 benign-allowed
set (safe container callees, normal builtin/module folds, a namespace read, a safe
caller alias, and PATH with trusted absolute entries).
2026-07-10 21:09:51 +00:00
pre-commit-ci[bot]
afc84773dc [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-10 20:30:37 +00:00
danielhanchen
9121b315b5 Harden sandbox: watch sh -c payload; xargs replace into exec; sqlite3 CLI writer + native _sqlite3.connect; jq file-option reads
Close five bypasses Codex found on the round-48 branch.

- watch runs its command via `sh -c '<operands>'` unless -x/--exec is given, so a
  quoted payload (watch 'python3 -c ...', watch -n 0.1 'rm -rf /') is shell CODE,
  not one inert command word. The wrapper handling only resolved the -x argv form.
  Scan the joined non-x operands recursively; a bare `watch date` re-scans `date`,
  and `echo watch rm` (watch in argument position) is left alone.

- xargs -I / -i / --replace substitutes UNSCANNED stdin into the command at
  runtime, so `printf ... | xargs -I{} sh -c '{}'` executes stdin as code while
  the scanner sees only the inert `{}` payload. Fail closed when the replacement
  token becomes the command word (xargs -I{} {}) or flows into an interpreter code
  string (sh -c '{}', python3 -c %); a replacement used only as a data ARGUMENT to
  a non-interpreter (xargs -I{} cp {} dir/) and xargs without a replace flag stay
  allowed.

- the sqlite3 CLI creates a database / redirects output in an unguarded child that
  has no realpath guard: `sqlite3 /tmp/escape.db '...'` writes outside the workdir,
  and `.output` / `.backup` / `.dump` / `.read` dot-commands read+write arbitrary
  files. Flag a DBFILE operand or a dot-command file target that escapes the
  workdir; a local DB (sqlite3 local.db ...), :memory:, and an in-memory URI stay
  allowed. sqlite3 is added to the argv-tail scan so the subprocess.run(['sqlite3',
  ...]) form is reconstructed and checked too.

- the round-48 runtime sqlite guard wrapped sqlite3.connect and
  sqlite3.dbapi2.connect, but the native _sqlite3 C extension still exposed the
  original connect and is importable directly (import _sqlite3;
  _sqlite3.connect('/tmp/escape.db')), bypassing both Python bindings. Wrap the
  low-level _sqlite3.connect entry point too.

- jq reads files through explicit options (--rawfile / --slurpfile read a file into
  a variable, -f/--from-file reads the program file), so an expanded / sensitive
  path leaks a host secret (P=$(printf /etc/passwd); jq -n --rawfile x $P '$x').
  Scan only jq's file-valued options -- jq is NOT a generic reader because its
  positional FILTER legitimately contains `$` (jq variables), which a blanket
  reader rule would misfire on. A local --rawfile (jq --rawfile x data.txt) and a
  $-bearing filter stay allowed.

Regression coverage: TestRound49Bypasses in tests/test_sandbox_tools.py (watch
sh -c payload, xargs replace into exec, sqlite3 CLI escape, jq file-option reads,
plus a round49 benign-allowed set) and a low-level _sqlite3.connect runtime case in
tests/test_sandbox_runtime_backstop.py.
2026-07-10 20:29:56 +00:00
pre-commit-ci[bot]
740e73fd99 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-10 19:48:28 +00:00
danielhanchen
b3758eac77 Harden sandbox: process substitution reads; var-prefix path escapes; openssl -in; find -exec over an escaping root; sqlite3 runtime confinement + benign local-DB FP
Close four bypasses and one false positive Codex found on the round-47 branch.

- process substitution <(cmd) / >(cmd): bash runs cmd in a child shell, so
  `echo <(cat /etc/passwd)` reads a host secret, but the command-sub extractor
  only handled $(...) / backticks and skipped the <(...) / >(...) forms. Extract
  the process-substitution body too so its reader is scanned.

- variable-prefix path operand: a leading $VAR / ${VAR} that expands to an
  absolute prefix escapes the workdir even when the operand appends a further
  segment (P=/tmp; git init $P/repo). The write-operand check matched only a
  whole-token variable; resolve a $VAR / ${VAR} PREFIX against the assignment map
  and re-test the concatenation.

- openssl -in: openssl base64/enc -in FILE reads its input, so it can exfiltrate
  a host secret the same way cat/base64 do. Add openssl to the shell read-command
  set so an -in over a sensitive path is caught.

- find -exec reader over an escaping root: `find /etc -name passwd -exec cat {} ;`
  reads a host file, but the -exec segment scan sees only `cat {}` -- the {}
  placeholder carries no path, so the /etc search root is lost. Compute the find
  search roots and, when a reader -exec references {} over a root that escapes the
  workdir, fail closed.

- sqlite3.connect filesystem escape + benign local-DB false positive: the network
  scanner treated any `.connect('string')` as a host, which mis-flagged benign
  local database opens (sqlite3.connect('local.db'), ':memory:') as an untrusted
  host while a bare-string socket connect is really an AF_UNIX path, never an
  AF_INET host. Restrict host classification to the (host, port) TUPLE form, and
  confine the sqlite DB path in the runtime guard instead: sqlite3.connect opens
  the file via the native _sqlite3 C extension (not builtins.open), so the
  open-like backstop never saw it; the guard now denies a database path that
  resolves outside the workdir (absolute / traversal / dynamically built) while
  :memory:, an in-memory URI, and workdir-local databases stay allowed.

Regression coverage: TestRound48Bypasses in tests/test_sandbox_tools.py (process
substitution read, variable-prefix operand escape, openssl -in, find -exec over an
escaping root, plus a round48 benign-allowed set that includes the local /
in-memory sqlite opens) and four runtime cases in
tests/test_sandbox_runtime_backstop.py (sqlite3 absolute + dynamically built
escapes denied; local and :memory: databases allowed).
2026-07-10 19:47:37 +00:00
pre-commit-ci[bot]
77fb4cac93 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-10 19:08:14 +00:00
danielhanchen
d57f25f969 Harden sandbox: guard prelude before same-line future stmt; backslash-newline; xargs --process-slot-var; addressed sed e; os.environ mutations; git config --system/--global; command-position FP
Close six bypasses and one false positive Codex found on the round-46 branch.

- guard prelude vs a same-physical-line statement: a `from __future__ import
  annotations; open('/tmp/x','w')` puts a real write on the SAME line as the future
  import, and the line-granular split copied that write into the head, before the guard
  prelude, so it ran unguarded. Split at the last head statement's exact end column and
  drop the leading `; ` so the tail moves after the prelude.

- backslash-newline line continuation: bash removes a `\<newline>` before command
  lookup, so `tou\<nl>ch` runs `touch`, but the newline rewriter preserved it as data.
  Drop the backslash + newline (outside single quotes) so the joined word is tokenized.

- xargs --process-slot-var VAR: the separated operand VAR was mistaken for the command
  word, so `xargs --process-slot-var VAR touch /tmp/p` passed. Add --process-slot-var to
  the xargs wrapper operand set.

- addressed sed e command: GNU sed runs `e COMMAND` after an address (`/x/e cmd`,
  `1,/y/e cmd`), which the standalone-e pattern missed. Add an address-anchored regex
  (boundary or range comma before the `/regex/`, `e` followed by a separator), so
  `s/a/e /` is not misread.

- os.environ mutation before a child: setting os.environ['PATH']='.' (or BASH_ENV / ENV
  / GIT_CONFIG* / GIT_DIR) mutates the inherited environment a later unguarded
  subprocess reads, the same escape as passing env={...}. Flag the dangerous mutation
  (unsafe PATH, a non-empty startup file, a GIT_CONFIG override, an escaping GIT_DIR); a
  benign env var and an absolute PATH prepend stay allowed.

- git config --system / --global writes: the config scan handled --file but not the host
  system / user config files (/etc/gitconfig, ~/.gitconfig). Block a --system / --global
  WRITE (KEY VALUE, or a write flag / --edit); a pure read (--get / --list / a bare KEY)
  and a local `git config user.name x` stay allowed.

- false positive: the sed command-word helper (_command_word_indices) reset command
  position on every shell keyword even as an argument, so `echo if sed -i s/a/b/ file`
  recorded sed and blocked it as `mutating:sed`. Only reset at command position (the
  round-44 fix, now applied to this helper too); real compound headers stay blocked.

Regression coverage: TestRound47Bypasses in tests/test_sandbox_tools.py (backslash
newline, xargs --process-slot-var, addressed sed e, os.environ PATH/BASH_ENV/GIT_CONFIG
mutations, git config --system/--global writes, and the command-position FP allowed)
plus a round47 benign-allowed set, and two guard-prelude cases in
tests/test_sandbox_runtime_backstop.py (a same-line future-import write is confined; the
own-line future import still works).
2026-07-10 19:07:34 +00:00
pre-commit-ci[bot]
d3902d24a4 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-10 18:21:10 +00:00
danielhanchen
736e8a6477 Harden sandbox: extend workdir-module import vetter (ctypes, dynamic import, closure/frame gadgets, indirect import-machinery, subscripted builtins); block openssl file output
Close six bypasses Codex found on the round-45 branch. Five harden the workdir-module
import vetter (the only scan of a helper .py the user wrote before import); the sixth
adds an openssl output-file scan.

- ctypes / native modules: the vetter only treated subprocess / pty as execution
  modules, so a helper doing import ctypes reached UNGUARDED native libc
  (ctypes.CDLL(None).open/write) bypassing the patched Python open / os.open. Refuse
  ctypes / _ctypes / cffi and the source-executing runpy / code / codeop.

- dynamic import: a helper bypassed the literal import subprocess check with
  importlib.import_module('subprocess'). Refuse import_module / reload whose target
  is a denied module (constant or module name); a dynamic import_module target fails
  closed.

- closure / frame gadgets: __closure__ / cell_contents / f_locals / __globals__ /
  __subclasses__ (etc.) recover a runtime guard wrapper's original unguarded callable
  or walk to os / builtins. Refuse the top-level _GADGET_DUNDERS set inside a workdir
  helper too.

- indirect import-machinery access: the vetter caught only the literal
  sys.meta_path attribute, so vars(sys)['meta_path'][:] = [...] (or
  getattr(sys, 'meta_path')) removed the vetter and imported an unscanned sibling.
  Refuse getattr / vars namespace-dict access on sys / os / builtins / importlib /
  deserializer modules (constant sink name, or a non-constant name that cannot be
  proven benign).

- subscripted builtins: imported helpers run with __builtins__ as a dict, so
  __builtins__['ev'+'al'](...) reached eval past the attribute checks. Refuse a
  subscript into __builtins__ / a builtins alias whose (statically foldable) key is
  an execution builtin, and fail closed on a non-constant key.

- openssl output files: openssl rand -out /tmp/p 4 (and -writerand / -keyout /
  -CAout / ...) writes a host file in an unguarded child. Block an openssl output-file
  flag whose value escapes the workdir; a workdir-local -out and the no-output forms
  (openssl rand -hex, openssl dgst) stay allowed. openssl joins the argv tail-scan set
  so the subprocess.run(['openssl', ...]) form is covered too.

Regression coverage: TestRound46Bypasses in tests/test_sandbox_tools.py (openssl
escaping output blocked in the shell-string and argv forms; -hex / dgst / workdir-local
-out allowed) and six workdir-module vetter cases in
tests/test_sandbox_runtime_backstop.py (ctypes, dynamic import, __closure__, indirect
vars(sys) meta_path, subscripted __builtins__['eval'] denied; importlib.import_module of
json still allowed).
2026-07-10 18:20:30 +00:00
pre-commit-ci[bot]
e98f775eb8 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-10 17:51:54 +00:00
danielhanchen
8814d7de3b Harden sandbox: fail closed on unvetted __code__ stores; workdir getattr sink obfuscation; honor subprocess cwd= and GNU env glued -C/-u for git writes
Close four bypasses Codex found on the round-44 branch:

- fn.__code__ = <code object>: rebinding a function's code runs it via fn()
  WITHOUT eval / exec, the __code__ twin of the FunctionType gadget. The
  assignment visitor only checked container-stored exec aliases, so
  co = codeop.compile_command('...'); f.__code__ = co; f() ran unanalyzed
  source. Flag a __code__ store whose RHS is not a vetted code object; an
  in-source function's code (g.__code__) and a compile() result (analyzed at
  the compile site) stay allowed.

- workdir-module getattr obfuscation: the import vetter caught direct
  os.system(...) but not getattr(os, 'system')('...') in an imported helper,
  so the top-level analyzer saw only the file write / import and the vetter
  passed. Refuse getattr on a sink-module receiver (os / posix / builtins /
  deserializers) -- a constant sink attribute name, and a non-constant name
  that cannot be proven benign.

- subprocess cwd= ignored for child writes: the argv scan reconstructed the
  git command but dropped cwd=, so subprocess.run(['git','init','repo'],
  cwd='/tmp') created /tmp/repo outside the workdir. Model a literal escaping
  cwd= as a synthetic `env -C <cwd>` wrapper on the reconstructed command so
  the existing git cwd backscan resolves the escape; a workdir-relative cwd
  adds no wrapper and stays allowed.

- GNU env glued -C / -u operands: env -C/tmp git init repo (and
  env -uGIT_CONFIG_COUNT git ...) glue the chdir / unset operand directly onto
  the short flag, which the separated and --long= scans missed, so the git cwd
  and hook-suppression backscan never saw the escape. Parse the glued short
  forms alongside the separated ones.

Regression coverage: TestRound45Bypasses in tests/test_sandbox_tools.py
(__code__ store of a producer / opaque code object blocked while a compile()
result and g.__code__ stay allowed; subprocess git under an escaping cwd
blocked while a workdir-relative cwd is allowed; env -C/tmp and
-uGIT_CONFIG_COUNT before git blocked while plain env git init is allowed) and
two workdir-module vetter cases in tests/test_sandbox_runtime_backstop.py
(getattr(os,'system') helper denied, benign getattr on a plain object allowed).
2026-07-10 17:51:12 +00:00
danielhanchen
8384cea660 Harden sandbox: fail closed on unvetted FunctionType code objects, subscript-stored exec aliases, and workdir deserializers; only treat shell keywords as separators at command position
Close three bypasses and one false positive Codex found on the round-43 branch:

- types.FunctionType() of an unvetted code object: the gadget was only flagged
  when its first arg was a compile() result, so a code object from any other
  producer (codeop.compile_command(), a loader's get_code(), or an opaque
  name) ran source the recursive eval/exec analysis never saw. Replace the
  compile-only denylist with an allowlist: FunctionType is allowed only when
  its first arg is an ordinary in-source function's code object
  (fn.__code__ / meth.__func__), whose body is analyzed normally, and fails
  closed for everything else. Robust against new producers instead of chasing
  each one.

- subscript-stored exec alias: storing a dynamic-exec builtin into a container
  element (d['e'] = exec; d['e'](payload)) hid the sink from the name /
  attribute call checks -- the alias tracker only followed plain-name targets
  -- so the later subscript call ran an unreviewed payload. Flag the store
  itself: there is no benign reason to stash exec / eval / compile / __import__
  in a container slot.

- deserializer in an imported workdir module: the module import vetter (the
  only scan of a helper .py the user wrote) checked shell / eval / import /
  network sinks but not deserializers, so a helper calling pickle.loads on
  bytes whose reducer runs posix.system spawned an unguarded child. Refuse a
  workdir module that calls a reduce-executing deserializer (pickle / marshal /
  dill / cloudpickle / jsonpickle load / loads / Unpickler / decode) or binds
  one via from-import. json / importing pickle for dumps stay allowed.

- false positive: shell keywords as separators regardless of position. if /
  while / until (and then / do / else / elif) were treated as command
  separators everywhere, so `echo if touch` was rejected as if `touch` ran even
  though it is just data passed to echo. Only reset command position for these
  keywords when they appear AT command position (the compound-statement
  header); real separators (; | && ...) still reset everywhere, so
  `if touch x; then :; fi` stays blocked.

Regression coverage: TestRound44Bypasses in tests/test_sandbox_tools.py
(FunctionType of codeop / loader / producer code objects blocked while
fn.__code__ stays allowed; subscript-stored exec / eval / compile blocked while
a benign container store is allowed; `echo if touch` allowed while the compound
headers stay blocked) and two workdir-module vetter cases in
tests/test_sandbox_runtime_backstop.py (pickle.loads reduce payload denied,
json.loads still allowed).
2026-07-10 17:17:32 +00:00
danielhanchen
997c7247f2 Harden sandbox: builtins-qualified exec sinks and dotted network imports in workdir modules; keyword-only compile payload; sponge child writer
Close four bypasses Codex found on the round-42 branch:

- builtins-qualified exec sinks in an imported workdir module: the import
  vetter only rejected the BARE eval/exec/compile/__import__ names, so a
  workdir helper doing `import builtins; builtins.eval("...")` ran arbitrary
  code at import unscanned. Recognize the execution builtins reached as an
  attribute of the builtins module (or an alias), for both the direct call
  and the assign-only reference (e = builtins.eval; e(...)). Requiring a
  builtins root keeps a benign .compile()/.eval() on another object
  (model.compile, df.eval) from being misread as a sink.

- keyword-only compile() payload: compile() accepts its source as the
  source= keyword, and a standalone compile() with no positional arg
  reached the payload-recovery early return (no node.args -> NO_PAYLOAD),
  so its code object was executed via the fn.__code__ = c; fn() gadget
  entirely unscanned. Recover the source= keyword before returning
  NO_PAYLOAD (eval / exec take no keyword arguments in CPython, so an empty
  node.args there is genuinely payload-less). A benign keyword-only compile
  is analyzed, not blanket-blocked.

- dotted stdlib network imports in a workdir module: the vetter left the
  urllib / http tops out so urllib.parse stays benign, but that also let a
  helper `import urllib.request` (or http.client) open outbound connections
  the static network policy never saw. Refuse the network submodules by
  their full dotted name (urllib.request, urllib.robotparser, http.client,
  xmlrpc.client), covering the import, `import ... as`, `from urllib.request
  import ...`, and `from urllib import request` forms, while urllib.parse and
  the bare tops remain importable.

- sponge child writer: sponge (moreutils) soaks up stdin and writes it to a
  file argument (printf x | sponge /tmp/probe), an unguarded-child write
  outside the workdir. Add it to the child-writer denylist next to tee /
  patch / mktemp.

Regression coverage: TestRound43Bypasses in tests/test_sandbox_tools.py
(keyword-only compile via the __code__ / FunctionType / exec(compile())
gadgets, sponge child writer, plus a benign keyword-only compile that stays
allowed) and three workdir-module vetter cases in
tests/test_sandbox_runtime_backstop.py (builtins.eval sink denied,
urllib.request denied, urllib.parse still allowed).
2026-07-10 16:41:10 +00:00
danielhanchen
f11fbdcb7f Harden sandbox: compile(source=) payload; workdir import vetter -- PEP263 decode, ignore bytecode cache, refuse symlinks, network sinks, os import aliases
Close six P1 bypasses Codex found on the round-41 branch:

- compile(source=...) keyword payload: compile() accepts its source as the source=
  keyword, but the analyzer only recovered the 1st positional arg, so a keyword-only
  compile feeding types.FunctionType(code)() (or exec(compile(source=...))) was treated
  as having no payload and ran unscanned. Recover the source= keyword too (new
  _compile_source_node), at both the code-object tracking and exec(compile()) sites.

The remaining five harden the workdir-module import vetter (a helper .py the user
wrote is vetted before import; each of these slipped a payload past it):

- PEP 263 source encoding: the vetter read modules as fixed UTF-8, but Python's loader
  honors an encoding cookie. A `# coding: utf_7` module hides os.system in what the
  UTF-8 scan sees as a comment (raw +AAo- bytes are a newline under UTF-7). Decode with
  importlib.util.decode_source so the vetter sees what the loader will run.
- bytecode cache: after scanning the source, returning the original spec let
  SourceFileLoader satisfy the import from a planted __pycache__ .pyc whose header
  matches the harmless source. Run the EXACT vetted source via a dedicated loader
  (_GuardVettedSourceLoader) so the bytecode cache is never consulted.
- symlinked module: a workdir module that is a symlink to an outside file had a realpath
  outside the workdir, so it was treated as not-workdir and handed to the default loader
  unvetted. Decide workdir-membership by the origin path, then fail closed when the
  realpath escapes.
- network sinks: the vetter only checked command-exec/eval, so a helper doing
  socket.create_connection(...) bypassed the static network policy (no runtime network
  backstop). Refuse a workdir module that imports a network primitive (socket / ssl /
  ftplib / smtplib / requests / httpx / aiohttp / ...).
- os import aliases: sink references were only recognized when rooted at literal os /
  posix, so import os as o; s = o.system; s(...) passed (the assignment, not a direct
  call). Record os / posix import aliases before checking sink references.

Regression coverage: TestRound42Bypasses in tests/test_sandbox_tools.py (compile
source= keyword, positional, and exec(compile()) forms) and five workdir-module vetter
tests in tests/test_sandbox_runtime_backstop.py (utf-7 encoding denied, forged pyc
ignored while the vetted source runs, symlinked module denied, network sink denied, os
import alias denied).
2026-07-10 16:05:17 +00:00
danielhanchen
6f54040042 Harden sandbox: env -C command-subs / nested-shell / argv cwd, env --unset git hooks, workdir meta_path, git apply --unsafe-paths, patch; scope literal-path reads to readers
Close eight findings Codex raised on the round-40 branch (7 P1 + 1 P2 FP):

- env -C command-substitution operand: env -C $(printf /etc) cat passwd (and the
  backtick form) tokenizes the operand into separator tokens, so the per-command
  chdir-dynamic state was reset before the trailing reader. Keep the env -C dynamic
  flag across command-substitution punctuation ( ( ) ` ), and mark it when the operand
  itself starts with a substitution token.
- dynamic env -C into a nested shell: env -C ${X:-/etc} bash -c 'cat passwd' marked the
  cwd dynamic but the nested-shell recursion passed only the original cwd_dynamic,
  dropping it. Propagate the current env -C cwd and its dynamic flag into the payload
  scan.
- argv env -C before a bash -c payload: subprocess.run(['env','-C','/etc','bash','-c',
  'cat passwd']) scanned the payload before applying the argv env -C, treating passwd as
  workdir-local. Fold the argv env -C (via _argv_env_chdir) into the payload's cwd, or
  fail closed on a dynamic DIR.
- env --unset (separated) / bare - drop git hook suppression: the git backscan handled
  -i / --ignore-environment / -u NAME / --unset=NAME but not --unset NAME (separated)
  or a bare - (GNU env: implies -i). Add both so the injected GIT_CONFIG_COUNT hook
  suppression cannot be stripped before a git child.
- workdir module import-vetter mutation: a workdir module of just
  `import sys; sys.meta_path.pop(0)` passed the vetter (pop is not an exec attr), then a
  second workdir module imported unscanned with the vetter removed. Refuse a workdir
  module that touches the import machinery (sys.meta_path / path_hooks /
  path_importer_cache).
- git apply --unsafe-paths: a patch applied with --unsafe-paths can write targets
  outside the working tree (a +++ ../../tmp/x hunk) in the unguarded git child. Deny the
  unsafe mode; a plain git apply p.patch (in-tree targets) stays allowed.
- patch child writer: patch is an unguarded native writer (patch -o /tmp/x, or a ../../
  target in the diff), so add it to the child-writer denylist alongside touch / cp / tar.
- P2 FP -- literal sensitive-path scan over-blocked non-readers: the unconditional token
  scan flagged any command that merely mentioned a sensitive path (echo /etc/passwd,
  printf %s /etc/passwd). Make the scan command-word aware and exempt an explicit
  non-reader allowlist (echo / printf / : / true / false / test / [); every other command
  word -- readers AND unknown commands -- still fails closed.

Regression coverage: TestRound41Bypasses in tests/test_sandbox_tools.py (the seven
static items plus an unknown-command-still-blocks control and a benign-allowed set incl.
echo/printf/test with a sensitive path) and a workdir-module meta_path mutation test in
tests/test_sandbox_runtime_backstop.py.
2026-07-10 15:28:30 +00:00
pre-commit-ci[bot]
3570bf8687 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-10 14:51:22 +00:00
danielhanchen
b4d6b5325d Harden sandbox: global/nonlocal chained aliases, wrapper-hidden env -C / readers, meta_path.__class__, getattr re-exports, git -o/x, find/ls expanded reads
Close eight P1 bypasses Codex found on the round-39 branch:

- global / nonlocal chained alias: a `global t; t = s` (with s = os.system bound in
  the module / an enclosing scope) rebinds the target-scope name from an EXISTING
  alias, but the global/nonlocal indexer only resolved a DIRECT sink RHS, so t stayed
  unlinked and t('...') ran unguarded. Resolve a bare-name RHS through the local map
  and the already-indexed enclosing / module scopes, matching the local pass.
- env -C behind a wrapper operand (argv): _argv_env_chdir stopped at a wrapper's own
  operand (timeout 1 ...) before reaching env, so run(['timeout','1','env','-C','/etc',
  'cat','passwd']) escaped. Skip wrapper flags / numeric operands, mirroring
  _blocked_in_argv, so the trailing env -C is found.
- env -C shell operand with an expansion: `P=/etc; env -C $P cat passwd` stored $P
  literally as the cwd and never combined it. Track local VAR=value bindings and
  resolve a $VAR chdir operand against them; an unknown $ / backtick expansion fails
  closed for the following relative reader.
- wrapper-hidden reader under a dynamic cwd: the cwd=P (non-literal) reader check only
  tested argv[0], so run(['timeout','1','cat','passwd'], cwd=P) hid the reader behind
  the wrapper. Resolve the executed command word past wrappers before the relative-arg
  fail-closed decision.
- sys.meta_path.__class__ mutation: the unbound list-method guard recognized `list.*`
  and `type(sys.meta_path).*` but not `sys.meta_path.__class__.pop(sys.meta_path, 0)`,
  which removes the workdir import vetter. Add the `.__class__` receiver form (and the
  same for the sys.modules loader-table guard).
- getattr / vars re-export of a call-returned module: `<mod>.os.system` was caught, but
  getattr(__import__('pathlib'), 'os').system(...) / vars(...)['os'].system(...) /
  <mod>.__dict__['os'].system(...) were not. Map an os / posix / subprocess fetched by
  name off any expression to the sink module.
- git stuck short path option: git archive -o/tmp/x glues the escaping output path onto
  the short flag with no space, which the separated / --opt=val scans missed. Handle the
  -o / -O / -C stuck short form (a non-escaping value like -oout.tar stays allowed).
- find / ls on an expanded path: only _SHELL_READ_COMMANDS ran the expansion check, so
  find ${P:-/root/.ssh} -exec cat {} \; and ls $SECRET enumerated an unresolved host
  root. Add find / ls as enumerator readers; literal find / ls (find . -name '*.py',
  ls -la) carry no expansion and stay allowed.

Regression coverage: TestRound40Bypasses in tests/test_sandbox_tools.py (per-item
blocked cases plus a benign-allowed set: benign global reassignment / alias, literal
find / ls, a relative git output path, benign git archive, a non-reader wrapped command
under a dynamic cwd, and a benign getattr on a non-module object).
2026-07-10 14:50:00 +00:00
pre-commit-ci[bot]
7ee6993c7d [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-10 13:46:33 +00:00
danielhanchen
a965f01a6e Harden sandbox: sys.meta_path mutation, env -i/-u git hook strip, git include.path, pyc/from-os workdir imports, single-quote command-sub FP
Close seven follow-up findings Codex raised on the round-36..38 git / env /
import-vetter work (5 P1 bypasses + 2 P2 false positives):

- sys.meta_path mutation (P1): the workdir-module vetter is installed as the first
  sys.meta_path finder, but the static analyzer never rejected mutating that list, so
  sandboxed code could sys.meta_path.pop(0) (or clear / reassign / del) to drop the
  vetter, then write and import a planted evil.py. Deny any Store / Del / mutating
  method on sys.meta_path (bound, unbound list.*, subscript, and reassignment);
  reading / iterating the list stays allowed.
- env strips git hook suppression (P1): the env-based core.hooksPath suppression only
  helps if the child keeps the injected GIT_CONFIG_* vars, but env -i /
  --ignore-environment starts git with an empty environment and env -u
  GIT_CONFIG_COUNT / --unset=GIT_CONFIG_* removes it, re-enabling a planted
  .git/hooks/*. Flag an env wrapper that drops the suppression before a git child
  (git-config-env-override). env -i before a non-git command stays allowed.
- git include.path (P1): include.path / includeIf.<cond>.path pull in another config
  file whose contents git honors, so an included workdir config can set core.hooksPath
  even though the direct key is blocked. Treat any include*.path key as exec-capable in
  _git_config_key_is_exec (covers git -c and git config forms).
- pyc-only workdir import (P1): the import vetter only inspected modules whose origin
  ends in .py, so a planted sourceless evil.pyc imported via the default bytecode
  loader ran unscanned. Refuse any non-source (.pyc / .so / ...) workdir module
  outright; only a readable .py is source-scanned.
- from-os sink workdir import (P1): the vetter rejected import subprocess / from
  subprocess but not from os import system (a bare sink name), so such a helper ran an
  unguarded child at import time. Reject a from os / from posix import of a sink name
  (or a star import), and flag an actual sink-named call on any receiver.
- workdir-module attribute FP (P2): the vetter refused any module containing an
  attribute named system / popen / ... regardless of receiver, so a benign helper with
  a data attribute (p.system = 'linux') failed to import. Scope rejection to actual
  sink CALLS and to sink references rooted at os / posix; an unrelated same-named
  attribute is no longer a sink.
- single-quoted command-sub FP (P2): the sensitive-read scanner extracted $() /
  backtick payloads without tracking quote state, so echo '$(cat /etc/passwd)' (a
  literal, since single quotes suppress substitution in POSIX) was blocked as a secret
  read. Track single / double quote state in _extract_command_subs; substitutions
  inside double quotes are still extracted.

Regression coverage: TestRound39Bypasses in tests/test_sandbox_tools.py (meta_path
mutation, env -i/-u git strip, include.path, double-quote-sub still blocks, plus a
benign-allowed set incl. the single-quote literal) and three workdir-module import
tests in tests/test_sandbox_runtime_backstop.py (pyc-only denied, from-os denied,
benign same-named attribute allowed).
2026-07-10 13:45:50 +00:00
danielhanchen
6cdac72b6b Harden sandbox: git config-env / GIT_DIR overrides, env argv assignments, interactive shells, sed -e writes, PATH+=, git --exec-path/--config-env/--file
Close seven follow-up bypasses Codex found on the round-37 git / env work:

- GIT_CONFIG_* env override: a leading GIT_CONFIG_COUNT / GIT_CONFIG_GLOBAL /
  GIT_CONFIG_SYSTEM (or any GIT_CONFIG*) assignment could drop or shadow the
  injected core.hooksPath suppression that _build_safe_env relies on. Treat a
  GIT_CONFIG / GIT_CONFIG_* assignment in front of a git child as an unsafe
  override (git-config-env-override), and in the argv env-node path require the
  suppression key to be present (no override, no ** splat) for a git child.
- GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE env vars: an assignment (shell prefix
  or argv env mapping) that points git's dir / work tree / index to an escaping
  path writes the repo outside the workdir. Block those when the value escapes
  (git-write-outside).
- env argv assignments: env NAME=VALUE ... argv[0] carried inline assignments the
  scan skipped, so run(['env','PATH=.','evil']) / BASH_ENV hid an unsafe PATH.
  Reconstruct the full argv when an env wrapper carries NAME=VALUE tokens and
  rerun the blocked-command / unsafe-PATH scan.
- interactive rc shells: bash -ic / sh -i -c source rc files from a user-writable
  workdir before running the command. Deny an interactive (-i in a bundled short
  flag) shell invocation (shell-interactive-rc:<shell>).
- sed -e / --expression writes: a write / exec command (w / W / s///w / e) can ride
  in an -e SCRIPT / -e'SCRIPT' / --expression=SCRIPT operand, not just the bare
  positional script. Extract and scan every script source (mutating:sed).
- PATH+= append: the assignment regex did not match NAME+=, so PATH+=:. was parsed
  as a local command. Accept a += append assignment (_ASSIGNMENT_RE) and evaluate
  PATH+=value as $PATH + value so an unsafe append is still caught while a benign
  absolute append (PATH+=:/opt/bin) stays allowed.
- git --exec-path / --config-env / config --file: --exec-path=DIR runs git helpers
  from DIR, --config-env=KEY=VAR binds an execution-capable config from an env var,
  and git config --file/-f PATH writes a config outside the workdir. Block the exec
  forms (git-exec-config) and the escaping --file target (git-write-outside).

Regression coverage: TestRound38Bypasses in tests/test_sandbox_tools.py (per-item
blocked cases plus a benign-allowed set: plain git commit / init, non-write sed
print and substitution, a safe env PATH prefix, a non-interactive shell, and a
benign absolute PATH+= append).
2026-07-10 13:09:32 +00:00
danielhanchen
d8f8566a3b Harden sandbox: git operand expansion, single-assign env PATH, env -C argv, git exec configs, workdir import vetter, env -S reads, make
Close eight follow-up bypasses Codex found on the round-36 git / env work:

- git operand via expansion: a git path operand from a locally-assigned absolute
  variable (OUT=/tmp/repo; git init $OUT) was treated as sandbox-local. The git
  scan now resolves a $VAR / ${VAR} operand against the command's local bindings
  (new _git_operand_escapes); an unknown external expansion is left to the literal
  check so git clone $REPO_URL is not a false positive.
- single-assignment env PATH: a non-shell subprocess with an env bound by a single
  assignment (e = {'PATH': '.'}; run(['evil'], env=e)) skipped the unsafe-PATH
  check because env was a Name, not an inline dict. Resolve the single-assignment
  env node to its literal dict before the BASH_ENV / unsafe-PATH scan.
- env -C in argv: the argv-tail git rescan sliced off a preceding env -C /tmp, so
  run(['env','-C','/tmp','git','init','repo']) hid the escaping cwd. Reconstruct
  from the FULL argv so the git cwd backscan sees the wrapper.
- git exec configs: git -c KEY=CMD / git config KEY CMD run their value in an
  unguarded child for execution-capable keys (core.fsmonitor / sshCommand / pager /
  editor / credential.helper / filter.*.clean / diff.external / ...); core.hooksPath
  / init.templateDir re-point hooks (undoing the env hook suppression). Block those
  configs (alias.*=! was already handled); benign configs (user.name) stay allowed.
- workdir module import vetter: user code may import a sibling .py it wrote, but
  that source was never statically analyzed, so a planted workdir/evilmod.py could
  run os.system('cat /etc/passwd') at import time in the guarded interpreter. A
  meta-path finder now vets a module resolved FROM the workdir and refuses it if it
  reaches a command-execution sink or eval/exec/compile; library imports and benign
  sibling modules still load. (Direct sinks only; deeper obfuscation is a residual.)
- env -S / --split-string reads: env -S 'cat /etc/passwd' / --split-string= run
  the operand as a command, but the READ scanner treated it as inert. Recurse the
  split-string payload into the sensitive-read scan (shell-string and argv forms).
- make: make runs shell recipes read from a workdir Makefile in an unguarded child,
  the same escape as the pip / pytest launchers. Deny make / gmake.

Regression coverage: TestRound37Bypasses in tests/test_sandbox_tools.py and the
benign/malicious workdir-module import tests in tests/test_sandbox_runtime_backstop.py.
2026-07-10 12:31:23 +00:00
danielhanchen
c3be77614b Harden sandbox: $VAR PATH, hash -p, find -fls, git output/env-C/hooks, xargs --arg-file, watch, numpy allow_pickle, yaml positional loader
Close nine static-classifier bypasses and one false positive from Codex, plus
neutralize git hooks in the sandbox env:

- $VAR-expanded PATH: a PATH component from a shell variable bound to a relative
  / cwd value (P=.; PATH=$P evil) or a relative ${VAR:-.} default resolved to the
  workdir but was treated as a trusted absolute path. _path_value_is_unsafe now
  brace-aware splits the list and resolves local VAR=value bindings and ${VAR-def}
  defaults; $PATH / an unknown external $VAR (a trusted absolute) stays allowed.
- hash -p: hash -p PATHNAME NAME binds a command name to PATHNAME, so a later bare
  NAME runs a local executable unguarded (hash -p ./evil ls; ls). Block hash -p
  with a local-executable pathname.
- find -fls: -fls FILE writes its listing to FILE like -fprint/-fprintf; add it to
  the mutating-find actions.
- git output options: --output / -o / --output-directory (git archive / format-
  patch) carry an inline path git writes to; a value escaping the workdir is now
  flagged alongside -C / --git-dir / --work-tree / --separate-git-dir.
- env -C git: env -C DIR / --chdir DIR changes git's cwd, so a bare or relative
  git write subcommand (env -C /tmp git init) resolves under DIR. The git scan now
  looks back for an escaping env -C wrapper. A relative env -C sub, and env -C with
  a non-git reader, stay allowed.
- xargs --arg-file: xargs -a FILE / --arg-file[=]FILE reads its argument list FROM
  FILE, so a sensitive / expanded target is a host-file read even though xargs is a
  wrapper; the read scanner now flags it.
- watch: watch [options] command runs command (via sh -c or exec -x); add watch as
  a command prefix with its -n operand so the wrapped writer is resolved.
- numpy allow_pickle: numpy.load unpickles when allow_pickle is truthy; a non-
  literal (flag=True) or splatted (**{'allow_pickle': True} / **kw) value is now
  rejected. allow_pickle absent / a constant False stays allowed.
- yaml positional loader (FALSE POSITIVE fix): yaml.load(data, yaml.SafeLoader)
  passes the loader positionally; _yaml_call_has_safe_loader now accepts args[1],
  so the safe positional form is no longer wrongly blocked.
- git hooks: git runs repository hooks (.git/hooks/*) in an unguarded child; a
  sandboxed snippet could plant one and trigger it via git commit / merge /
  checkout. _build_safe_env points core.hooksPath at a non-directory (via git's
  env-config mechanism) so no repository hook runs for any git subcommand, without
  blocking git itself.

Regression coverage: TestRound36Bypasses in tests/test_sandbox_tools.py and the
sandbox-env whitelist test.
2026-07-10 11:52:31 +00:00
danielhanchen
3bad13b58d Harden sandbox: home-rooted PATH, git write targets, args= shell child, posix_spawn, python launchers, command globs, pickle loaders
Close seven static-classifier bypasses in studio/backend/core/inference/tools.py:

- Home-rooted PATH: in the sandbox HOME and the child cwd ARE the session
  workdir, so a ~ / ~user or $HOME / $PWD (${HOME} / ${PWD}) PATH entry lets a
  bare command resolve to a workdir shebang. _path_value_is_unsafe now flags
  those while keeping absolute, $PATH, and other $VAR (assumed absolute) entries
  allowed, so PATH=~/bin evil / env={'PATH': '~/bin'} block.
- git write targets: git is a native child the runtime backstop cannot see, so
  git init /tmp/x, git clone url /tmp/x, git init ../x, and git -C /outside /
  --git-dir= / --work-tree= / --separate-git-dir= write outside the workdir.
  Flag a git path operand or dir-option value that escapes the workdir; all
  workdir-relative git usage (status, log, clone url, -C sub) stays allowed.
- args= shell child: the argv sequence can be passed through the public args=
  keyword, which left _is_shell_child false and accepted a BASH_ENV / opaque env
  for a bash child. Resolve the argv from node.args[0] OR the args= kwarg for
  both the executable= reconstruction and the shell-child env check.
- posix_spawn: os.posix_spawn(path, argv, env) executes path while argv[0] is
  cosmetic, but it never entered the exec/spawn argv reconstruction, so a
  literal-env form (env=() / a byte list) ran a mutating tail (sed -i /tmp/out)
  unguarded. Widen the reconstruction to os.posix_spawn / os.posix_spawnp.
- Python launcher scripts: pip / pytest / ipython console scripts start a fresh
  unguarded interpreter (the same escape as the already-blocked bare python), so
  subprocess.run(['pytest', 'evil.py']) / pip install <local sdist> could run
  workdir code. Deny the well-known launcher entry points.
- Command-name globs: /bin/s? / touc? / /bin/[bd]ash expand to a shell / writer
  before command lookup while the scanner compares the literal basename. Fail
  closed on * / ? / [ ] glob metacharacters in a command word (a bare [ is the
  test builtin and stays allowed).
- Pickle-backed loaders: torch.load(weights_only=False), joblib.load, and
  numpy.load(allow_pickle=True) run a reduce payload. Flag the unsafe forms
  while the safe defaults (torch.load(f), torch.load(f, weights_only=True),
  numpy.load(f)) stay allowed.

Regression coverage: TestRound35Bypasses in tests/test_sandbox_tools.py.
2026-07-10 11:13:59 +00:00
danielhanchen
c2e10298b0 Harden sandbox: PATH-relative exec, git alias dispatch, executable=, alias body, trap terminator, interactive/BASH_ENV shells
Close six static-classifier bypasses in studio/backend/core/inference/tools.py:

- Unsafe PATH search list: a PATH prefix or env mapping with a relative / cwd
  entry (PATH=. cmd, PATH=.:$PATH, env={'PATH': '.'}) lets a bare command word
  resolve to a workdir shebang, defeating the bare-name PATH exemption. New
  _path_value_is_unsafe flags such assignments in the shell-prefix, standalone,
  and subprocess env= forms.
- git shell-dispatch alias: git -c alias.X=!CMD X and git config alias.X !CMD run
  CMD through an unguarded shell while the scanner sees only git. Detect the !
  marker on an alias config value in both the shell-string path and the argv path
  (git added to the argv-tail rescan set).
- executable= override: subprocess(argv, executable=PROG) runs PROG with argv[1:]
  as its flags, so scanning argv and executable separately misses
  run(['x','-i','s/a/b/','/f'], executable='/usr/bin/sed'). Reconstruct PROG +
  argv tail and scan the effective command line.
- alias body: alias x='touch f'; x runs the alias body at execution time under a
  command word the scanner cannot resolve; scan the body of each alias definition.
- trap -- terminator: trap -- 'CMD' EXIT left the handler unscanned because the
  handler operand was read as the -- token. Skip trap options / -- in both the
  blocked-command and sensitive-read trap scans.
- interactive / persisted-startup shells: bash -i (and combined -ic) sources rc
  files before the -c payload, and an exported BASH_ENV / ENV in a separate
  command persists for later shells; flag both as unscanned startup.

Regression coverage: TestRound34Bypasses in tests/test_sandbox_tools.py.
2026-07-10 10:39:56 +00:00
danielhanchen
8be5765b04 Harden sandbox: quoted command subs, path-normalize system-bin, flock/coproc/trap, low-level posix dir readers + fresh-module fd deniers
- Scan command substitutions regardless of the outer command and quoting: bash runs $(...) /
  backtick payloads even inside double quotes, so echo "$(head -1 /etc/passwd)" read the file
  while the scanner only inspected reader-command arguments. Extract every substitution payload
  (new _extract_command_subs) and recurse the read scanner into it.
- Normalize .. before the system-bin exemption: _is_local_executable_path trusted any /usr/bin/
  prefix, so /usr/bin/../../<workdir>/evil escaped the local-exec check and ran an unguarded
  shebang. normpath the command path first (the leading-slash check on the original token keeps
  ./evil local).
- Block flock as a command-executing wrapper: flock [opts] <file>|<fd> <command> (or flock -c
  <command>) runs an arbitrary command in an unguarded child; its file/fd operand + -c forms make
  the command word hard to resolve, so block the wrapper outright.
- Treat coproc as a command-position keyword: coproc [NAME] command runs COMMAND asynchronously,
  so coproc touch /tmp/escape must resolve touch as the command; add coproc to the keyword set.
- Scan trap handler payloads: trap 'CMD' SIGSPEC runs CMD in the unguarded shell on EXIT / a
  signal, so recurse the blocked-command and sensitive-read scanners into the handler operand; a
  reset (trap - EXIT) / ignore (trap '' EXIT) has nothing to run.
- Guard the low-level posix / nt directory readers: posix.listdir / posix.scandir re-export the
  ORIGINAL enumerators, so an opaque sensitive path (posix.listdir('/root')) slipped past the
  os.* dir guard; apply the same sensitive-read confinement to the low-level modules (via a
  module-parametrized _guard_dir_reader).
- Reapply fd deniers + dir-reader guards to a freshly created posix / nt module: _reguard_created
  only rewrapped open + path mutators, so a fresh module's fchmod / fchown (host-metadata mutation
  on a read-only outside fd) and listdir / scandir were unguarded; reapply them too.

Adds TestRound33Bypasses plus runtime posix dir-reader / fresh-module fd-denier tests.
2026-07-10 10:10:01 +00:00
danielhanchen
b725253e61 Harden sandbox: shell condition-body command position, chrt/mktemp, alias-aware module/builtins recovery
- Preserve command position after shell compound-statement keywords: if / while / until (and
  the existing then / do / else / elif) run their CONDITION command, so `if touch /tmp/escape;
  then :; fi` executed the child writer while the scanner mistook `if` for the command and
  skipped `touch`. Add if/while/until to the command-position keyword set (fixing both the
  blocked-command scanner and the wrapper-aware command-word resolver) and to the sensitive-read
  scanner's separators so a reader in a condition body is scanned too.
- Add chrt to the command-prefix wrappers: its arity was declared but chrt was not resolved as a
  prefix, so `chrt -o 0 touch /tmp/x` treated chrt as the command and never inspected touch.
- Block mktemp as a child writer: mktemp creates a file/dir at a caller-chosen template path
  (mktemp /tmp/x.XXXXXX, mktemp -d) outside the workdir in an unguarded child.
- Make the loader-table / namespace-dict / builtins recovery checks alias-aware:
  - sys.modules subscript and .get() now use the alias-aware _is_sys_modules, so
    `m = sys.modules; m['os'].system(...)` / `m.get('os')...` is caught like the direct form.
  - namespace-dict subscript resolves a single-assignment alias
    (`g = globals(); g['__builtins__'].__import__('os')`) via a new _is_namespace_dict_expr.
  - the dynamic-import check resolves a builtins alias
    (`b = __builtins__; b.__import__('os')`) via a new _is_builtins_ref.

Adds TestRound32Bypasses.
2026-07-10 09:30:53 +00:00
danielhanchen
cc12c8d10a Harden sandbox: block frame introspection, opaque compile, and shell pipeline negation
- Block frame / traceback introspection that recovers a runtime guard's original callable: the
  open()/os.* guard wrappers hold the unguarded callable as a free variable (real), so a snippet
  that triggers a denied open() could read it back via a trace hook or the caught exception's
  traceback (frame.f_locals['real'], tb.tb_frame.f_locals) and call it directly. __closure__ /
  cell_contents were already blocked, so the frame path was the remaining channel; add the frame
  acquisition + value-read attributes (f_locals, f_globals, f_back, f_builtins, tb_frame,
  tb_next, gi_frame, cr_frame, ag_frame, settrace, setprofile, _getframe, _current_frames,
  currentframe) to the introspection-gadget set, flagged for any receiver in both the attribute
  and getattr-string forms.
- Treat an opaque compile() source as executable: compile() does not itself run, but its code
  object can be executed WITHOUT exec / eval (fn.__code__ = compile(src, '<p>', 'exec'); fn()),
  so a non-literal compile source is as unverifiable as an opaque exec / eval payload and is now
  blocked too. A literal compile source is still analyzed recursively and stays allowed.
- Treat a leading shell ! as command-position syntax: in bash ! negates the pipeline exit status
  but the following word is still the executed command, so ! touch /tmp/escape / ! python3 -c ...
  slipped past the child-writer / interpreter blocklist. Skip a command-position ! in the
  command scanner and the wrapper-aware command-word resolver so the real command is scanned; a !
  in argument position ([ ! -f x ], find . ! -name ...) is unaffected.

Adds TestRound31Bypasses.
2026-07-10 08:37:51 +00:00
danielhanchen
bcc023b456 Harden sandbox: env -C in argv reads, yaml.load from-imports, shell startup env, exact sensitive dirs, Unpickler.load, find -exec reads
- Track env -C in a subprocess argv read scan: subprocess.run(['env', '-C', '/etc', 'cat',
  'passwd']) chdirs the child to /etc before the reader runs, so the relative reader arg reads
  /etc/passwd. Extract an argv env -C / --chdir dir (new _argv_env_chdir) and fold it into the
  cwd used to resolve relative argv reads, mirroring the shell-string env -C handling.
- Resolve from-imported yaml.load / load_all aliases: the safe-loader check only ran for the
  yaml.load attribute form, so from yaml import load; load(payload) bypassed it. Track the
  bare-name yaml load aliases and apply the same safe-loader check to the direct-call form.
- Fail closed on a non-literal shell startup env: the BASH_ENV / ENV check only inspected an
  inline env={...} dict, so env=e, dict(BASH_ENV='env.sh'), and a computed-key dict still set a
  startup script bash / sh sources before the scanned -c payload. Fold the dict(...) form and,
  for a shell child (shell=True or an argv resolving to bash / sh), flag an opaque / non-literal
  env mapping.
- Block a BASH_ENV / ENV assignment prefix before a shell in the shell-string scanner:
  BASH_ENV=env.sh bash -c '...' (and the env BASH_ENV=env.sh bash -c form) sources the workdir
  script before the -c payload; scan the command segment before each shell command word for a
  non-empty startup-env assignment.
- Match a sensitive directory named without a trailing slash: the directory markers carry a
  trailing slash to match descendants, so an unguarded ls /root / find /etc/ssh enumerating the
  dir itself was accepted. Append a slash to the candidate before the marker check so the dir
  itself matches without loosening the component boundary.
- Treat pickle.Unpickler(f).load() as a deserialization sink: the sink-name list caught
  pickle.load but not the equivalent Unpickler(file).load() API (incl. dill / _pickle /
  cloudpickle and a from-imported ctor). Detect the Unpickler-constructor receiver of a .load()
  / .load_all() method call.
- Recurse into find -exec nested shell reads: find . -exec sh -c 'cat /etc/passwd' ; runs the
  quoted -c payload in an unguarded child; the read scanner only recursed into a shell that was
  the command word. Scan each -exec segment through the read scanner (mirrors the blocked-command
  find -exec handling).

Adds TestRound30Bypasses.
2026-07-10 07:57:36 +00:00
danielhanchen
e80c4b4b1f Harden sandbox: wrapper-prefixed shell argv, wrapper durations, args= cwd, relative env -C, diff readers, literal kwargs
- Resolve a wrapper-prefixed shell argv before scanning: subprocess.run(['env', 'bash', '-c',
  'cat /etc/passwd']) hid the nested shell behind argv[0]=env, so only argv[0] was checked for a
  shell binary and the -c payload was never scanned. Resolve the executed command word past
  wrapper prefixes (via _blocked_in_argv) so env / timeout / nice wrapped bash -c is scanned.
- Skip a wrapper's numeric duration in the shell-string read scanner: timeout 1 bash -c
  'cat /etc/passwd' treated the operand 1 as the command word, so the nested bash -c was not
  reached. Add the same _is_wrapper_numeric_arg skip the blocklist path already uses.
- Honor args= when failing closed on a dynamic cwd: the fail-closed only inspected positional
  argv, so subprocess.run(args=['cat', 'passwd'], cwd=P) slipped. Resolve the argv from the
  public args= keyword too.
- Resolve a relative env -C against the ambient subprocess cwd: env -C . cat passwd under
  cwd=/etc chdirs to /etc, not the bare fragment, so the relative reader still reads /etc/passwd.
  Join a relative env -C / --chdir= operand onto the current child cwd instead of replacing it.
- Treat diff-style utilities as file readers: diff / sdiff / diff3 / colordiff / cmp print file
  contents, so an escaping glob (diff /etc/pass* /dev/null) exfiltrated a secret. Add them to
  the shell-read command allowlist.
- Expand a literal star-star dict unpack for the shell / cwd decisions: a shell= or cwd= smuggled
  through subprocess.run(cmd, **{'shell': True}) was invisible to the kwarg loop (kw.arg is None).
  Iterate keywords through a helper that also expands a literal dict unpack.
- Materialize a device-sink path via the base str.replace before trusting it: a str subclass
  could override replace() to return '/dev/null' while its real value escaped the workdir. Call
  the genuine str.replace on the underlying buffer so the real path is checked.

Adds TestRound29Bypasses plus a runtime device-sink str-subclass escape test.
2026-07-10 06:39:37 +00:00
danielhanchen
475c06c968 Harden sandbox: aliased open modules, os shell from-imports, subprocess shell cwd, device sink writes
- Recognize an aliased open-module receiver (import builtins as b; b.open(...), import io as i;
  i.open(...), import os as o; o.open(...)) as a read callee via a new _open_mod_aliases set, so
  an aliased traversal / sensitive read is caught like the literal builtins/io/os.open forms.
- Record os shell aliases from `from os import system as s` / popen: the import-walk elif that
  consumed the os module only recorded `open`, so the later shell-alias branch never saw it and
  the read scan skipped s('cat /etc/passwd'). Handle os shell functions in that branch and split
  the subprocess from-import handling into its own branch.
- Combine a subprocess cwd= with a shell payload's relative reads: subprocess.run('cat passwd',
  shell=True, cwd='/etc') is resolved to /etc/passwd (the shared read scanner now takes a cwd
  seed, overridable per-command by env -C), and a NON-literal cwd fails closed for a relative
  reader.
- Allow a Python write to a standard device sink (/dev/null, /dev/stdout, ...) in the runtime
  guard, checked on the requested path (not its realpath, so /dev/stdout is not followed to a
  redirected outside file); benign output-suppression patterns are no longer denied.

Adds TestRound28Bypasses plus runtime device-sink write tests.
2026-07-10 06:09:54 +00:00
danielhanchen
e6d24369a9 Harden sandbox: local exec scripts, dynamic/env cwd reads, BASH_ENV, exact /root, pathlib glob, quoted newlines
- Block running an explicit LOCAL executable path at command position (./evil, subdir/tool) in
  both the argv scanner and the shell command scanner: a sandboxed snippet can create + chmod a
  local script with an interpreter shebang and run it, starting an unguarded child the basename
  scan never sees. Absolute system-bin paths (/bin, /usr/bin, ...) stay allowed and are still
  interpreter-checked by basename.
- Fail closed on a child file-reader (cat / head / ...) with a relative argv path under a
  NON-literal subprocess cwd= (cwd=P that could evaluate to /etc), which cannot be proven
  sandbox-local; a literal benign cwd and a non-reader program stay allowed.
- Treat a shell startup variable (BASH_ENV / ENV) in an explicit subprocess env= dict as a shell
  escape: bash / sh sources it before the -c payload runs.
- Runtime backstop: treat the exact /root path (not only /root/*) as sensitive so a directory
  reader over the root home is denied, and wrap Path.glob / Path.rglob like Path.iterdir so a
  dynamically built receiver pointing at a sensitive directory is screened.
- Make the newline -> ; command-separator rewrite quote-aware, and neutralize quoted separators
  before the command-boundary regex, so a quoted multiline string (echo "ok\nrm") is not
  mis-blocked; unquoted separators and command substitution ($(...) / backticks, including
  inside double quotes) still block.

Adds TestRound27Bypasses plus runtime tests for exact /root and pathlib glob / rglob.
2026-07-10 05:45:53 +00:00
danielhanchen
fbc67fd490 Harden sandbox: history writes, cwd-relative reads, unpacking aliases, pin guard builtins/stat, root-home reads
- Block bash's history builtin when it reads/writes a file (history -w / -a / -r / -n): it can
  create or overwrite an arbitrary host path (or read a file into the buffer) in the unguarded
  shell child. Bare history / -c / -d / -p / -s stay allowed.
- Combine a subprocess cwd= with relative argv paths in the sensitive-read scan, so
  subprocess.run(['cat', 'passwd'], cwd='/etc') is seen as a /etc/passwd read.
- Track env -C DIR / --chdir DIR in the shell-string read scan so a later relative reader
  argument (env -C /etc cat passwd) resolves against DIR.
- Record aliases created by tuple/list unpacking assignments ((s,) = (os.system,); a, b =
  os.system, 1; [e] = [exec]) in the scope alias index, pairing a literal target with a literal
  RHS element-wise, so the shell/exec/deserializer sink checks see them.
- Pin the builtins the runtime path guard consults (isinstance / int / bytes / str / any) into
  the guard namespace, so sandboxed code cannot reassign builtins.isinstance to make
  isinstance(path, int) treat an outside path as an fd and approve an absolute write.
- Re-pin os.path.stat + S_ISLNK before each realpath resolution in the guard, so a
  os.path.stat.S_ISLNK = lambda mode: False (stopping realpath from following an in-workdir
  symlink that escapes) cannot approve a write the real open() then routes outside.
- Restore the /root/ protection in the runtime sensitive-read backstop for an opaque path,
  carving out package / library trees (site-packages, dist-packages, the stdlib) so imports
  under a root home are not broken.

Adds TestRound26Bypasses plus runtime tests for the pinned builtins / stat and root-home reads.
2026-07-10 05:12:39 +00:00
danielhanchen
7f8c4d7a8f Harden sandbox classifier: brace expansion, prefixed/nested shell reads, unbound MRO gadget
- Model bash brace expansion (comma lists) before the block and read scans, so a payload such
  as {touch,/tmp/x} or {python3,-c} '...' is seen as the writer / interpreter bash actually
  runs. Only unquoted groups with a top-level comma expand; {} (find -exec), ${VAR} parameter
  expansion, numeric {1..5} sequences and quoted braces are left intact, and expansion is
  bounded.
- Resolve the reader / command word in the shell-string sensitive-read scan past leading
  VAR=value assignments and command wrappers (env / nice / timeout / ...), flag a VAR=value
  whose value is a sensitive path, and recursively scan a nested bash -c '<payload>' shell, so
  a read hidden behind a normal command-prefix form is caught. The classifier and terminal
  scanners now share one _scan_command_string_for_reads with a strict_traversal knob (strict
  for os.system shell strings, lenient .. for benign in-tree terminal navigation).
- Treat unbound MRO / getattribute access on a guarded file class as the same recovery gadget
  as io.FileIO.__mro__: type.mro(io.FileIO), type.__getattribute__(io.FileIO, '__mro__') /
  object.__getattribute__(..., 'mro'), and getattr(io.FileIO, '__mro__') are blocked.

Adds TestRound25Bypasses plus terminal brace / prefixed-read regression tests.
2026-07-10 04:41:56 +00:00
danielhanchen
5eef982723 Harden sandbox classifier: sed write/exec, rmdir, glued redirect, PyYAML, methodcaller, chained aliases, bash reads
- Block sed w-path writes without a separating space (w followed by a slash,
  tilde or tab) and sed e / s///e scripts that execute a shell command (new
  _SED_WRITE_RE / _SED_EXEC_RE / _SED_SFLAG_RE checks in the mutating-util scan).
- Add rmdir to the POSIX child-writer denylist.
- Split a glued input redirection (sh here-string payload) before shell
  detection by adding the input-redirect operator to the shlex punctuation_chars.
- Deny PyYAML unsafe deserialization: yaml.unsafe_load / full_load(_all) are
  unconditional sinks, and yaml.load / load_all are flagged unless given an
  explicit safe Loader (SafeLoader / CSafeLoader / BaseLoader).
- Rewrite operator.methodcaller('system', ...)(os) to the direct os.system(...)
  call in both the signal-escape visitor and the sensitive-read scanner so a
  methodcaller-hidden shell / read sink is analyzed.
- Fix chained single-assignment alias resolution (s = os.system; t = s; t(...)):
  the scope walk yielded assignments out of order, so process them in source
  order before propagating alias identity through smap / emap / dmap.
- Apply the sensitive-read scan to direct terminal (bash) commands, which run in
  an unguarded shell child that the Python-tool open() backstop does not cover;
  block reads of host identity / credential files, sensitive-target directory
  traversal, and escaping-glob / expansion reads while allowing benign in-tree
  relative navigation.

Adds TestRound24Bypasses plus terminal sensitive-read regression tests.
2026-07-10 04:08:13 +00:00
danielhanchen
6f5ab8a65d Harden sandbox: versioned interpreters and per-wrapper option arity in argv, low-level os alias and shell-string reads, exec-family varargs and traversal reads, inherited class sinks, newline separators, dir-reader path materialization 2026-07-10 03:16:24 +00:00
danielhanchen
c3c2106ffb Harden sandbox: env option arity and hidden shells in argv, find/sed actions in argv vectors, split child-writer, dot source builtin, class sinks through instances, user-site disable 2026-07-10 02:35:01 +00:00
danielhanchen
65781ccf14 Harden sandbox: wrapper option operands and hidden shells in argv, shell=True sequence payloads, pty/posix/runpy import and alias sinks, dunder/vars/unbound-dict namespace access, expansions behind wrappers, fresh built-in module creation 2026-07-10 01:57:32 +00:00
danielhanchen
daea6c84d0 Harden sandbox: keyword subprocess args, shell-separator reads, sed write commands, non-shell argv scoping, getattr(sys, 'modules'), FileIO MRO iteration, dir-reader sensitive reads 2026-07-10 01:22:07 +00:00
danielhanchen
52e68507ff Harden sandbox: global/nonlocal aliases, wrapper-hidden commands, sys.modules aliases, descriptor gadgets, higher-order containers, keyword path guard
Round 19 review follow-ups on the Studio code-exec sandbox classifier and runtime guard:

- Record a global/nonlocal sink alias in its TARGET scope (module for global, enclosing for
  nonlocal): global s; s = os.system; s('...') now resolves s to the shell sink instead of
  being skipped as a rebound name (and a global name no longer shadows an outer alias).
- Fail closed on any expansion in COMMAND POSITION, not just command substitution: a
  variable-expanded command word (p=python3; $p -c ...) / ${VAR} is unprovable. Argument
  position ($HOME, echo $(date)) stays allowed.
- Honor wrapper/command-position logic in the mutating-utility and shell-script scans via a
  shared command-word index, so env sed -i / timeout 5 bash s.sh no longer hide behind a
  wrapper prefix.
- Use the subprocess-exec callee resolver for the shell=True read scan, so
  from subprocess import run as r; r('head /etc/passwd', shell=True) and r = subprocess.run
  aliases are tokenized.
- Resolve sys.modules aliases (m = sys.modules; m.pop('_io')) for the loader-table mutation
  checks (bound and unbound forms).
- Treat a __dict__ subscript keyed by a gadget dunder (type(open).__dict__['__closure__'].
  __get__(open)) as gadget access, closing the descriptor-lookup route around the attribute
  gadget scan.
- Unwrap inline literal containers in the higher-order sink check, so
  list(map([eval][0], [...])) / map({'e': exec}['e'], ...) are flagged.
- Runtime: the mutator guard now accepts the path via its public keyword (os.makedirs(name=),
  os.mkdir(path=)) instead of raising TypeError, while still confining the write.

Adds TestRound19Bypasses and keyword-path runtime tests; full sandbox suite green.
2026-07-10 00:42:34 +00:00
danielhanchen
94cb233099 Harden sandbox: command-sub, posix/pty imports, unbound sys.modules, mutating read utils, os re-exports, instance-attr aliases, network aliases/keywords
Round 18 review follow-ups on the Studio code-exec sandbox classifier and runtime guard:

- Fail closed on a command-position command substitution ($(printf touch) / `printf touch`
  as the command word): the expansion becomes the command name and cannot be proven safe. An
  argument-position substitution (echo $(date), x=$(cmd)) stays allowed.
- Model direct imports of the os C backend (posix/nt -> os aliases) and pty: posix.system(...)
  resolves as an os shell sink and pty.spawn(...)/pty.fork() is flagged as an unguarded child.
- Reject unbound sys.modules mutation (dict.pop(sys.modules, '_io'),
  type(sys.modules).__delitem__(sys.modules, ...)) alongside the bound sys.modules.pop form.
- Treat mutating flags of normally read-only utilities as child writers: sed -i, sort -o FILE,
  find ... -delete, dd of=FILE, tee FILE, truncate. Non-mutating uses stay allowed.
- Detect os / subprocess re-exported through a stdlib module (pathlib.os.system,
  tempfile.os.system, subprocess.os.system): the .os attribute IS the os module.
- Resolve instance-attribute sink aliases (c.e = exec; c.e(payload) / obj.s = os.system;
  obj.s('rm -rf /')) tree-wide, alongside the existing class-attribute aliases.
- Import the guard's remaining pure-Python dep (shutil) with the workdir still stripped from
  sys.path; the restore now runs at the very end of the prelude, so no workdir/shutil.py can
  shadow it.
- Network policy: resolve import aliases (import requests as r; r.get(...)) and inspect the
  url= / address= keyword arguments so aliased or keyword-host calls to a metadata / untrusted
  host are no longer skipped.

Adds TestRound18Bypasses and extends the workdir-shadowing runtime test to shutil; full
sandbox suite green.
2026-07-10 00:11:00 +00:00
danielhanchen
cf7503da21 Harden sandbox: compile/FunctionType gadgets, lambda/comprehension aliases, IFS + symlink redirects, workdir import shadowing
Round 17 review follow-ups on the Studio code-exec sandbox classifier and runtime guard:

- Track a code object built through a LOCAL compile alias (cfn = compile; co = cfn(src);
  types.FunctionType(co, {})()) by resolving the callee through the scope exec-builtin map,
  so the FunctionType execution gadget still gets the recursive payload analysis.
- Recognize type(lambda: None) as the function constructor: type(lambda: None) IS
  types.FunctionType, so type(lambda: None)(code, {})() executed a compile() code object
  without the eval/exec gate.
- Include lambda and comprehension scopes in the alias index: (lambda e=exec: e(payload))()
  and [e(payload) for e in [exec]] now resolve e back to the exec sink. Lambdas/comprehensions
  become their own alias scopes, and a one-element comprehension generator binds its target.
- Record annotated single-assignment aliases (e: object = exec; e(payload)) alongside plain
  assignments, so the AnnAssign RHS is analyzed.
- Expand ${IFS} / $IFS to whitespace before shell command matching, so a separator-obfuscated
  writer/reader (rm${IFS}-rf${IFS}/, cat${IFS}/etc/shadow) is tokenized as bash runs it.
- Import the child-guard's stdlib deps (os/io/pathlib/re) with the workdir stripped from
  sys.path, then restore it, so a malicious workdir/os.py or pathlib.py cannot shadow a guard
  import and run unguarded at import time.
- Fail closed on shell redirection to any real-file target (the unguarded child follows a
  pre-existing symlink); only fd duplications (>&2) and the standard device sinks
  (/dev/null, ...) are allowed. This also fixes a pre-existing false positive where a benign
  redirect to /dev/null was blocked.

Adds TestRound17Bypasses and a workdir-shadowing runtime test; full sandbox suite green.
2026-07-09 23:35:46 +00:00
danielhanchen
93a1786898 Harden sandbox: Path.open reads, shell -c argv reads, ANSI-C quoting, from-import subprocess sinks, escaping globs
Round 16 review follow-ups on the Studio code-exec sandbox classifier and runtime guard:

- Apply the runtime sensitive-read backstop to Path.open reads (not just write
  modes), so a dynamically assembled pathlib receiver (Path(globals()['P']).read_text())
  cannot exfiltrate a host secret. On Python <= 3.11 pathlib holds the original io.open,
  so confining at the public Path.open level is the version-robust fix. read_text /
  read_bytes route through the same self.open() and are covered.
- Scan the -c payload of a subprocess shell argv for sensitive reads. subprocess.run(
  ['sh', '-c', 'head -1 /etc/passwd']) has no blocked command, but the unguarded child
  prints the secret, so the payload is now tokenized and read-scanned like a string sink.
- Normalize bash ANSI-C ($'...') and locale ($"...") quoting before command matching.
  shlex leaves $'touch' as the literal $touch, so a writer / interpreter hidden behind
  ANSI-C quoting ($'touch' x, $'\x74ouch' x) previously evaded the command blocklist;
  the escapes bash resolves (\n, \xHH, octal, \uHHHH) are decoded first.
- Recognize from-imported subprocess exec names as read sinks: from subprocess import
  run as r; r(['cat', '../../etc/shadow']) now hits the traversal / sensitive-read check.
- Treat a shell glob that can expand outside the workdir (absolute / ~ rooted, e.g.
  head /etc/shad*) as an escaping read expansion and fail closed, for reader arguments
  and input redirects. A relative in-workdir glob (grep foo *.txt) stays allowed.

Adds TestRound16Bypasses and pathlib runtime backstop tests; full sandbox suite green.
2026-07-09 23:01:47 +00:00
danielhanchen
59fff4b86b Harden sandbox: alias read sinks, gc graph walk, list-concat fold cap, runtime sensitive-read backstop, multi-component redirects
Round 15 review follow-ups on the Studio code-exec sandbox classifier and runtime guard:

- Resolve single-assignment aliases for shutil.copy* and subprocess exec read
  sinks (c = shutil.copy; c('../../etc/passwd', ...); r = subprocess.run; r([...]))
  so the traversal / sensitive-path check fires on the aliased callee.
- Block gc.get_referents / get_referrers / get_objects (and from-import aliases):
  they walk the object graph to a guarded wrapper's closure cell to recover the
  original unguarded open / os.* callable.
- Cap list / tuple concatenation during constant folding so a doubling chain
  (a + a + a + ...) cannot materialize an oversized sequence in the parent process
  before the child rlimits apply.
- Add a runtime sensitive-read backstop in the child prelude: deny a read whose
  realpath resolves to a known host secret (SSH / cloud / kube / netrc / HF-token /
  /etc/passwd family / /proc) outside the workdir. This covers opaque read paths the
  static scanner cannot fold (open(globals()['x'])) and pre-existing in-workdir
  symlinks to secrets, while leaving benign outside reads and library imports intact.
- Fail closed on relative multi-component shell redirect targets (echo x > sub/out.txt)
  whose subdirectory component could be a symlink traversing outside the workdir; a
  bare single-component target stays allowed.

Adds TestRound15Bypasses and runtime backstop tests; full sandbox suite green.
2026-07-09 22:34:19 +00:00
danielhanchen
dc8dde653f Harden sandbox classifier against round-14 shell-argv, path-alias, and class-alias bypasses
Shell command scanner:
- Analyze subprocess shell argv vectors as a whole (['sh', 's.sh'] / ['bash', '-s'] / bare ['bash'] blocked; ['bash', '-c', 'literal'] scans the payload; dynamic -c blocked).
- Add archive / compression writers (tar, zip, gzip, xz, zstd, 7z, rar, cpio, rsync, ...) to the child write blocklist.
- Deny any command-position shell without an inline -c payload, covering piped bare shells (printf ... | bash).
- Fail closed on shell-expanded read paths: an input redirect (< $VAR) or a $ / backtick expansion passed to a file-reading command (cat $P).

Static read scanner:
- Fold os-aliased / from-imported path builders (import os as o -> o.path.join(...); from os.path import join -> join(...)).

Dynamic-exec / obfuscation:
- Normalize operator.methodcaller('__getattribute__', 'name')(obj) as an attribute fetch like attrgetter.
- Resolve class-body sink aliases reached as ClassName.attr (class C: f = os.system; C.f(...)) for shell / exec / deserializer sinks.

Adds TestRound14Bypasses covering each vector plus benign controls.
2026-07-09 21:59:23 +00:00
danielhanchen
e92119eaac Harden sandbox classifier against round-13 shell, read-callee, and dir_fd bypasses
Shell command scanner:
- Recognize >& as a redirection operator (echo hi >& /tmp/x), keeping fd redirects (>&2) allowed.
- Block pushd cwd escapes alongside cd, including behind command / builtin wrappers.
- Add awk / gawk / mawk / nawk to the interpreter child blocklist.
- Block shell script-file execution (bash s.sh, sh script.sh, bash -s) since only inline -c is analyzable.

Static read scanner:
- Resolve non-bare open callees for traversal reads (builtins.open, __builtins__.open, open.__call__).
- Fold function-local single-assignment constants inside path-builder calls (p = '/etc'; os.path.join(p, 'passwd')).
- Resolve single-assignment Path constructor aliases (P = pathlib.Path; P('/etc', 'passwd').read_text()).
- Treat subprocess argv path traversals as host reads (subprocess.run(['cat', '../../root/.ssh/id_rsa'])).
- Block getattr(<sensitive module>, '__dict__') namespace obfuscation.

Runtime backstop:
- Deny read-only os.open with dir_fd (an fd-relative read under an outside directory fd escapes the workdir).

Adds TestRound13Bypasses and a read-only os.open dir_fd runtime test.
2026-07-09 21:24:24 +00:00
danielhanchen
389303d3de Harden sandbox classifier against round-12 mro/attrgetter/loader bypasses
Static classifier:
- Flag mro().pop(i) / __mro__.pop(i) base-class extraction alongside the subscript and __getitem__ forms.
- Normalize operator.attrgetter('name')(obj) as attribute access whether or not the result is immediately invoked, so attrgetter('__closure__')(open)[0] gadget recovery is caught.
- Resolve a container-hidden open alias (o = [open][0]; o('../../etc/passwd').read()) as a read callee.
- Fail closed on an opaque read path assembled from obfuscation primitives (open(''.join(map(chr, ...))).read()), matching the exec-payload obfuscation policy.
- Treat cd behind the command / builtin shell wrappers as a cwd escape before allowing a relative redirect.
- Block importlib file loaders as execution sinks (SourceFileLoader(...).load_module(), spec.loader.exec_module(...)).
- Add the in-cluster Kubernetes service-account credential path to the sensitive-read list.

Adds TestRound12Bypasses covering each vector plus benign controls.
2026-07-09 20:55:05 +00:00
danielhanchen
6baedd40d5 Harden sandbox classifier against round-11 obfuscation and higher-order sink bypasses
Static classifier:
- Flag __getattribute__/__getattr__ with a non-foldable (runtime-assembled) attribute name as an obfuscated gadget access.
- Recognize POSIX noclobber redirect targets (>| path) in the shell redirect scanner.
- Detect a container-wrapped compile() code object passed to types.FunctionType.
- Flag mro().__getitem__(i) / __mro__.__getitem__(i) base-class extraction alongside the subscript form.
- Scan shell command strings (os.system / subprocess shell=True / getoutput) for embedded sensitive-file reads.
- Treat a default-parameter value that is a dangerous callable (def f(e=exec)) as a sink alias.
- Normalize a trailing .__call__ for shell/import/deserializer sinks (os.system.__call__, __import__.__call__, pickle.loads.__call__).
- Resolve a container-unwrapped sink assigned first (s = [os.system][0]; s(...)).
- Look through no-op pathlib methods (resolve/absolute/expanduser) when resolving a read receiver.
- Recognize from-imported shutil copy sinks (from shutil import copy as c).
- Extend the higher-order first-class-value check to shell and deserializer sinks (map(os.system, ...), partial(subprocess.getoutput, ...), map(pickle.loads, ...)).
- Normalize operator.attrgetter('name')(obj) as attribute-access obfuscation.

Adds TestRound11Bypasses covering each vector plus benign controls.
2026-07-09 20:30:42 +00:00
danielhanchen
e895535b96 Harden sandbox classifier against round-10 introspection and indirection bypasses
Static classifier:
- Flag mro()[i] base-class extraction alongside subscripted __mro__ (FileIO C base recovery).
- Fail closed on non-literal shell redirect targets and cd to an outside directory.
- Block sys.modules mutating methods (pop/popitem/clear/setdefault/update) that drop a guarded module for reimport.
- Detect indirect eval/exec: <builtin>.__call__(payload) and eval/exec/compile passed by reference to a higher-order call (map/reduce/partial), including starred literals.
- Block inspect.getclosurevars() closure recovery of a guarded wrapper.
- Track runpy run_path/run_module from-import aliases.
- Expand starred literal path arguments in the sensitive-read scanner.
- Resolve container-hidden deserializers (([pickle.loads][0])(payload)).

Runtime backstop:
- Guard the low-level posix/nt chdir (cwd escape) and fchmod/fchown fd metadata mutators, matching the os.* deniers.

Adds TestRound10Bypasses and low-level posix runtime tests.
2026-07-09 19:51:54 +00:00
danielhanchen
ccac1f0293 Studio sandbox: close ninth-round review bypasses (module table, exec sinks, aliases, budget)
Static classifier:
- deny sys.modules Store/Del: del sys.modules['posix']; import posix drops the guard-patched module for a fresh unwrapped C module
- flag code.InteractiveInterpreter().runcode / InteractiveConsole().runsource as code-object execution sinks (opaque compile results run un-analyzed)
- resolve indirect read-only open callees: from os/io/builtins import open as X (os.open read-only is deliberately allowed outside the workdir, so traversal must be caught statically)
- fold os.path.normpath / abspath on literals so a traversal that only emerges after normalization is scanned
- resolve inline-container-hidden exec/eval: ({'e': exec}['e'])(...) / [exec][0](...)
- normalize the bound one-arg __getattribute__ form obj.__getattribute__('name') (builtins.open.__getattribute__('__closure__'))
- descend into a literal list/tuple argv so subprocess.run(['cat', '/etc/passwd']) is caught
- enforce the analyzer node budget: charge each tree's node count against _MAX_ANALYZER_NODES and fail closed above it (parent-process DoS guard)

Runtime backstop:
- capture and re-pin os.lstat / os.readlink / os.getcwd / os.stat before realpath, since it consults the live symlink helpers -- monkeypatching os.lstat to fail could stop realpath following an in-workdir symlink that points outside
2026-07-09 19:17:07 +00:00
danielhanchen
0c17f074ae Studio sandbox: close eighth-round review bypasses (closures, class scopes, redirects, aliases)
Static classifier:
- flag cell_contents (the only closure-cell reader) as a gadget so recovering a guarded wrapper's original callable via __closure__ fails closed even when the __closure__ name is built at runtime
- treat a class body as its own alias scope (class C: e = eval; e(...) now recognized) while keeping methods lexically skipping the class scope, so a same-named class attr does not shadow the module-level sink a method reaches
- block output redirection (> / >> / &> / N>) to an absolute / ~ / .. target: a child shell runs unguarded; relative in-workdir redirects stay allowed
- resolve pathlib constructor import aliases (from pathlib import Path as P) before traversal reads
- flag an integer-indexed __mro__ (io.FileIO.__mro__[1]) that extracts the unguarded FileIO C base class; plain iteration / slicing stays allowed
- resolve aliased read callees before traversal checks: o = open and import shutil as sh; sh.copy(...)
- recurse into env -S / --split-string operands so env -S 'python3 -c ...' still detects the interpreter
2026-07-09 18:45:38 +00:00
danielhanchen
bf4bc7e449 Studio sandbox: close seventh-round review bypasses (scope counts, obfuscation, child writers, reads)
Static classifier:
- fix scope-local walker so a nested def/class reassigning an alias name no longer inflates the outer single-assignment count and drops a real module-level sink alias
- track non-bare compile aliases (builtins.compile, from builtins import compile as comp) for the types.FunctionType(c) code-object gadget
- block child-process file writers at shell command position (touch/tee/cp/mv/mkdir/install/truncate/mkfifo/mknod/shred/unlink): a spawned child runs without the in-process write guard
- expand a literal **{...} unpack in the read scanner so open(**{'file': '../../etc/passwd'}) is resolved
- resolve a pathlib expression bound to a single-assignment name before read methods (p = Path('..')/'etc'/'passwd'; p.read_text())
- keep a wrapper's separated option argument in command position so stdbuf -o L python -c ... still detects the interpreter (env -i rm still caught; no FP on grep patterns)
- treat object.__getattribute__ / type.__getattribute__ as attribute obfuscation, covering gadget dunders and sensitive-module attrs (also closes __closure__ recovery of a guarded wrapper's original callable)
- block runpy.run_path / runpy.run_module execution sinks
- treat shutil.copy*/move SOURCE as a read callee so a .. traversal source is caught

Runtime backstop:
- normalize a bytes realpath (fsdecode) before the workdir prefix compare so a legitimate in-workdir bytes write is not denied by a TypeError; outside bytes writes still denied
2026-07-09 18:14:22 +00:00
danielhanchen
0f4b4b3d36 Studio sandbox: close sixth-round review bypasses (obfuscation, reads, child procs, guard pinning)
Static classifier:
- resolve pathlib expressions passed to open()/read callees so open(Path('/etc') / 'passwd') blocks like open('/etc/passwd')
- flag getattr()/setattr() of an introspection gadget dunder (__globals__, __subclasses__, ...) regardless of receiver
- add the .get() twin of the globals()/locals()/vars() namespace-dict subscript guard
- constant-fold sys.modules[...] and sys.modules.get(...) keys so a concatenated key is caught
- track 'from builtins import __import__ as imp' as a dynamic import alias
- treat deserializer module aliases (pickle, dill, ...) as sensitive targets for getattr/vars/__dict__
- flag code objects executed through types.FunctionType(compile(src, ...), ...), including the c = compile(src); FunctionType(c) two-step
- block language interpreters (python/perl/ruby/node/...) at shell command position: a spawned child runs without the in-process write guard

Runtime backstop:
- pin os.fspath/os.path.realpath to captured originals inside _within so a sandboxed reassignment of os.fspath cannot make realpath resolve an outside write target to an in-workdir path
2026-07-09 17:32:27 +00:00
danielhanchen
5dcb93f57d Studio sandbox: close fifth-round review bypasses (aliases, reads, path guards)
Scope-aware assignment aliases (extends the per-scope index):
- Resolve single-assignment aliases of dangerous callables in the call's own scope:
  e = builtins.eval, im = importlib.import_module (and imp = __import__), and
  l = pickle.loads (incl. aliased modules). Previously only bare-name and from-import
  aliases were recognized.
- Count function parameters as local bindings so a parameter lexically shadows an
  outer sink alias of the same name (fixes a false positive where def f(s): s(...)
  with a module-level s = os.system flagged the parameter call).

Sensitive-read scanner:
- Resolve pathlib join receivers -- (Path('/etc') / 'passwd').read_text() and
  Path('/etc').joinpath('passwd') -- not just a bare Path(...) constructor.
- Normalize path spellings (collapse redundant separators / '.' and resolve '..')
  before the exact / dir checks, so /etc//passwd, /etc/./passwd and
  /tmp/../etc/passwd are matched.
- Fold function-local single-assignment path constants (def f(): p = '/etc/passwd';
  open(p)), not only module-level constants.
- Flag sys.modules.get('os') as the method-call twin of sys.modules['os'].

Runtime realpath backstop:
- Path.open coerces a str-subclass mode through the base str (matching the other open
  wrappers) so a lying __contains__ cannot skip the write check.
- Path.rename/replace/link materialize the target once so a stateful __fspath__ cannot
  return an in-workdir path for the check and an outside one for the real call (the
  pre-3.11 accessor path where this wrapper is the only confinement).

Adds regression tests across the classifier, aliasing and runtime-backstop suites.
2026-07-09 16:50:43 +00:00
danielhanchen
0441be5e11 Studio sandbox: close fourth-round review bypasses (scope-aware aliases + guards)
Scope-aware alias resolution (replaces the flat, module-wide alias maps):
- A new per-scope index resolves shell-sink, exec-builtin and compiled-code aliases
  with Python lexical scoping. This fixes two problems the flat maps had: a safe
  `c = compile('1+1')` in one function no longer shadows a dynamic `exec(c)` in
  another (a real bypass), and a `s = os.system` in one function no longer makes a
  benign `s = print` call in another look like a shell sink (a false positive), while
  still catching a genuine function-local sink and honoring local shadowing of a
  module-level alias.

Runtime realpath backstop:
- io.FileIO now passes the MATERIALIZED fspath to the real constructor (a stateful
  __fspath__ could otherwise return an outside path to the C constructor).
- Deny an integer fd path for the mutating single-path wrappers (os.chmod(fd) etc.):
  a read-only fd opened on an outside file could otherwise mutate host metadata.

Constant-folder allocation DoS:
- Refuse dynamic printf widths/precisions ('%*s', '%.*f') that draw their size from a
  runtime argument.
- Bound str.replace / str.join output before it allocates (a long replacement over
  many occurrences, or joining many long parts, can build a multi-gigabyte string).

Static classifier:
- Flag builtins / a sensitive module reached through the namespace dict:
  globals()['__builtins__'], locals()[...] and globals()['os'].

Adds regression tests across the aliasing, runtime-backstop, const-fold and classifier
suites for every item above.
2026-07-09 16:22:40 +00:00
danielhanchen
daaaee5bcc Studio sandbox: close third-round review bypasses (backstop + folder + classifier)
Runtime realpath backstop:
- Guard the low-level posix / nt module mutators (os re-exports from them, so
  posix.open / posix.rename / ... stayed reachable with the originals).
- Guard io.FileIO / _io.FileIO constructors for write modes (a C constructor that
  opens a file without routing through open()).
- Add os.mkfifo / os.utime / os.setxattr / os.removexattr (and lchflags) to the
  guarded single-path mutators.
- Materialize fspath ONCE per call so a stateful __fspath__ cannot return a workdir
  path for the check and an outside path for the syscall (TOCTOU).
- Coerce open() mode through the base str and os.open flags through the base int, so
  a str-subclass __contains__ or an int-subclass __and__ cannot lie to the guard.

Constant-folder allocation DoS:
- Refuse str.format templates with a nested width field ({:{}}) driven by an
  oversized numeric argument before format() allocates.

Static classifier:
- Reconstruct the full pathlib receiver path (all constructor args, joined) and
  accept module-qualified pathlib.Path so Path('/etc', 'passwd').read_text() and
  pathlib.Path(...) reads are inspected, not just single-arg bare Path(...).
- Treat builtins.__import__ / __builtins__.__import__ as a dynamic import.
- Count alias single-assignment per function scope instead of tree-wide, so two
  functions binding the same local name no longer cancel out and miss a real sink.

Adds regression tests across the runtime-backstop, const-fold, aliasing and
classifier suites for every item above.
2026-07-09 15:50:14 +00:00
pre-commit-ci[bot]
89650ddc95 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-09 15:18:32 +00:00
danielhanchen
fbb030b019 Studio sandbox: close second-round review bypasses (classifier + backstop)
Fixes a further batch of P1 bypasses and analysis-time DoS vectors found in review.

Static classifier:
- Decode exec/compile bytes payloads the way CPython does (PEP 263 coding cookie
  via tokenize.detect_encoding), then analyze the real source. A bytes payload whose
  UTF-8 view is pure comments but whose utf-7 decode runs hidden code no longer slips
  through; a payload decoding to a blocked op blocks, a benign one stays allowed.
- Resolve exec-builtin aliases assigned in nested scopes (def f(): e = exec; e(...)),
  matching the shell-sink aliasing (stored-once guard keeps it low false-positive).
- Treat deserializer modules (pickle/marshal/dill/...) as dangerous dynamic-import
  targets so __import__('pickle').loads(blob) is caught.
- Flag vars(os) / vars(__builtins__) as a module-__dict__ obfuscation, like os.__dict__.
- Inspect the pathlib receiver path for read methods: Path('../../.ssh/id_rsa').read_text()
  / read_bytes() / open() now check the constructor path, not only call args.
- Fold literal os.path.join / posixpath.join so open(os.path.join('/etc','passwd')).read()
  is seen by the sensitive-read scanner instead of treated as opaque.

Constant-folder allocation DoS (folding runs in-process, before subprocess rlimits):
- Reject oversized f-string / str.format / %-format widths and precisions before
  format() allocates the padded string.
- Reject oversized str padding-method widths (ljust/rjust/center/zfill).
- Cap list/tuple repetition (seq * n) as str/bytes repetition already was.

Runtime realpath backstop:
- Do not publish __wrapped__ on the guard wrappers (functools.wraps would expose the
  original unguarded callable, e.g. open.__wrapped__(outside, 'w')).
- Guard the low-level _io.open entry point (io.open / builtins.open originate there).
- Confine os.chdir to the workdir and deny os.fchdir so a cwd escape cannot turn a
  later relative read/write into a host-path access.
- Deny fd-based metadata mutators (os.fchmod / os.fchown) that could reuse a read-only
  descriptor opened on an outside file.

Adds regression tests across the const-fold, aliasing, exec-recursion and runtime-
backstop suites for every item above.
2026-07-09 15:17:37 +00:00
pre-commit-ci[bot]
47dd8fd277 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-09 14:39:53 +00:00
danielhanchen
8c317ddb44 Studio sandbox: close static-classifier and runtime-guard review gaps
Harden the code-exec sandbox against bypasses raised in review, keeping the
static gate a pure classifier (it never executes the tool call):

- Shell scan: `timeout` duration/float args (5m, 0.5, 2h) no longer drop the
  following command out of command position, and `find -exec CMD ... ;` rescans
  the whole slice so a wrapped `env`/`timeout`/`sh -c` target is still caught.
- exec/eval/compile of a bytes payload that is not valid UTF-8 Python now blocks:
  those sinks honor PEP 263 coding cookies (e.g. utf-7) that the static UTF-8 view
  cannot see; plain ASCII bytes payloads stay allowed.
- Resolve aliased/indirect reaches to exec, dynamic import, sensitive modules and
  deserialization sinks: builtins.eval / __builtins__.exec, from builtins import
  exec as e, importlib aliases, sys.modules[...] (and the getattr form), os.__dict__,
  posix/nt, and pickle/marshal module-and-symbol aliases plus the *.load variants.
- Shell-sink aliasing walks the whole tree, so a function-local `s = os.system`
  alias is resolved (the stored-once guard keeps it low false-positive).
- Drop __mro__ / __code__ from the introspection-gadget dunders: on their own they
  do not reach an execution primitive and are read by ordinary ML/debug code.
- Refuse folding oversized `bytes(n)` / `bytearray(n)` so static analysis cannot OOM.

Runtime realpath backstop:
- Fail closed on a mutating dir_fd / src_dir_fd / dst_dir_fd (an fd-relative path
  cannot be confined by a string realpath) for os and shutil mutators.
- Confine Path.rename/replace/symlink_to/hardlink_to when the destination is passed
  as the `target=` keyword, not only positionally.
- Splice the guard after a leading docstring and `from __future__` imports instead
  of prepending it, so future-import programs no longer raise SyntaxError while the
  sandbox is still established before the first real statement.

Adds regression tests for each gap across the shell, const-fold, exec-recursion,
aliasing and runtime-backstop suites.
2026-07-09 14:39:12 +00:00
danielhanchen
98cd44861e Studio sandbox: fix Path.open/write_text on Python <= 3.11 under the runtime guard
On Python <= 3.11, pathlib._NormalAccessor captures io.open (and os.* mutators)
into class attributes at pathlib import time. A C builtin captured there does not
bind on instance access, but a Python wrapper does: self shifts into the next
positional, so Path.open / Path.write_text raised 'open() argument mode must be
str, not PosixPath' once the guard had replaced io.open with a Python wrapper
before pathlib was imported. (3.12+ dropped the accessor, which is why it only
failed on the 3.10 CI leg.)

Import io and pathlib at the top of the guard, before any patching, so the
accessor captures the original builtins, and confine Path.open by wrapping the
public method directly (mode-aware) rather than relying on the io.open patch to
reach it. Direct io.open() writers are still guarded for the zipfile-based cases.
2026-07-09 12:53:36 +00:00
pre-commit-ci[bot]
f6a813f161 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-09 12:36:20 +00:00
danielhanchen
016de7790d Studio sandbox: enforce filesystem confinement at runtime, drop the static path resolver
The static filesystem write-confinement (the LOCAL/ESCAPE/UNKNOWN path resolver
_resolve_path / _resolve_path_call plus the _FS_* mutating-op inventory) was the
largest and most complex part of the classifier, and for writes it duplicated the
runtime realpath backstop, which is strictly more robust: it resolves the true
realpath at the syscall boundary, so it also catches dynamic paths, pre-existing
symlinks, and library writers the static pass could not prove.

Make the runtime backstop the single filesystem-write boundary and delete the
static resolver:

- Harden the backstop to close the gaps the static layer used to cover: guard the
  low-level os.open (any mutating flag confines the target; a mutating dir_fd fails
  closed) and io.open (which also carries pathlib.Path.open('w')), and add
  os.mknod / lchmod / lchown / chflags and shutil.chown / copymode / copystat to
  the wrapped set. Native-C writers (cv2.imwrite) and the realpath TOCTOU window
  remain documented residuals that only OS-level isolation can close.
- Remove _resolve_path, _resolve_path_call, _resolve_join, _classify_path_string,
  _is_pathlib_expr and the _FS_* / _PATHLIB_CTORS / _PATH_DEPTH_CAP constants, and
  the write half of the filesystem visitor plus the FS_READ_STRICT knob.
- Reads are not confined by the backstop, so keep a small static sensitive-read
  scanner (_is_sensitive_abs_path) that still blocks host-secret reads via a
  sensitive absolute / ~ literal in any call arg (covers open, os.open, and library
  loaders such as pandas.read_csv('/etc/passwd')) and .. / ~ traversal on the
  dedicated open/read callees.

Net: about 300 fewer lines in tools.py and one fewer concept to audit; static
analysis now scopes to exec, shell, network and sensitive-reads while writes are
confined at runtime. Rework the filesystem tests around the new contract and add
os.open / io.open / Path.open / dir_fd escape cases to the backstop suite.
2026-07-09 12:35:29 +00:00
danielhanchen
d369453ada Merge remote-tracking branch 'origin/main' into danielhanchen/harden-code-exec-sandbox 2026-07-09 12:22:31 +00:00
pre-commit-ci[bot]
8656ce2cf3 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-09 07:45:08 +00:00
danielhanchen
74b8f5393f Studio sandbox: block opaque executing-sink payloads, allow recovered literals
Tighten the eval/exec/compile dynamic policy so an executing sink (eval/exec/
runpy) applied to a payload that cannot be statically recovered is refused
unconditionally, not only when an RCE-core module happens to be imported in the
snippet. An un-analyzable executing payload can synthesize any shell, network,
or filesystem escape at runtime, so the prior in-scope-import heuristic left
exec(input()) and eval(user_var) allowed whenever no such import was present.
compile() of the same payload stays allowed since it does not run.

A payload that is fully recovered as a constant but is invalid Python for the
sink's mode (for example eval("data = 1") or eval("not python !!")) is now
allowed: its exact source is known and it raises SyntaxError at runtime, so it
is not an execution vector. Only genuinely opaque, non-recoverable payloads
(for example eval of a runtime-computed f-string) reach the block.

Remove the now-unused _RCE_CORE_MODULES set and _scope_imported_roots helper,
and move the opaque-f-string case in the tests to the blocked set.
2026-07-09 07:43:53 +00:00
danielhanchen
86555efc6c Studio sandbox: add runtime realpath backstop and block ln
Add ln to the bash command denylist so a symlink escape cannot be created from
the terminal tool. In the sandboxed (non-bypass) _python_exec path, prepend a
one-line guard to the generated temp module that monkeypatches only MUTATING file
ops (builtins.open in write/append/x/+ modes, os remove/unlink/rmdir/removedirs/
rename/renames/replace/truncate/chmod/chown/mkdir/makedirs/symlink/link, shutil
rmtree/move/copy/copy2/copyfile/copytree, pathlib write_text/write_bytes/unlink/
rename/replace/mkdir/rmdir/chmod/symlink_to/hardlink_to/touch) to resolve the true
os.path.realpath of the target and raise PermissionError unless it lands inside
the injected session workdir. Reads are left unpatched. The guard runs in its own
namespace so helper names never leak into user globals, and it is skipped
entirely under disable_sandbox. This catches what the static gate cannot prove:
pre-existing symlink escapes and dynamic library-writer paths that funnel through
builtins.open. Benign in-workdir relative writes and library imports are
unaffected (importlib swallows out-of-workdir bytecode-cache write failures).
2026-07-09 06:38:22 +00:00
danielhanchen
dca00ade1a Studio sandbox: catch single-assignment and inline-container sink aliases
Add pragmatic aliasing so an aliased shell sink with a dangerous argument is
caught: a name stored exactly once and bound to a resolved os/subprocess sink
(s = os.system; s('rm -rf /')) and inline literal-container indexing
([os.system][0](...), (os.system,)[0](...), {'k': os.system}['k'](...)) both feed
the existing _find_blocked_commands argument check. Resolution is deliberately
low-false-positive: only unambiguous single assignments and inline literal
containers, never a flow-insensitive union, so s = os.system; s = print; s('hi')
is not aliased. The shell-sink set is lifted to module scope (_SHELL_SINK_FUNCS)
so the alias pre-pass and the visitor share one definition. Interprocedural and
flow-sensitive taint remain out of scope (deferred to a full fixpoint).
2026-07-09 06:33:00 +00:00
danielhanchen
4ca35ec644 Studio sandbox: add first-class filesystem confinement
Add a filesystem_violations category backed by _resolve_path, a LOCAL / ESCAPE /
UNKNOWN classifier that constant-folds strings and understands os.path.join,
pathlib Path()/'/'/joinpath, and f-strings with real join plus absolute-reset
semantics. expanduser / expandvars / os.environ / getcwd / dynamic parts collapse
to UNKNOWN. A new _FilesystemPolicyVisitor inventories destructive and mutating
ops (open write/append/x/+, os remove/unlink/rmdir/rename/replace/truncate/chmod/
chown/mkdir/makedirs/mknod/symlink/link/chdir, shutil rmtree/move/copy*, pathlib
write_text/write_bytes/unlink/rename/replace/mkdir/rmdir/chmod/symlink_to/touch,
tempfile dir=, and a curated numpy/pandas/torch/joblib/PIL/matplotlib/cv2 writer
set) and applies prove-or-block: mutating LOCAL allows, UNKNOWN/ESCAPE blocks.
rename/move check src and dst; symlink/link check both target and link path;
chdir must be LOCAL; tempfile dir= must be LOCAL. Reads block only on a provable
escape (sensitive absolute path or ..'/~ traversal), with an FS_READ_STRICT knob
for prove-or-block reads. A callee-independent literal-sensitive-path scan blocks
loaders like pandas.read_csv('/etc/shadow'). Library writers block only on a
provable escape so in-memory buffers are not over-blocked; the Stage 5 runtime
backstop covers the dynamic residual.
2026-07-09 06:28:42 +00:00
danielhanchen
28a69d5ef7 Studio sandbox: unwrap eval/exec/compile instead of a blanket ban
Replace the blanket dynamic_exec block with a recursive payload analyzer gated by
UNSLOTH_STUDIO_SINK_ANALYZER (default on; =0 restores the legacy ban). For eval /
exec / compile (and single-assignment aliases like e = exec), constant-fold the
first argument; a recovered source string is bracket-depth pre-scanned, size and
recursion-depth bounded, then re-classified through the full analyzer. An inner
sink blocks and surfaces the inner reason; a clean inner payload allows; a bound
or budget breach fails closed. Non-foldable payloads follow a low-false-positive
dynamic policy: block when assembled from decode / fetch / runtime-assembly
primitives (including nested exec and large string repetition) or when an
RCE-core module is imported in scope, else allow.

Keep the gadget-dunder and dynamic-import blocks but constant-fold import names
so __import__('hugging'+'face_hub') resolves to a real module. Refine getattr /
setattr on a sensitive module so a benign constant attribute (getattr(os,
'getpid')) is allowed while a dynamic or dangerous constant attribute blocks. Add
pickle/marshal/dill.loads as unverifiable code-deserialization sinks. eval('2+2'),
compile('a+b','<s>','eval') and ast.literal_eval now pass; base64/hex/rot13/chr
and gadget-obfuscated escapes still block. Wire a filesystem_violations category
through is_safe, the info dict, and the reason assembly (populated in a later
stage). The three legacy tests that asserted the blanket ban are updated to the
new recurse-the-payload behavior.
2026-07-09 06:21:42 +00:00
danielhanchen
eff637715e Studio sandbox: add pure constant folder for static safety analysis
Introduce _const_fold, a whitelist-only, bounded, side-effect-free partial
evaluator plus a single-assignment const-prop environment builder. It recomputes
pure transforms on literals only (concat, repeat, join, format, f-strings,
slice/reverse, chr/ord, base64/hex/rot13/zlib decode, pure builtins and string
methods) and never executes, imports, or reflects on user code. Depth, op, size,
and sequence caps guarantee it can only fail to recover a value, never crash or
hang. This is the foundation the later eval/exec unwrapping and filesystem path
resolver build on.
2026-07-09 06:11:31 +00:00
danielhanchen
006bf4479e Harden Studio Python-tool AST checks against obfuscated escapes
The python tool's static safety analysis (_check_signal_escape_patterns) was
purely name-based with no attribute visitor, so obfuscated routes to the shell
/ network / file policies it already enforces slipped past: eval / exec /
compile, __import__ / importlib with a computed or dangerous module name,
getattr / setattr aimed at os / subprocess / sys / builtins, and dunder gadget
chains (().__class__.__bases__[0].__subclasses__()).

Add a dynamic_exec category covering those, surfaced through _check_code_safety
alongside the existing categories. Dynamic import stays allowed for a benign
literal module name (huggingface_hub, json, numpy) so real workflows and the HF
upload gate keep working; ordinary getattr(obj, "field") and __class__ access
stay benign. Bypass Permissions (disable_sandbox) still skips the check.

Tests: TestDynamicExecObfuscation in test_sandbox_tools.py with matching benign
cases, giving _check_signal_escape_patterns its first direct coverage.
2026-07-08 11:10:55 +00:00
1278 changed files with 41011 additions and 222386 deletions

2
.gitattributes vendored
View file

@ -6,7 +6,7 @@
# them when run in WSL/Linux (e.g. `set -e` -> "set: Illegal option -").
*.sh text eol=lf
# Normalize Unsloth frontend sources to LF. Scoped to the frontend tree (rather
# Normalize Studio frontend sources to LF. Scoped to the frontend tree (rather
# than repo-wide *.ts/*.tsx/... rules) so the policy can't force LF on files
# elsewhere. text=auto lets Git detect and leave binary assets (logos, fonts)
# untouched while text files (.ts/.tsx/.json/.html/.svg/...) are stored as LF.

View file

@ -36,23 +36,6 @@ AGENT="${2:?usage: agent-guides-drive.sh <mode> <agent>}"
# Determinism (seed/temp) is applied at the server level by
# serve-unsloth-run.sh --extra; agents inherit it through the API.
TIMEOUT="${AGENT_INVOKE_TIMEOUT:-180}"
# opencode is the slow outlier. Unlike the print-mode agents (claude -p, codex
# exec) it runs a full turn AND a separate small_model call to name the session,
# so one connection reply takes ~8 min on a CPU-served 4B -- right at the shared
# 600s cap, so the cell flaked when a run drifted past a ~480s success. Give it
# headroom (still well under the 40-min job budget); the fast agents keep the
# tight cap that still catches a real headless-TTY hang.
case "$AGENT" in
opencode)
# Double it, but only for a bare-integer seconds value. A GNU timeout(1)
# duration suffix (s/m/h/d, including floats like 0.5s) is left unchanged so
# the arithmetic never sees a non-number; timeout(1) parses it directly.
case "$TIMEOUT" in
*[!0-9]*) ;;
*) TIMEOUT=$(( TIMEOUT * 2 )) ;;
esac
;;
esac
# Claude refuses --dangerously-skip-permissions outside a sandbox; the CI runner
# IS the sandbox, so declare it (mirrors unslothai/scripts launcher.sh). Harmless
@ -183,8 +166,8 @@ parse_connect() {
echo "[$AGENT] connect --no-launch printed:"; cat_redacted "$raw"
CONNECT_ENV="$(grep -E '^(export |unset )' "$raw" || true)"
# The launch command is the last non-export, non-status line. start.py
# prints "Unsloth <url> · model <id>" and "Updated ..." status lines first.
CONNECT_CMD="$(grep -vE '^(export |unset |Unsloth |Updated |Disabled |Warning|Loading)' "$raw" \
# prints "Studio <url> · model <id>" and "Updated ..." status lines first.
CONNECT_CMD="$(grep -vE '^(export |unset |Studio |Updated |Disabled |Warning|Loading)' "$raw" \
| grep -E '[^[:space:]]' | tail -1)"
[ -n "$CONNECT_CMD" ] || guide_fail "could not parse a launch command from connect --no-launch output"
redact "$raw"

View file

@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Assert Unsloth installed a llama.cpp that loads and runs on THIS macOS. Tests
# Assert Studio installed a llama.cpp that loads and runs on THIS macOS. Tests
# the contract that matters (binaries load and their minimum-OS is <= this host)
# instead of the old "did install.sh fall back to a source build?" grep, since a
# source build with a correct deployment target is a valid outcome.

View file

@ -31,7 +31,7 @@
# (llama_cpp.py:337-340). So default: ~/.unsloth/studio/logs/llama-server/.
#
# <P> is the INTERNAL llama-server port (self._find_free_port(),
# llama_cpp.py:3489 / :4641) -- a RANDOM port, NOT the Unsloth port. So we must
# llama_cpp.py:3489 / :4641) -- a RANDOM port, NOT the Studio port. So we must
# NOT filter the log glob by STUDIO_PORT (the brief's `port-<STUDIO_PORT>`
# glob would never match). We pick the newest llama-*.log instead.
#

View file

@ -3,7 +3,7 @@
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
#
# Download a single file from a Hugging Face repo with a stall-retry
# watchdog. Used by the Unsloth CI workflows so a hung hf-xet transfer
# watchdog. Used by the Studio CI workflows so a hung hf-xet transfer
# kills + retries instead of silently consuming the job's timeout.
#
# Usage: hf-download-with-retry.sh REPO FILE LOCAL_DIR
@ -35,7 +35,7 @@ REPO="${1:?usage: hf-download-with-retry.sh REPO FILE [LOCAL_DIR]}"
FILE="${2:?usage: hf-download-with-retry.sh REPO FILE [LOCAL_DIR]}"
# LOCAL_DIR is optional. If empty, hf falls back to HF_HUB_CACHE
# (~/.cache/huggingface/hub) which is the desired path for callers
# that populate HF_HOME for a downstream Unsloth model load.
# that populate HF_HOME for a downstream Studio model load.
LOCAL_DIR="${3:-}"
# Stall threshold per attempt, in seconds. Override with

View file

@ -1,70 +0,0 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
set -euo pipefail
port="${1:?usage: $0 PORT BROWSER [CHANNEL]}"
browser="${2:?usage: $0 PORT BROWSER [CHANNEL]}"
channel="${3:-}"
slug="$browser${channel:+-$channel}"
artifact_dir="logs/playwright-permissions-$slug"
server_log="logs/studio-permissions-$slug.log"
studio_home="${UNSLOTH_STUDIO_HOME:-$HOME/.unsloth/studio}"
set --
if [ -n "${STUDIO_PERMISSION_FRONTEND:-}" ]; then
set -- -f "$STUDIO_PERMISSION_FRONTEND"
fi
mkdir -p "$artifact_dir"
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf "$studio_home/auth"
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$port" "$@" \
>"$server_log" 2>&1 &
studio_pid=$!
cleanup() {
kill "$studio_pid" 2>/dev/null || true
wait "$studio_pid" 2>/dev/null || true
}
trap cleanup EXIT
healthy=0
for _ in $(seq 1 180); do
if curl -fs "http://127.0.0.1:$port/api/health" >/dev/null; then
healthy=1
break
fi
if ! kill -0 "$studio_pid" 2>/dev/null; then
tail -100 "$server_log" || true
exit 1
fi
sleep 1
done
if [ "$healthy" -ne 1 ]; then
tail -100 "$server_log" || true
exit 1
fi
old_password=$(cat "$studio_home/auth/.bootstrap_password")
new_password="CIPerm-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
echo "::add-mask::$old_password"
echo "::add-mask::$new_password"
fi
export BASE_URL="http://127.0.0.1:$port"
export STUDIO_OLD_PW="$old_password"
export STUDIO_NEW_PW="$new_password"
export STUDIO_UI_STRICT=1
export STUDIO_UI_PERMISSION_ONLY=1
export STUDIO_UI_WALL_TIMEOUT_S=240
export STUDIO_PLAYWRIGHT_BROWSER="$browser"
export PW_ART_DIR="$artifact_dir"
if [ -n "$channel" ]; then
export STUDIO_PLAYWRIGHT_CHANNEL="$channel"
else
unset STUDIO_PLAYWRIGHT_CHANNEL || true
fi
python tests/studio/playwright_chat_ui.py

View file

@ -7,7 +7,7 @@
#
# Why a separate workflow:
# - studio-backend-ci.yml's "Repo tests (CPU)" job already auto-discovers
# tests/ minus tests/qlora, tests/saving, tests/utils, tests/sh. The 17
# tests/ minus tests/qlora, tests/saving, tests/utils, tests/sh. The 16
# Bucket-A tests below live inside those --ignore dirs (CPU-runnable but
# historically excluded with their GPU siblings); pulling them out into
# a sibling job keeps the existing 760-passed baseline stable while we
@ -268,13 +268,10 @@ jobs:
tests/saving/test_save_shell_injection.py \
tests/saving/test_patch_saving_none_tokenizer.py \
tests/saving/test_fix_sentencepiece_gguf_robustness.py \
tests/saving/test_fix_sentencepiece_tokenizer_guard.py \
tests/saving/test_compressed_export_schemes.py \
tests/saving/test_export_api_surface.py \
tests/saving/test_export_dispatch.py \
tests/saving/test_imatrix_export.py \
tests/saving/test_gguf_single_pass_export.py \
tests/saving/test_offline_gguf_vlm_tokenizer_7481.py \
tests/utils/test_attention_masks.py \
tests/utils/test_trunc_normal_patch.py \
tests/python/test_fast_language_model_text_only.py
@ -360,23 +357,21 @@ jobs:
tests/saving/test_save_shell_injection.py \
tests/saving/test_patch_saving_none_tokenizer.py \
tests/saving/test_fix_sentencepiece_gguf_robustness.py \
tests/saving/test_fix_sentencepiece_tokenizer_guard.py \
tests/saving/test_compressed_export_schemes.py \
tests/saving/test_export_api_surface.py \
tests/saving/test_export_dispatch.py \
tests/saving/test_imatrix_export.py \
tests/saving/test_gguf_single_pass_export.py \
tests/saving/test_offline_gguf_vlm_tokenizer_7481.py \
tests/utils/test_attention_masks.py \
tests/utils/test_trunc_normal_patch.py \
tests/python/test_fast_language_model_text_only.py \
tests/test_bad_mappings_redirect.py \
tests/test_prefetch_snapshot_scope.py \
tests/test_gemma_2b_mapper_key.py \
tests/test_raw_text_json_loading.py
# test_run_attention_flash_varlen_receives_window_and_softcap was deselected
# until attention_dispatch.py predefined flash_attn_varlen_func as None; it
# monkeypatches that name, so it no longer needs flash_attn on this runner.
--deselect 'tests/utils/test_attention_masks.py::test_run_attention_flash_varlen_receives_window_and_softcap'
# The deselected test monkeypatches flash_attn_varlen_func, which is
# only bound on the module when `flash_attn` is importable. flash_attn
# requires CUDA + dev toolchain, which the CPU-only ubuntu-latest
# runner does not have. The other Bucket-A tests pass cleanly.
- name: unsloth_zoo @ ${{ env.UNSLOTH_ZOO_REF }} — full pytest (CPU)
# 106 of 111 test_* in unsloth_zoo are CPU-only. The two CUDA-skip
@ -2130,7 +2125,7 @@ jobs:
pip show unsloth_zoo
echo "::endgroup::"
echo "Consolidated job done. Coverage:"
echo " - 17 unsloth Bucket-A tests under tests/saving/ + tests/utils/"
echo " - 16 unsloth Bucket-A tests under tests/saving/ + tests/utils/"
echo " - unsloth_zoo @ ${UNSLOTH_ZOO_REF} pytest tests/ (5 GPU cases deselected)"
echo " - unsloth_zoo.compiler.test_apply_fused_lm_head"

View file

@ -1,16 +1,18 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Runs installer parity and autostart opt-out tests across all three platforms.
# Runs tests/python/test_cross_platform_parity.py on Windows and macOS.
#
# Why: the parity test guards that install.sh and install.ps1 stay in sync.
# It originally ran only on ubuntu-latest through studio-backend-ci.yml.
# On Windows, Path.read_text() defaults to the cp1252 locale encoding, so a
# non-cp1252 byte in install.sh raises UnicodeDecodeError even though Linux
# and macOS default to UTF-8. The reads were pinned to encoding="utf-8" in
# #6166; this matrix keeps that from silently regressing. Pure pytest, no GPU,
# sub-second, so the matrix is cheap. Linux also runs the POSIX rollback test
# under dash, matching the supported curl-to-sh installer path.
# Why: that test is the guard that install.sh and install.ps1 stay in
# sync, but today it only runs on ubuntu-latest (auto-discovered by
# studio-backend-ci.yml's "Repo tests (CPU)" job). The test reads both
# installer scripts, and on Windows Path.read_text() defaults to the
# cp1252 locale encoding, so a non-cp1252 byte in install.sh (it already
# contains a U+274C) raises UnicodeDecodeError there even though Linux and
# macOS default to UTF-8. The reads were pinned to encoding="utf-8" in
# #6166; this job keeps that from silently regressing by exercising the
# test on the platforms it claims parity for. Pure pytest, no GPU,
# sub-second, so the matrix is cheap.
name: Cross-platform parity
@ -19,20 +21,14 @@ on:
paths:
- 'install.sh'
- 'install.ps1'
- 'tests/test_installer_skip_autostart.py'
- 'tests/python/test_cross_platform_parity.py'
- 'tests/sh/test_install_rollback_lifecycle.sh'
- 'tests/studio/test_install_rollback_lifecycle.ps1'
- '.github/workflows/cross-platform-parity-ci.yml'
push:
branches: [main]
paths:
- 'install.sh'
- 'install.ps1'
- 'tests/test_installer_skip_autostart.py'
- 'tests/python/test_cross_platform_parity.py'
- 'tests/sh/test_install_rollback_lifecycle.sh'
- 'tests/studio/test_install_rollback_lifecycle.ps1'
- '.github/workflows/cross-platform-parity-ci.yml'
workflow_dispatch:
@ -49,7 +45,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
os: [windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 10
steps:
@ -61,18 +57,5 @@ jobs:
python-version: '3.12'
cache: 'pip'
- run: python -m pip install -U pip pytest
- name: Cross-platform parity tests
env:
UNSLOTH_NO_TORCH: '1'
run: >-
python -m pytest
tests/python/test_cross_platform_parity.py
tests/test_installer_skip_autostart.py
-q
- name: PowerShell rollback lifecycle tests
if: runner.os == 'Windows'
shell: pwsh
run: pwsh -NoProfile -File tests/studio/test_install_rollback_lifecycle.ps1
- name: POSIX rollback lifecycle tests
if: runner.os == 'Linux'
run: sh tests/sh/test_install_rollback_lifecycle.sh
- name: Cross-platform parity test
run: python -m pytest tests/python/test_cross_platform_parity.py -q

View file

@ -13,10 +13,10 @@
# committed YAML / JSON config.
#
# TypeScript and Rust are NOT duplicated here on purpose:
# - Unsloth Frontend CI runs `npm run typecheck` (= `tsc --noEmit`)
# - Studio Frontend CI runs `npm run typecheck` (= `tsc --noEmit`)
# and `npm run build` (vite/swc) on every studio/frontend/**
# change, which is a full TS AST + type check.
# - Unsloth Tauri CI runs `tauri build --debug --no-bundle` on
# - Studio Tauri CI runs `tauri build --debug --no-bundle` on
# every studio/src-tauri/** or studio/frontend/** change, which
# compiles the Rust crate (= cargo check + cargo build).
# Each is a stricter check than a parse-only step would be, so a

View file

@ -154,7 +154,7 @@ jobs:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Gated off PR (see note above); public GGUF still downloads.
@ -167,9 +167,7 @@ jobs:
# ── boot the server under test (factored helper) ──────────────────
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
run: |
# Wipe, not reset-password: since #7573 the reset rotates in place and
# prints the new passphrase, which would land unmasked in the job log.
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
bash .github/scripts/serve-unsloth-run.sh \
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
--port "$STUDIO_PORT" --log-dir logs \
@ -258,7 +256,7 @@ jobs:
done
fi
- name: Stop Unsloth
- name: Stop Studio
if: always()
run: |
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
@ -361,7 +359,7 @@ jobs:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Gated off PR (see note above); public GGUF still downloads.
@ -373,7 +371,7 @@ jobs:
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
run: |
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
bash .github/scripts/serve-unsloth-run.sh \
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
--port "$STUDIO_PORT" --log-dir logs \
@ -450,7 +448,7 @@ jobs:
done
fi
- name: Stop Unsloth
- name: Stop Studio
if: always()
run: |
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make
@ -545,7 +543,7 @@ jobs:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
@ -556,7 +554,7 @@ jobs:
- name: Serve unsloth run --disable-tools (gemma-4-E4B)
run: |
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
bash .github/scripts/serve-unsloth-run.sh \
--gguf-file "$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}" \
--port "$STUDIO_PORT" --log-dir logs \
@ -622,7 +620,7 @@ jobs:
done
fi
- name: Stop Unsloth
- name: Stop Studio
if: always()
run: |
if [ -n "${UNSLOTH_SERVER_PID:-}" ] && [ "${UNSLOTH_SERVER_PID}" != "0" ]; then
@ -708,7 +706,7 @@ jobs:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Gated off PR (see note above); public GGUF still downloads.
@ -720,7 +718,7 @@ jobs:
- name: Serve unsloth run --disable-tools (gemma-3-270m)
run: |
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
bash .github/scripts/serve-unsloth-run.sh \
--model "$GGUF_REPO" --gguf-variant "$GGUF_VARIANT" \
--port "$STUDIO_PORT" --log-dir logs \
@ -766,7 +764,7 @@ jobs:
done
fi
- name: Stop Unsloth
- name: Stop Studio
if: always()
run: |
# Guard the PID: an unset/zero UNSLOTH_SERVER_PID would make

View file

@ -130,7 +130,7 @@ jobs:
# MLX support landed after the most recent unsloth-zoo PyPI
# release; the wheel still raises NotImplementedError on
# Apple Silicon when device_type.get_device_type() runs
# unguarded. Unsloth's own install.sh overlays unsloth-zoo
# unguarded. Studio's own install.sh overlays unsloth-zoo
# from git main for the same reason. Pulling deps lets pip
# resolve the platform-conditional MLX-only wheels (mlx,
# mlx-lm, mlx-vlm gated on darwin+arm64 in unsloth-zoo's
@ -317,13 +317,13 @@ jobs:
echo
done
# Validates the macOS prebuilt path Unsloth's setup.sh uses (#5963): install the
# Validates the macOS prebuilt path Studio's setup.sh uses (#5963): install the
# unslothai/llama.cpp fork's latest release, download a small public GGUF, and
# check llama-server /completion end to end. Split and placed last so the
# untrusted binary runs only in the final smoke step, after every HF_TOKEN step,
# leaving no token-bearing step or shared workspace for a tampered prebuilt to
# corrupt. GH_TOKEN: releases API; HF_TOKEN (withheld on PR): probe + GGUF fetch.
- name: Unsloth prebuilt llama.cpp install + GGUF download (Mac M1)
- name: Studio prebuilt llama.cpp install + GGUF download (Mac M1)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -344,12 +344,12 @@ jobs:
# Final step: runs the downloaded binaries with no secrets present, and clears
# the GitHub Actions command files so a tampered prebuilt cannot influence the job.
- name: Unsloth prebuilt llama.cpp GGUF inference smoke (Mac M1)
- name: Studio prebuilt llama.cpp GGUF inference smoke (Mac M1)
run: |
set -euo pipefail
unset GITHUB_ENV GITHUB_PATH GITHUB_OUTPUT GITHUB_STEP_SUMMARY
INSTALL_DIR="$HOME/.unsloth-studio-prebuilt-test/llama.cpp"
# Unsloth bundles only llama-server + llama-quantize (not llama-cli);
# Studio bundles only llama-server + llama-quantize (not llama-cli);
# inference goes through llama-server's HTTP /completion endpoint.
LLAMA_SERVER="$INSTALL_DIR/build/bin/llama-server"
LLAMA_QUANT="$INSTALL_DIR/build/bin/llama-quantize"
@ -400,4 +400,4 @@ jobs:
tail -40 /tmp/llama-server.log
exit 1
fi
echo "OK: Unsloth prebuilt llama.cpp on Mac M1 + GGUF /completion works"
echo "OK: Studio prebuilt llama.cpp on Mac M1 + GGUF /completion works"

View file

@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
studio_version:
description: 'Unsloth version tag to release (for example, v0.1.39-beta)'
description: 'Studio version tag to release (for example, v0.1.39-beta)'
type: string
required: true
pypi_version:
@ -19,19 +19,6 @@ on:
permissions:
contents: read
env:
DESKTOP_RELEASE_NOTES: |
Desktop app for Unsloth Studio.
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
concurrency:
group: release-desktop-${{ github.repository }}
cancel-in-progress: false
@ -69,7 +56,7 @@ jobs:
if not studio_version:
sys.exit('studio_version is required, for example v0.1.39-beta')
if re.fullmatch(r'v?20\d{2}\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?', studio_version):
sys.exit(f'studio_version must be an Unsloth SemVer tag, not a date-style backend version: {studio_version}')
sys.exit(f'studio_version must be a Studio SemVer tag, not a date-style backend version: {studio_version}')
semver_tag = re.compile(
r'^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'
@ -146,7 +133,7 @@ jobs:
print(f'pypi_version={pypi_version}', file=output)
PY
- name: Verify PyPI package and Unsloth stamp
- name: Verify PyPI package and Studio stamp
shell: bash
env:
STUDIO_VERSION: ${{ steps.prepare.outputs.studio_version }}
@ -211,7 +198,7 @@ jobs:
fi
python3 scripts/stamp_studio_release.py --verify-dist "$RUNNER_TEMP/pypi-unsloth-dist" --expected "$STUDIO_VERSION"
else
echo "scripts/stamp_studio_release.py not found; release-desktop requires #5308 to verify the PyPI Unsloth stamp." >&2
echo "scripts/stamp_studio_release.py not found; release-desktop requires #5308 to verify the PyPI Studio stamp." >&2
exit 1
fi
@ -308,6 +295,14 @@ jobs:
PY
build:
# TODO: split into a "build (no secrets)" + "publish (secrets)" job pair
# with actions/upload-artifact handoff so the matrix build cannot
# publish a Release on its own. The current matrix runs across
# Linux/macOS/Windows in a single job, so the split needs artefact
# collection across the OS matrix and is out of scope for this
# hardening pass.
permissions:
contents: write # tauri-apps/tauri-action creates / uploads a GitHub Release
strategy:
fail-fast: false
max-parallel: 1
@ -316,21 +311,15 @@ jobs:
- platform: macos-latest
args: '--target aarch64-apple-darwin'
label: macOS (Apple Silicon)
artifact: macos-aarch64
release_arch: aarch64
# - platform: macos-latest
# args: '--target x86_64-apple-darwin'
# label: macOS (Intel)
- platform: ubuntu-22.04
args: ''
label: Linux (x64)
artifact: linux-x64
release_arch: x64
- platform: windows-latest
args: ''
label: Windows (x64)
artifact: windows-x64
release_arch: x64
name: Build ${{ matrix.label }}
needs: prepare-version
@ -476,18 +465,41 @@ jobs:
if (chmodIdx !== -1 && sha256Idx > chmodIdx) {
throw new Error('Desktop Linux release must verify the linuxdeploy digest before chmod +x');
}
const releaseBody = process.env.DESKTOP_RELEASE_NOTES;
if (!releaseBody) {
throw new Error('DESKTOP_RELEASE_NOTES must not be empty');
const releaseBodies = [];
for (let i = 0; i < lines.length; i += 1) {
const match = lines[i].match(/^(\s*)releaseBody:\s*\|\s*$/);
if (!match) continue;
const baseIndent = match[1].length;
const bodyLines = [];
i += 1;
for (; i < lines.length; i += 1) {
const line = lines[i];
if (line.trim() === '') {
bodyLines.push('');
continue;
}
const indent = line.match(/^\s*/)[0].length;
if (indent <= baseIndent) {
i -= 1;
break;
}
bodyLines.push(line.slice(baseIndent + 2));
}
releaseBodies.push(bodyLines.join('\n'));
}
if (/\brpm\b|\.rpm/i.test(releaseBody)) {
throw new Error('Desktop release body must not advertise RPM packages');
if (releaseBodies.length === 0) {
throw new Error('Expected at least one desktop release body');
}
if (/AppImage.*universal|universal.*AppImage/i.test(releaseBody)) {
throw new Error('Desktop release body must not advertise AppImage as universal');
}
if (!/AppImage.*experimental/i.test(releaseBody)) {
throw new Error('Desktop release body must mark AppImage as experimental');
for (const body of releaseBodies) {
if (/\brpm\b|\.rpm/i.test(body)) {
throw new Error('Desktop release body must not advertise RPM packages');
}
if (/AppImage.*universal|universal.*AppImage/i.test(body)) {
throw new Error('Desktop release body must not advertise AppImage as universal');
}
if (!/AppImage.*experimental/i.test(body)) {
throw new Error('Desktop release body must mark AppImage as experimental');
}
}
JS
@ -632,33 +644,48 @@ jobs:
dest="$tools_dir/linuxdeploy-x86_64.AppImage"
curl -fsSL "$LINUXDEPLOY_URL" -o "$dest"
# Verify the digest BEFORE the binary is ever marked executable. The
# next step builds the AppImage with the Tauri signing key, so a
# substituted linuxdeploy that ran here could exfiltrate signing
# material or tamper with release artifacts. Fail closed on any
# mismatch.
# next step builds the AppImage with the Tauri signing key and a
# contents:write GITHUB_TOKEN in scope, so a substituted linuxdeploy
# that ran here could exfiltrate signing material or tamper with
# published release artifacts. Fail closed on any mismatch.
echo "${LINUXDEPLOY_SHA256} ${dest}" | sha256sum -c -
chmod +x "$dest"
# ── Linux: build + sign ──
# ── Linux: build + sign + upload ──
- name: Build Linux app
id: build_linux
if: matrix.platform == 'ubuntu-22.04'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
XDG_CACHE_HOME: ${{ runner.temp }}/tauri-tools-cache
with:
projectPath: studio
tauriScript: npx --prefix . tauri
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
releaseBody: |
Desktop app for Unsloth Studio.
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }}
# ── macOS: build + sign + notarize ──
# ── macOS: build + sign + notarize + upload ──
- name: Build macOS app
id: build_macos
if: matrix.platform == 'macos-latest'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
@ -668,14 +695,29 @@ jobs:
with:
projectPath: studio
tauriScript: npx --prefix . tauri
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
releaseBody: |
Desktop app for Unsloth Studio.
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }}
# ── Windows: build + sign ──
# ── Windows: build + sign + upload ──
- name: Build Windows app
id: build_windows
if: matrix.platform == 'windows-latest'
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@ -686,252 +728,44 @@ jobs:
with:
projectPath: studio
tauriScript: npx --prefix . tauri
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
releaseBody: |
Desktop app for Unsloth Studio.
**macOS**: Download the Apple Silicon `.dmg`.
**Windows**: Download the `-setup.exe` installer.
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
releaseDraft: ${{ inputs.draft }}
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
args: -v ${{ matrix.args }}
- name: Stage release assets
shell: bash
env:
ARTIFACT_PATHS: ${{ steps.build_linux.outputs.artifactPaths || steps.build_macos.outputs.artifactPaths || steps.build_windows.outputs.artifactPaths }}
RELEASE_ARCH: ${{ matrix.release_arch }}
run: |
set -euo pipefail
if command -v python3 >/dev/null 2>&1; then
PYTHON=python3
else
PYTHON=python
fi
"$PYTHON" <<'PY'
import json
import os
import pathlib
import re
import shutil
import sys
import unicodedata
raw_paths = os.environ.get('ARTIFACT_PATHS', '')
try:
artifact_paths = json.loads(raw_paths)
except json.JSONDecodeError as error:
sys.exit(f'Invalid tauri-action artifactPaths output: {error}')
if not isinstance(artifact_paths, list) or not artifact_paths:
sys.exit('tauri-action did not return any release artifacts')
destination = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
destination.mkdir(parents=True, exist_ok=True)
staged = []
for raw_path in artifact_paths:
source = pathlib.Path(raw_path)
if not source.is_file():
continue
name = source.name
for extension in ('.app.tar.gz.sig', '.app.tar.gz'):
if name.endswith(extension):
name = f'{name[:-len(extension)]}_{os.environ["RELEASE_ARCH"]}{extension}'
break
name = unicodedata.normalize('NFD', name)
name = ''.join(character for character in name if not unicodedata.combining(character))
name = re.sub(r'[ ()\[\]{}]', '.', name)
while '..' in name:
name = name.replace('..', '.')
target = destination / name
if target.exists():
sys.exit(f'Duplicate staged release asset name: {name}')
shutil.copy2(source, target)
staged.append(name)
if not staged:
sys.exit('No release files were staged')
print('Staged release assets:')
print('\n'.join(sorted(staged)))
PY
- name: Upload signed release assets
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-release-${{ matrix.artifact }}
path: ${{ runner.temp }}/desktop-release-assets/*
if-no-files-found: error
compression-level: 0
retention-days: 1
# Only this job gets write access; builds hand off signed files via artifacts.
# Draft runs do not advance the public desktop-latest channel.
publish-release:
name: Publish desktop release
# Release process note: only non-draft workflow runs advance the public
# desktop-latest updater channel. Draft builds are for private review; if a
# draft is manually published later, this channel intentionally remains
# unchanged until a narrow manual channel-publish flow is added or a public
# desktop release is created by running this workflow with draft=false.
publish-updater-channel:
name: Publish desktop updater channel
needs: [prepare-version, build]
if: ${{ !inputs.draft }}
runs-on: ubuntu-latest
permissions:
contents: write # create the versioned Release and replace updater-channel metadata
contents: write
env:
GH_REPO: ${{ github.repository }}
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
PYPI_VERSION: ${{ needs.prepare-version.outputs.pypi_version }}
STUDIO_VERSION: ${{ needs.prepare-version.outputs.studio_version }}
DESKTOP_RELEASE_TAG: ${{ needs.prepare-version.outputs.desktop_release_tag }}
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
steps:
- name: Harden runner (audit)
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
egress-policy: audit
- name: Download signed release assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: desktop-release-*
path: ${{ runner.temp }}/desktop-release-assets
merge-multiple: true
- name: Validate release asset set
shell: bash
run: |
set -euo pipefail
python3 <<'PY'
import pathlib
import os
import sys
asset_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
files = [path for path in asset_dir.iterdir() if path.is_file()]
required_suffixes = (
'.dmg',
'.app.tar.gz',
'.app.tar.gz.sig',
'.deb',
'.AppImage',
'.AppImage.sig',
'-setup.exe',
'-setup.exe.sig',
)
for suffix in required_suffixes:
matches = [path for path in files if path.name.endswith(suffix)]
if len(matches) != 1:
sys.exit(f'Expected exactly one {suffix} release asset, found {len(matches)}')
if any(path.name == 'latest.json' for path in files):
sys.exit('Build artifacts must not supply latest.json')
print('\n'.join(sorted(path.name for path in files)))
PY
- name: Create or validate versioned release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_DRAFT: ${{ inputs.draft }}
run: |
set -euo pipefail
notes_file="$RUNNER_TEMP/desktop-release-notes.md"
printf '%s\n' "$DESKTOP_RELEASE_NOTES" > "$notes_file"
release_json="$RUNNER_TEMP/versioned-release.json"
# REST tag lookup omits drafts; `gh release view` also checks pending tags.
if gh release view "$DESKTOP_RELEASE_TAG" \
--json tagName,isDraft,isPrerelease > "$release_json" 2>/dev/null; then
python3 <<'PY'
import json
import os
import pathlib
import sys
release = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'versioned-release.json').read_text())
expected_draft = os.environ['RELEASE_DRAFT'].lower() == 'true'
expected_prerelease = os.environ['DESKTOP_PRERELEASE'].lower() == 'true'
if release.get('tagName') != os.environ['DESKTOP_RELEASE_TAG']:
sys.exit('Existing desktop release tag does not match the requested tag')
if bool(release.get('isDraft')) != expected_draft:
sys.exit('Existing desktop release draft state does not match the workflow input')
if bool(release.get('isPrerelease')) != expected_prerelease:
sys.exit('Existing desktop release prerelease state does not match the requested version')
PY
else
release_flags=(
--title "Unsloth Studio (Desktop) ${STUDIO_VERSION}"
--notes-file "$notes_file"
--target "$GITHUB_SHA"
)
if [ "$RELEASE_DRAFT" = "true" ]; then
release_flags+=(--draft)
fi
if [ "$DESKTOP_PRERELEASE" = "true" ]; then
release_flags+=(--prerelease)
fi
gh release create "$DESKTOP_RELEASE_TAG" "${release_flags[@]}"
fi
- name: Publish versioned release assets
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release upload "$DESKTOP_RELEASE_TAG" "$RUNNER_TEMP/desktop-release-assets"/* --clobber
- name: Generate and publish versioned updater metadata
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
python3 <<'PY'
import datetime
import json
import os
import pathlib
import sys
import urllib.parse
asset_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
files = [path for path in asset_dir.iterdir() if path.is_file()]
def exactly_one(suffix: str) -> pathlib.Path:
matches = [path for path in files if path.name.endswith(suffix)]
if len(matches) != 1:
sys.exit(f'Expected exactly one {suffix} updater asset, found {len(matches)}')
return matches[0]
def entry(signature_suffix: str) -> dict[str, str]:
signature_path = exactly_one(signature_suffix)
bundle_name = signature_path.name.removesuffix('.sig')
bundle_path = asset_dir / bundle_name
if not bundle_path.is_file():
sys.exit(f'Missing updater bundle for {signature_path.name}: {bundle_name}')
encoded_tag = urllib.parse.quote(os.environ['DESKTOP_RELEASE_TAG'], safe='')
encoded_name = urllib.parse.quote(bundle_name, safe='')
return {
'signature': signature_path.read_text(),
'url': (
f'https://github.com/{os.environ["GITHUB_REPOSITORY"]}/releases/download/'
f'{encoded_tag}/{encoded_name}'
),
}
darwin = entry('.app.tar.gz.sig')
linux = entry('.AppImage.sig')
windows = entry('.exe.sig')
notes = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-notes.md').read_text()
metadata = {
'version': os.environ['APP_VERSION'],
# App version is SemVer; CHANGELOG.md is keyed by the backend release.
'pypi_version': os.environ['PYPI_VERSION'],
'notes': notes,
'pub_date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
'platforms': {
'darwin-aarch64': darwin,
'darwin-aarch64-app': darwin,
'linux-x86_64': linux,
'linux-x86_64-appimage': linux,
'windows-x86_64': windows,
'windows-x86_64-nsis': windows,
},
}
output = pathlib.Path(os.environ['RUNNER_TEMP'], 'latest.json')
output.write_text(json.dumps(metadata, indent=2) + '\n')
PY
gh release upload "$DESKTOP_RELEASE_TAG" "$RUNNER_TEMP/latest.json" --clobber
- name: Download versioned updater metadata
if: ${{ !inputs.draft }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
@ -956,7 +790,6 @@ jobs:
test -s "$RUNNER_TEMP/desktop-updater/latest.json"
- name: Validate versioned updater metadata
if: ${{ !inputs.draft }}
shell: bash
run: |
python3 <<'PY'
@ -1016,7 +849,6 @@ jobs:
PY
- name: Ensure desktop updater channel release
if: ${{ !inputs.draft }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
@ -1049,7 +881,6 @@ jobs:
PY
- name: Prevent updater channel downgrade
if: ${{ !inputs.draft }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
@ -1140,7 +971,6 @@ jobs:
PY
- name: Publish desktop updater channel metadata
if: ${{ !inputs.draft }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}

View file

@ -36,8 +36,8 @@
# - unsloth `huggingfacenotorch` extras (the canonical install path
# for fine-tuning users; pulls transformers / peft / accelerate /
# trl / datasets / diffusers / sentence-transformers / etc.)
# - all six Unsloth backend requirements files
# - Unsloth frontend (npm) and Tauri shell (cargo)
# - all six Studio backend requirements files
# - Studio frontend (npm) and Tauri shell (cargo)
# Each Python step builds a filtered dep list from pyproject.toml +
# requirements/*.txt before auditing. We do NOT install any of these
# -- pip-audit resolves through PyPI metadata, scan_packages.py
@ -218,7 +218,7 @@ jobs:
# on the runner). A comment line is left in place so the
# skipped specs are obvious in the artifact.
# The `huggingface` extra is `huggingfacenotorch` plus torch /
# torchvision / triton, deliberately skipped: Unsloth backend
# torchvision / triton, deliberately skipped: Studio backend
# already pins a torch and the +cu* / +cpu local-version tags
# trip up the PyPI resolver in `-r` mode.
run: |
@ -253,7 +253,7 @@ jobs:
# `-r requirements.txt` resolves the requirements through pip's
# dependency resolver against PyPI metadata and audits the
# resolved tree without ever executing setup.py / install
# hooks. Way faster than installing the full Unsloth runtime
# hooks. Way faster than installing the full Studio runtime
# and -- critically -- safer: an attacker who has compromised
# a transitive dep cannot run code in this job.
#
@ -326,9 +326,9 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# npm: Unsloth frontend
# npm: Studio frontend
# ─────────────────────────────────────────────────────────────
- name: npm audit (Unsloth frontend)
- name: npm audit (Studio frontend)
# `npm audit` resolves the lockfile through the npmjs.com
# advisory DB. `--audit-level=high` filters the noise floor
# to only HIGH and CRITICAL. We do NOT pass --omit=dev: a
@ -342,7 +342,7 @@ jobs:
# Always also write the full JSON for grep-ability.
npm audit --json > ../../logs-npm-audit.json || true
{
echo "## npm audit (Unsloth frontend)"
echo "## npm audit (Studio frontend)"
echo
echo '```'
tail -200 ../../logs-npm-audit.txt
@ -350,9 +350,9 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
# ─────────────────────────────────────────────────────────────
# cargo: Unsloth Tauri shell
# cargo: Studio Tauri shell
# ─────────────────────────────────────────────────────────────
- name: cargo audit (Unsloth Tauri)
- name: cargo audit (Studio Tauri)
# `--deny warnings` would make the job fail on any advisory.
# Keep non-blocking initially; drop continue-on-error after
# the baseline closes.
@ -362,7 +362,7 @@ jobs:
set +e
cargo audit | tee ../../logs-cargo-audit.txt
{
echo "## cargo audit (Unsloth Tauri)"
echo "## cargo audit (Studio Tauri)"
echo
echo '```'
tail -200 ../../logs-cargo-audit.txt
@ -559,7 +559,7 @@ jobs:
# ─────────────────────────────────────────────────────────────
# CycloneDX SBOM. Lets downstream consumers audit what's
# actually shipped in unsloth wheels and the Unsloth backend
# actually shipped in unsloth wheels and the Studio backend
# runtime. Generates one JSON file per requirements input plus
# a combined SBOM keyed off pyproject.toml; uploads as a build
# artifact (and a future step can attest it via SLSA).
@ -740,7 +740,7 @@ jobs:
# `--with-deps` makes the scan transitive: every package the
# declared set resolves to gets fetched and pattern-scanned, not
# just the top-level pins. Resolving the full transitive closure
# of the unsloth + Unsloth dep tree downloads several hundred
# of the unsloth + Studio dep tree downloads several hundred
# archives, hence the longer timeout.
#
# Sharded across runners for wall-clock parallelism. Each shard
@ -749,7 +749,7 @@ jobs:
# composition tries to balance load:
# - hf-stack: pyproject extras + no-torch-runtime
# (~150 archives, transformers/peft/accelerate/...)
# - studio: FastAPI/Unsloth backend + overrides + extras-no-deps
# - studio: FastAPI/Studio backend + overrides + extras-no-deps
# (~150 archives, smaller scientific stack)
# - extras: the heavy openai-whisper / scikit-learn / librosa
# stack (~250 archives, dominant cost)
@ -964,7 +964,7 @@ jobs:
# documented at scripts/scan_npm_packages.py top-of-file. The
# script is stdlib-only so adding it does not increase the
# transitive supply-chain surface.
name: npm scan-packages (Unsloth frontend tarballs)
name: npm scan-packages (Studio frontend tarballs)
runs-on: ubuntu-latest
timeout-minutes: 30
needs: []
@ -1173,7 +1173,7 @@ jobs:
with:
python-version: '3.12'
- name: Install Unsloth frontend deps (--ignore-scripts)
- name: Install Studio frontend deps (--ignore-scripts)
# `npm audit signatures` requires node_modules to be populated.
# `--ignore-scripts` is mandatory: this is exactly the lever the
# new-install-script gate below protects against, and we must

View file

@ -1,156 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Measures where Studio's startup time goes, on each platform.
#
# Nothing recorded a number before: main.py logs "lifespan startup completed in X ms"
# and studio_test_kit polls /healthz, but both throw the elapsed time away. A first
# local run (Linux, warm cache, 18-core server) put `import main` at 5.7-6.6s BEFORE
# the server can bind, dominated by eager module-level imports pulled in by routes:
# torch ~1.9s self, unsloth_zoo ~0.8s, routes ~0.6s, transformers ~0.5s.
#
# Not a gate yet: --max-healthz-seconds exists, but a budget should come from
# observed numbers rather than a guess.
name: Startup profile
on:
pull_request:
paths:
# The measured import graph is the whole backend tree: main.py imports auth,
# core, hub, loggers, models, picker, routes and utils at module scope.
- 'studio/backend/**'
- '!studio/backend/tests/**'
# The launch phase spawns `unsloth studio --api-only`, so the CLI counts too.
- 'unsloth_cli/**'
- 'studio/src-tauri/src/preflight**'
# The profiler hardcodes the desktop argv that process.rs::backend_args builds,
# so a change there must schedule a run or the two silently diverge.
- 'studio/src-tauri/src/process.rs'
- 'scripts/profile_startup.py'
- '.github/workflows/startup-profile-ci.yml'
# The job profiles whatever `install.sh --local` built: the installers pick the
# venv's Python and the dependency specs, and pyproject's include list is what
# makes --local overlay studio.backend*.
- 'install.sh'
- 'install.ps1'
- 'pyproject.toml'
# --local also runs the checkout's setup scripts (install.sh picks
# $_REPO_ROOT/studio/setup.sh, the editable install resolves setup.ps1 to the
# repo), and both call install_python_stack.py, which picks the dependencies.
- 'studio/setup.sh'
- 'studio/setup.ps1'
- 'studio/install_python_stack.py'
workflow_dispatch:
inputs:
repeats:
description: 'launch repeats per OS (median reported)'
type: string
default: '3'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
profile:
name: startup ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
continue-on-error: true
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-14, windows-latest]
env:
UNSLOTH_STUDIO_HOME: ${{ github.workspace }}/.studio-home
# A wildcard bind calls ifconfig.me on the startup path; loopback times our code.
UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK: '1'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install Studio
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -o pipefail
mkdir -p logs
# --local is load-bearing: it overlays the checkout, so the profiled server
# is this diff. Without it install.sh resolves unsloth from PyPI.
if [ "${{ runner.os }}" = "Windows" ]; then
pwsh -NoProfile -File ./install.ps1 --local 2>&1 | tee logs/install.log
else
bash install.sh --local 2>&1 | tee logs/install.log
fi
- name: Profile startup
shell: bash
run: |
BIN="$UNSLOTH_STUDIO_HOME/unsloth_studio/bin/unsloth"
[ -x "$BIN" ] || BIN="$UNSLOTH_STUDIO_HOME/unsloth_studio/Scripts/unsloth.exe"
[ -x "$BIN" ] || BIN=""
# Profile imports with the INSTALLED interpreter: that venv is what launches.
PY="$UNSLOTH_STUDIO_HOME/unsloth_studio/bin/python"
[ -x "$PY" ] || PY="$UNSLOTH_STUDIO_HOME/unsloth_studio/Scripts/python.exe"
[ -x "$PY" ] || PY="$(command -v python3 || command -v python)"
python3 scripts/profile_startup.py \
--python "$PY" \
${BIN:+--bin "$BIN"} \
--repeats "${{ inputs.repeats || '3' }}" \
--json "startup-${{ matrix.os }}.json" 2>&1 | tee logs/profile.log
- name: Summary
if: always()
shell: bash
run: |
f="startup-${{ matrix.os }}.json"
[ -f "$f" ] || { echo "no profile produced"; exit 0; }
python3 - "$f" >> "$GITHUB_STEP_SUMMARY" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
print(f"### {d['platform']} / {d['machine']} (py {d['python']}, {d['cpu_count']} cpu)\n")
imp = d.get("imports", {})
# Gate on ok: a failed `import main` still leaves rows, so a total can lie.
if imp.get("ok"):
print(f"**`import main`: {imp['total_seconds']}s**\n")
print("| package | self ms |")
print("|---|---:|")
for k, v in list(imp.get("self_by_package_ms", {}).items())[:8]:
print(f"| {k} | {v} |")
print()
else:
print("**`import main` failed - no valid import profile**\n")
print("```\n" + (imp.get("error") or "")[-1500:] + "\n```\n")
lau = d.get("launch") or {}
runs = len(lau.get("runs") or [])
failed = lau.get("failed_runs") or 0
if lau.get("healthz_median_seconds") is not None:
# The aggregates cover only the runs that reached healthz, so flag the
# failures: bare numbers would read as a normal fast startup.
note = f" _({runs - failed} of {runs} launches; {failed} never became healthy)_" if failed else ""
print(f"**time to a healthy port: {lau['healthz_median_seconds']}s median, "
f"{lau['healthz_max_seconds']}s max**{note}\n")
elif lau.get("skipped"):
print(f"_launch phase skipped: {lau['skipped']}_\n")
elif runs:
print(f"**no launch measurement: all {runs} launches failed to become healthy**\n")
PY
- name: Upload profile
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: startup-profile-${{ matrix.os }}
path: |
startup-*.json
logs/
retention-days: 14
if-no-files-found: warn

View file

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Unsloth API & Auth Tests -- HTTP-level integration tests for the
# Studio API & Auth Tests -- HTTP-level integration tests for the
# FastAPI surface. No Playwright, no model UI; tests/studio/test_studio_api_smoke.py
# runs ~30 s and asserts:
# - CORS hardening (no wildcard + credentials, no bootstrap leak)
@ -15,7 +15,7 @@
# Reuses the GGUF cache key from studio-ui-smoke.yml so the model
# download is one cache-hit on the second job.
name: Unsloth API CI
name: Studio API CI
on:
pull_request:
@ -40,7 +40,7 @@ permissions:
jobs:
api-smoke:
name: Unsloth API & Auth Tests
name: Studio API & Auth Tests
runs-on: ubuntu-latest
timeout-minutes: 12
env:
@ -98,7 +98,7 @@ jobs:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -111,10 +111,9 @@ jobs:
- name: Install pyjwt for the JWT-expiry forge test
run: pip install 'pyjwt>=2.6'
- name: Reset auth + boot Unsloth (API-only)
- name: Reset auth + boot Studio (API-only)
run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -145,7 +144,7 @@ jobs:
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
- name: Run Unsloth API & Auth tests
- name: Run Studio API & Auth tests
# The script is named WITHOUT a `test_` prefix so it isn't
# auto-collected by pytest in Backend CI's `tests/` walk
# (which doesn't set BASE_URL and would crash at import).
@ -154,7 +153,7 @@ jobs:
STUDIO_AUTH_DIR: /home/runner/.unsloth/studio/auth
run: python tests/studio/studio_api_smoke.py
- name: Stop Unsloth
- name: Stop Studio
if: always()
run: |
kill "${STUDIO_PID}" 2>/dev/null || true

View file

@ -30,13 +30,6 @@ on:
- 'unsloth/**'
- 'unsloth_cli/**'
- 'tests/**'
# The root installers: tests/sh/*.sh and tests/studio/install/* assert
# against these two files, so a change here must run the suite that
# covers it. Without them an install-only edit (the shape most AMD/ROCm
# routing fixes take) skipped Backend CI entirely.
- 'install.sh'
- 'install.ps1'
- 'scripts/**'
- 'pyproject.toml'
- '.github/workflows/studio-backend-ci.yml'
push:
@ -71,7 +64,7 @@ jobs:
- name: Install backend test dependencies (CPU only)
run: |
python -m pip install --upgrade pip
# Unsloth's declared backend deps:
# Studio's declared backend deps:
pip install -r studio/backend/requirements/studio.txt
# Extras that studio.txt does not list but the import chain needs
# (python-multipart for FastAPI form/file uploads, sqlalchemy/cryptography
@ -200,7 +193,6 @@ jobs:
--ignore=tests/sh \
--ignore=tests/studio/test_hardware_dispatch_matrix.py \
--ignore=tests/studio/test_is_mlx_dispatch_gate.py \
--ignore=tests/studio/test_xpu_spoof_pipeline.py \
--ignore=tests/vllm_compat \
--ignore=tests/version_compat \
-m 'not server and not e2e' \
@ -213,53 +205,36 @@ jobs:
env:
PYTHONPATH: ${{ github.workspace }}/studio
UNSLOTH_COMPILE_DISABLE: '1'
# These files mutate hardware.py module globals at runtime via the
# spoof fixtures (CUDA/ROCm/XPU/MLX/CPU), which leaks state into any
# other test that imports hardware. Run them in their own pytest
# invocation so the leak does not cross file boundaries.
# These two files mutate hardware.py module globals at runtime
# via the spoof fixtures, which leaks state into any other test
# that imports hardware. Run them in their own pytest invocation
# so the leak does not cross file boundaries.
run: |
python -m pytest -q --tb=short \
tests/studio/test_hardware_dispatch_matrix.py \
tests/studio/test_is_mlx_dispatch_gate.py \
tests/studio/test_xpu_spoof_pipeline.py
- name: CLI tests (unsloth_cli)
# unsloth_cli/tests had no CI at all: `unsloth_cli/**` was only a paths
# trigger and a ruff target, so 673 tests covering the studio launcher,
# the pre-exposure gate and the auth secret writers ran nowhere, and
# four of them had been failing on main unnoticed.
# Own step, not folded into the tests/ discovery above: pyproject's
# testpaths is tests/, and this suite needs no PYTHONPATH or CUDA spoof
# (it self-bootstraps sys.path and imports neither unsloth nor torch).
run: python -m pytest unsloth_cli/tests -q --tb=short
tests/studio/test_is_mlx_dispatch_gate.py
- name: Shell installer tests
# Auto-discovered rather than allowlisted. The old hardcoded list had
# silently fallen seven files behind tests/run_all.sh, including
# test_strixhalo_wsl_reroute.sh -- the only shell coverage of the ROCm
# WSL reroute -- so that suite never ran on a PR. Skips are explicit,
# each with a reason, and tests/studio/test_ci_shell_suite_coverage.py
# fails if this step stops discovering the directory or the skip list
# grows without one.
#
# Skipped:
# test_install_host_defaults.sh: asserts an install.ps1 layout that
# has drifted (separate followup).
# test_install_rollback_lifecycle.sh: already runs on both platforms
# in cross-platform-parity-ci.yml.
# Subset that does not depend on a writable / pristine install.sh
# tree; test_install_host_defaults.sh checks install.ps1 layout
# which has drifted (separate followup).
run: |
set -e
skip="test_install_host_defaults.sh test_install_rollback_lifecycle.sh"
found=0
for s in tests/sh/test_*.sh; do
case " $skip " in
*" $(basename "$s") "*) echo "skipping $s (see workflow comment)"; continue ;;
esac
found=$((found + 1))
for s in \
tests/sh/test_get_torch_index_url.sh \
tests/sh/test_mac_intel_compat.sh \
tests/sh/test_node_decision.sh \
tests/sh/test_studio_home_node_dir.sh \
tests/sh/test_system_node_readonly.sh \
tests/sh/test_nvcc_meets_llama_minimum.sh \
tests/sh/test_resolve_cuda_archs.sh \
tests/sh/test_tauri_install_exit_order.sh \
tests/sh/test_torch_constraint.sh \
tests/sh/test_torch_flavor.sh \
tests/sh/test_with_llama_cpp_dir_flag.sh \
tests/sh/test_with_llama_cpp_dir_link_behavior.sh; do
echo "::group::$s"
bash "$s"
echo "::endgroup::"
done
[ "$found" -gt 0 ] || { echo "::error::no shell tests discovered under tests/sh"; exit 1; }
echo "ran $found shell installer test files"

View file

@ -9,7 +9,7 @@
# export is validated separately. No GPU / model / llama.cpp: the tests mock the probes and block
# torch/unsloth, so the job installs only a CPU PyTorch plus import deps.
name: Unsloth export capability
name: Studio export capability
on:
pull_request:

View file

@ -133,13 +133,10 @@ jobs:
- name: Typecheck
run: npm run typecheck
- name: Unit tests
run: npm test
- name: Build
run: npm run build
- name: Built bundle must not contain Unsloth's unstable_Provider call site
- name: Built bundle must not contain Studio's unstable_Provider call site
run: |
set -e
JS=$(ls dist/assets/index-*.js | head -1)
@ -147,7 +144,7 @@ jobs:
echo "main bundle: $JS"
echo "unstable_Provider: hits=$HITS (assistant-ui internals contribute up to 3)"
if [ "$HITS" -gt 3 ]; then
echo "::error file=studio/frontend/src/features/chat/runtime-provider.tsx::Unsloth bundle still passes unstable_Provider through useRemoteThreadListRuntime; this is the 2026.5.1 chat-history regression. Pass adapters directly into useLocalRuntime instead."
echo "::error file=studio/frontend/src/features/chat/runtime-provider.tsx::Studio bundle still passes unstable_Provider through useRemoteThreadListRuntime; this is the 2026.5.1 chat-history regression. Pass adapters directly into useLocalRuntime instead."
exit 1
fi

View file

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Three end-to-end smoke jobs that boot a freshly-installed Unsloth and
# Three end-to-end smoke jobs that boot a freshly-installed Studio and
# exercise the surfaces real users hit through the OpenAI / Anthropic
# SDKs and curl. Each job picks the smallest model that exercises the
# behaviour under test, primes HF_HOME via actions/cache, and shares
@ -27,7 +27,7 @@
# All three jobs run in parallel. Total wall time is dominated by job 3
# on a cold cache; warm cache cuts that to ~3 min.
name: Unsloth GGUF CI
name: Studio GGUF CI
on:
pull_request:
@ -112,7 +112,7 @@ jobs:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -125,10 +125,9 @@ jobs:
- name: Install OpenAI + Anthropic Python SDKs
run: pip install 'openai>=1.50' 'anthropic>=0.40'
- name: Reset auth + boot Unsloth (API-only)
- name: Reset auth + boot Studio (API-only)
run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -143,7 +142,7 @@ jobs:
fi
sleep 1
done
echo "Unsloth did not become healthy in 180s"
echo "Studio did not become healthy in 180s"
tail -200 logs/studio.log
exit 1
@ -230,11 +229,11 @@ jobs:
return replies
def run_anthropic():
# Two SDK quirks vs. Unsloth:
# Two SDK quirks vs. Studio:
# 1. base_url must NOT include /v1 -- the SDK appends
# /v1/messages itself; otherwise the request hits
# /v1/v1/messages and 405s.
# 2. The SDK sends `x-api-key` by default, but Unsloth's
# 2. The SDK sends `x-api-key` by default, but Studio's
# auth layer is HTTPBearer-only. Override via
# default_headers so Authorization: Bearer ... is
# sent instead.
@ -277,7 +276,7 @@ jobs:
print(
f"[{label}] WARN non-determinism at temperature=0.0 across "
f"{len(determinism_failures)} of {len(first)} turn(s); "
f"small-quant model drift, not an Unsloth regression. "
f"small-quant model drift, not a Studio regression. "
f"Details: " + " | ".join(determinism_failures)
)
# Sanity: turn-2 reply should mention the earlier question, and
@ -291,7 +290,7 @@ jobs:
print(f"[{label}] {status_word} -- 4 turns, history grounded ('paris' present)")
PY
- name: Stop Unsloth
- name: Stop Studio
if: always()
run: |
kill "${STUDIO_PID}" 2>/dev/null || true
@ -324,7 +323,7 @@ jobs:
# store xet chunks + blobs + snapshots = ~4 GiB compressed --
# 4-5x file-size inflation, dominated by xet chunks. Use main's
# `--local-dir gguf-cache` pattern to cache the flat .gguf only.
# Unsloth's /api/inference/load accepts either a HF repo (which
# Studio's /api/inference/load accepts either a HF repo (which
# uses HF_HOME) or an absolute file path; passing the absolute
# path keeps the test off HF_HOME entirely so the cache size
# tracks the GGUF file 1:1. The OpenAI/Anth and JSON+images
@ -381,7 +380,7 @@ jobs:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -391,7 +390,7 @@ jobs:
set -o pipefail
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
- name: Reset auth + boot Unsloth (API-only, default tool policy)
- name: Reset auth + boot Studio (API-only, default tool policy)
# We deliberately use the API-only mode rather than
# `unsloth studio run` because the latter calls
# `set_tool_policy(...)` with a resolved bool: on loopback the
@ -401,7 +400,7 @@ jobs:
# tool_policy=None so each request's `enable_tools` field is
# honoured.
run: |
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -445,8 +444,6 @@ jobs:
python - <<'PY'
import json
import os
import time
import urllib.error
import urllib.request
BASE = os.environ["BASE_URL"]
@ -467,26 +464,10 @@ jobs:
"Content-Type": "application/json",
},
)
# Shared CI runners stall sporadically, so retry transport-level
# failures only; HTTP status errors surface immediately. Bounded
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
with urllib.request.urlopen(req, timeout = timeout) as resp:
return resp.status, json.loads(resp.read().decode())
def post_sse(path, body, *, timeout = 600, retries = 1, complete_on = None):
def post_sse(path, body, *, timeout = 600):
"""POST a streaming request and accumulate the assistant
text deltas. The server-side agentic loop ALWAYS returns
SSE regardless of the request's `stream` field, so any
@ -502,22 +483,6 @@ jobs:
invocation markers / tool output, since
`delta.content` alone is not evidence
that the tool path executed.
A shared CI runner can stall the stream transport (the
connection opening, or a mid-stream read) even when Unsloth
is healthy, so retry a stall once with a fresh request
capped at 300s. A stall means the stream did NOT complete,
so partial events are normally NOT returned (an early
tool_start with no tool_end is not proof the tool loop
finished). The one exception is `complete_on`: an optional
predicate over the events collected so far -- when a stall
happens after it is already satisfied (the tool ran and
produced its result before the trailing read timed out),
those events are returned rather than discarded, so the
stall-after-answer case still counts. HTTP status errors
surface immediately; a stall that yields no completed result
across all attempts re-raises so the caller can rotate to
the next seed.
"""
body = {**body, "stream": True}
data = json.dumps(body).encode()
@ -530,45 +495,26 @@ jobs:
"Content-Type": "application/json",
},
)
for attempt in range(retries + 1):
parts = []
events = []
t = timeout if attempt == 0 else min(timeout, 300)
try:
with urllib.request.urlopen(req, timeout = t) as resp:
for raw in resp:
line = raw.decode().strip()
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
events.append(payload)
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
for choice in chunk.get("choices", []):
delta = choice.get("delta", {}) or {}
if delta.get("content"):
parts.append(delta["content"])
return "".join(parts), events
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
# A stall after the tool already produced its result is
# the case this probe exists to tolerate: keep those
# events. But a stall with only an early tool_start (no
# completed output) is not proof the tool loop finished,
# so it must not pass -- retry once, then raise so
# _run_tool_probe rotates to the next seed.
if complete_on is not None and complete_on(events):
print(f"[retry-sse] {path}: {exc!r}; keeping {len(events)} completed events", flush = True)
return "".join(parts), events
if attempt == retries:
raise
print(f"[retry-sse] {path}: {exc!r}", flush = True)
time.sleep(15)
parts = []
events = []
with urllib.request.urlopen(req, timeout = timeout) as resp:
for raw in resp:
line = raw.decode().strip()
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
events.append(payload)
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
for choice in chunk.get("choices", []):
delta = choice.get("delta", {}) or {}
if delta.get("content"):
parts.append(delta["content"])
return "".join(parts), events
_STUDIO_TOOL_TYPES = {
"tool_start", "tool_end", "tool_use", "tool_result",
@ -576,11 +522,11 @@ jobs:
def _tool_invoked(events):
"""Structural check: True iff some SSE payload is a real
tool envelope (Unsloth tool_start/tool_end, Anthropic
tool envelope (Studio tool_start/tool_end, Anthropic
tool_use/tool_result, OpenAI non-empty delta.tool_calls /
message.tool_calls / finish_reason='tool_calls' /
role:'tool' / function_call). tool_status is NOT
evidence: Unsloth emits empty tool_status events on
evidence: Studio emits empty tool_status events on
iteration boundaries even when no tool ran.
"""
for raw in events:
@ -699,61 +645,23 @@ jobs:
attempt has structural invocation evidence. WARN (not
FAIL) if invoked but no attempt produces the expected
literal in tool_end.result -- small-quant Qwen3.5-2B can
emit OpenAI tool_calls deltas without Unsloth's GGUF
emit OpenAI tool_calls deltas without Studio's GGUF
agentic loop intercepting them, and that GGUF-vs-OpenAI
format mismatch is out of scope for #5642.
"""
attempts_log = []
best = None
# Cap the wall-clock spent rotating through stalled seeds so a
# persistent no-data wedge fails fast (clean assertion) instead
# of being killed by the job's timeout-minutes. A healthy or
# merely degenerate round answers in seconds, so all seeds still
# run in the normal case; only stalls consume the budget.
probe_deadline = time.monotonic() + 300
for attempt_i in range(max_attempts):
# Cap each read by the budget still remaining (not just a flat
# 180s) and skip an attempt too small to finish, so the whole
# rotation stays within ~300s -- two probes then fit the job's
# timeout-minutes even if every seed stalls.
remaining = int(probe_deadline - time.monotonic())
if attempt_i and remaining < 30:
print(f"[tools] {label}: seed-rotation budget spent after {attempt_i} attempts", flush = True)
break
attempt_seed = SEED + attempt_i
try:
# Bounded per-attempt timeout, no inner retry -- the seed
# loop IS the retry, so a stall raises quickly and rotates
# rather than spending post_sse's full 600+300s. complete_on
# keeps a stall that already produced the tool result (only
# the trailing read timed out) instead of discarding it.
content, events = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": prompt}],
"enable_tools": True,
"permission_mode": "full",
"enabled_tools": enabled,
"session_id": f"{session}-att{attempt_i}",
"temperature": TOOL_PROBE_TEMP,
"seed": attempt_seed,
"max_tokens": 600,
}, timeout = min(180, remaining), retries = 0,
complete_on = lambda ev: _tool_invoked(ev) and _tool_output_contains(ev, *needles))
except urllib.error.HTTPError:
# HTTPError subclasses URLError, so re-raise a real 4xx/5xx
# here instead of letting the transport-stall handler below
# swallow it and rotate seeds -- an endpoint status failure
# must surface, not be masked as missing tool evidence.
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
# A transport stall that outlived post_sse's own retry:
# log it as a failed attempt and rotate to the next seed
# rather than sinking the whole probe on one bad stream.
attempts_log.append({
"attempt": attempt_i, "seed": attempt_seed,
"transport_error": repr(exc),
})
print(f"[tools] retry {label} attempt {attempt_i}: transport {exc!r}", flush = True)
continue
content, events = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": prompt}],
"enable_tools": True,
"enabled_tools": enabled,
"session_id": f"{session}-att{attempt_i}",
"temperature": TOOL_PROBE_TEMP,
"seed": attempt_seed,
"max_tokens": 600,
})
invoked = _tool_invoked(events)
produced = _tool_output_contains(events, *needles)
attempts_log.append({
@ -812,21 +720,17 @@ jobs:
# because (a) the search may legitimately return no results,
# and (b) DuckDuckGo upstream blocks GHA IP ranges often
# enough that requiring a tool_call marker would create
# red-herring failures from infra rather than from Unsloth.
# red-herring failures from infra rather than from Studio.
try:
# Best-effort and bounded: a single 180s attempt keeps a stall
# from eating the job's timeout-minutes (it already WARNs, so a
# retry buys nothing).
content, events = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
"enable_tools": True,
"permission_mode": "full",
"enabled_tools": ["web_search"],
"session_id": "ci-tool-calling-web",
"temperature": 0.0,
"seed": SEED,
"max_tokens": 400,
}, timeout = 180, retries = 0)
})
print(
f"[tools] PASS web_search stream ({len(content)} chars in content, "
f"{len(events)} raw events)"
@ -835,7 +739,7 @@ jobs:
print(f"[tools] WARN web_search probe failed (non-blocking): {exc}")
# ── 5. Thinking on / off ─────────────────────────────────────
# Unsloth strips think blocks from message.content for tools-mode
# Studio strips think blocks from message.content for tools-mode
# responses, so we toggle plain chat (no enable_tools) and look
# at the surfaced reasoning_content / message.thinking field.
def thinking_call(enable):
@ -849,7 +753,7 @@ jobs:
})
assert status == 200
msg = data["choices"][0]["message"]
# Unsloth surfaces thinking via reasoning_content (OpenAI
# Studio surfaces thinking via reasoning_content (OpenAI
# extension). Fall back to inline <think> markers for
# robustness across template versions.
raw = (msg.get("content") or "") + (msg.get("reasoning_content") or "")
@ -869,7 +773,7 @@ jobs:
print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)")
PY
- name: Stop Unsloth
- name: Stop Studio
if: always()
run: |
kill "${STUDIO_PID}" 2>/dev/null || true
@ -961,7 +865,7 @@ jobs:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v2
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -974,12 +878,12 @@ jobs:
- name: Install OpenAI + Anthropic Python SDKs
run: pip install 'openai>=1.50' 'anthropic>=0.40'
- name: Reset auth + boot Unsloth (API-only)
- name: Reset auth + boot Studio (API-only)
# See Job 2's comment: API-only mode keeps tool_policy=None so
# response_format requests aren't routed through the agentic
# tool loop.
run: |
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -1034,8 +938,6 @@ jobs:
import base64
import json
import os
import time
import urllib.error
import urllib.request
from openai import OpenAI
from anthropic import Anthropic
@ -1054,36 +956,20 @@ jobs:
"Content-Type": "application/json",
},
)
# Shared CI runners stall sporadically, so retry transport-level
# failures only; HTTP status errors surface immediately. Bounded
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
with urllib.request.urlopen(req, timeout = timeout) as resp:
return resp.status, json.loads(resp.read().decode())
# ── 1. response_format = json_object (JSON mode) ─────────────
# llama.cpp's HTTP server supports OpenAI-compatible JSON
# mode: `response_format: {"type": "json_object"}` constrains
# the model to emit syntactically-valid JSON. We use raw HTTP
# rather than the OpenAI SDK so that the field shape Unsloth
# rather than the OpenAI SDK so that the field shape Studio
# forwards to llama-server is unambiguous (the SDK rewrites
# response_format depending on which variant it recognises).
# We deliberately do NOT pass a strict JSON schema -- on
# small Gemma-4 quants the GBNF-from-schema path occasionally
# produces empty output, and JSON mode is the surface we care
# about exposing through Unsloth.
# about exposing through Studio.
status, data = post("/v1/chat/completions", {
"model": "default",
"messages": [
@ -1113,7 +999,7 @@ jobs:
print(f"[json] PASS json_object -> {parsed}")
# ── 2. OpenAI image_url (data URI base64) ───────────────────
# 64x64 solid-red PNG. stb_image (used by Unsloth's image
# 64x64 solid-red PNG. stb_image (used by Studio's image
# normaliser at routes/inference.py:3410) rejects 4x4 or
# smaller PNGs as truncated, so we go up to 64x64 -- still
# tiny in token cost. The assertion is loose: any non-empty
@ -1149,9 +1035,9 @@ jobs:
print("[image/openai] PASS image_url accepted, non-empty response")
# ── 3. Anthropic source/base64 image ────────────────────────
# Two SDK quirks vs. Unsloth: base_url must NOT include /v1
# Two SDK quirks vs. Studio: base_url must NOT include /v1
# (the SDK appends it itself; otherwise /v1/v1/messages -> 405),
# and Unsloth's auth is HTTPBearer-only so the SDK's default
# and Studio's auth is HTTPBearer-only so the SDK's default
# x-api-key header is ignored -- send Authorization: Bearer
# via default_headers.
anthropic = Anthropic(
@ -1185,7 +1071,7 @@ jobs:
print("[image/anthropic] PASS source/base64 accepted, non-empty response")
PY
- name: Stop Unsloth
- name: Stop Studio
if: always()
run: |
kill "${STUDIO_PID}" 2>/dev/null || true

View file

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
#
# Event-loop regression test for the Unsloth model-load orchestrator.
# Event-loop regression test for the Studio model-load orchestrator.
# Pins down issue #5642 (Win10 UI freeze on model load): the /load
# route calls LlamaCppBackend.detect_audio_type synchronously, blocking
# the FastAPI event loop on a chain of sync httpx.Client.post() probes.
@ -14,7 +14,7 @@
# danielhanchen/unsloth-staging-2 (Ubuntu / macOS / Windows all
# green at PR time).
name: Unsloth load-orchestrator CI
name: Studio load-orchestrator CI
on:
pull_request:

View file

@ -33,7 +33,7 @@ permissions:
jobs:
api-smoke:
name: Unsloth API & Auth Tests
name: Studio API & Auth Tests
runs-on: macos-14
timeout-minutes: 25
env:
@ -83,7 +83,7 @@ jobs:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -99,10 +99,9 @@ jobs:
- name: Install pyjwt for the JWT-expiry forge test
run: pip install 'pyjwt>=2.6'
- name: Reset auth + boot Unsloth (API-only)
- name: Reset auth + boot Studio (API-only)
run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -130,13 +129,13 @@ jobs:
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
- name: Run Unsloth API & Auth tests
- name: Run Studio API & Auth tests
env:
BASE_URL: http://127.0.0.1:18895
STUDIO_AUTH_DIR: /Users/runner/.unsloth/studio/auth
run: python tests/studio/studio_api_smoke.py
- name: Stop Unsloth
- name: Stop Studio
if: always()
run: |
kill "${STUDIO_PID}" 2>/dev/null || true

View file

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Three end-to-end smoke jobs that boot a freshly-installed Unsloth and
# Three end-to-end smoke jobs that boot a freshly-installed Studio and
# exercise the surfaces real users hit through the OpenAI / Anthropic
# SDKs and curl. Each job picks the smallest model that exercises the
# behaviour under test, primes a model cache via actions/cache, and
@ -108,7 +108,7 @@ jobs:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -124,10 +124,9 @@ jobs:
- name: Install OpenAI + Anthropic Python SDKs
run: pip install 'openai>=1.50' 'anthropic>=0.40'
- name: Reset auth + boot Unsloth (API-only)
- name: Reset auth + boot Studio (API-only)
run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -142,7 +141,7 @@ jobs:
fi
sleep 1
done
echo "Unsloth did not become healthy in 180s"
echo "Studio did not become healthy in 180s"
tail -200 logs/studio.log
exit 1
@ -229,11 +228,11 @@ jobs:
return replies
def run_anthropic():
# Two SDK quirks vs. Unsloth:
# Two SDK quirks vs. Studio:
# 1. base_url must NOT include /v1 -- the SDK appends
# /v1/messages itself; otherwise the request hits
# /v1/v1/messages and 405s.
# 2. The SDK sends `x-api-key` by default, but Unsloth's
# 2. The SDK sends `x-api-key` by default, but Studio's
# auth layer is HTTPBearer-only. Override via
# default_headers so Authorization: Bearer ... is
# sent instead.
@ -284,7 +283,7 @@ jobs:
print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded")
PY
- name: Stop Unsloth
- name: Stop Studio
if: always()
run: |
kill "${STUDIO_PID}" 2>/dev/null || true
@ -364,7 +363,7 @@ jobs:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -377,7 +376,7 @@ jobs:
- name: Assert llama.cpp loads on this macOS
run: bash .github/scripts/assert-llama-loads.sh
- name: Reset auth + boot Unsloth (API-only, default tool policy)
- name: Reset auth + boot Studio (API-only, default tool policy)
# We deliberately use the API-only mode rather than
# `unsloth studio run` because the latter calls
# `set_tool_policy(...)` with a resolved bool: on loopback the
@ -387,7 +386,7 @@ jobs:
# tool_policy=None so each request's `enable_tools` field is
# honoured.
run: |
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -431,8 +430,6 @@ jobs:
python - <<'PY'
import json
import os
import time
import urllib.error
import urllib.request
BASE = os.environ["BASE_URL"]
@ -453,41 +450,14 @@ jobs:
"Content-Type": "application/json",
},
)
# Shared CI runners stall sporadically, so retry transport-level
# failures only; HTTP status errors surface immediately. Bounded
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
with urllib.request.urlopen(req, timeout = timeout) as resp:
return resp.status, json.loads(resp.read().decode())
def post_sse(path, body, *, timeout = 600, retries = 1, soft = False):
def post_sse(path, body, *, timeout = 600):
"""POST a streaming request and accumulate the assistant
text deltas. The server-side agentic loop ALWAYS returns
SSE regardless of the request's `stream` field, so any
call with enable_tools=true must use this helper.
A shared CI runner can stall the stream transport (the
connection opening, or a mid-stream read) even when Unsloth
is healthy, so harden the read three ways: retry a stall
once with a fresh request capped at 300s; return any text
already streamed before a stall (a stall on the trailing
tokens, after the answer arrived, still counts); and when
every attempt yields nothing, a hard call re-raises while a
soft call (the best-effort server-side tool probes) returns
None so the caller can WARN instead of sinking the whole
job. HTTP status errors always surface immediately."""
call with enable_tools=true must use this helper."""
body = {**body, "stream": True}
data = json.dumps(body).encode()
req = urllib.request.Request(
@ -499,43 +469,24 @@ jobs:
"Content-Type": "application/json",
},
)
for attempt in range(retries + 1):
parts = []
t = timeout if attempt == 0 else min(timeout, 300)
try:
with urllib.request.urlopen(req, timeout = t) as resp:
for raw in resp:
line = raw.decode().strip()
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
for choice in chunk.get("choices", []):
delta = choice.get("delta", {}) or {}
if delta.get("content"):
parts.append(delta["content"])
return "".join(parts)
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
# Text already streamed is a valid signal -- keep it
# rather than re-running a heavy generation.
if parts:
joined = "".join(parts)
print(f"[retry-sse] {path}: {exc!r}; keeping {len(joined)} partial chars", flush = True)
return joined
if attempt == retries:
if soft:
print(f"[tools] WARN {path}: SSE transport stalled with no data ({exc!r}) -- non-blocking", flush = True)
return None
raise
print(f"[retry-sse] {path}: {exc!r}", flush = True)
time.sleep(15)
parts = []
with urllib.request.urlopen(req, timeout = timeout) as resp:
for raw in resp:
line = raw.decode().strip()
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
for choice in chunk.get("choices", []):
delta = choice.get("delta", {}) or {}
if delta.get("content"):
parts.append(delta["content"])
return "".join(parts)
# ── 1. Standard OpenAI function calling ──────────────────────
weather_tool = {
@ -575,11 +526,11 @@ jobs:
assert status == 200, f"tool call status {status}: {data}"
choice = data["choices"][0]
tool_calls = (choice.get("message") or {}).get("tool_calls") or []
# Unsloth's contract: when tool_choice='required', llama.cpp's
# Studio's contract: when tool_choice='required', llama.cpp's
# grammar should force a tool_calls payload. On Mac that
# contract is sometimes broken by the underlying quant; the
# PASS path is "tool_calls present + correct schema", the
# WARN path documents Unsloth still returned 200 with a
# WARN path documents Studio still returned 200 with a
# well-formed choices[] envelope.
if tool_calls:
tc = tool_calls[0]
@ -606,23 +557,16 @@ jobs:
# macos-14 free runner is ~10 tok/s on Qwen3.5-2B Q4_K_XL;
# cap max_tokens tightly so each SSE round stays under ~30s
# even when the model stalls in a degenerate output state.
# retries=0 on the best-effort probes: this job's 25-minute cap
# allows a 10-minute model load, so a no-data stall must be a
# single 180s attempt (not 180+15+180s) to leave room for the
# thinking checks. A soft/best-effort probe only WARNs anyway.
content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}],
"enable_tools": True,
"permission_mode": "full",
"enabled_tools": ["python"],
"session_id": "ci-tool-calling-py",
"temperature": TEMP,
"seed": SEED,
"max_tokens": 128,
}, timeout = 180, retries = 0, soft = True)
if content is None:
print("[tools] WARN python tool: SSE transport stalled after retries -- non-blocking")
elif "56088" in content or "56,088" in content:
}, timeout = 180)
if "56088" in content or "56,088" in content:
print(f"[tools] PASS python tool ({len(content)} chars, found 56088)")
else:
# Empty stream is a known Mac-quant degeneracy too; log
@ -649,19 +593,18 @@ jobs:
content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
"enable_tools": True,
"permission_mode": "full",
"enabled_tools": ["web_search"],
"session_id": "ci-tool-calling-web",
"temperature": TEMP,
"seed": SEED,
"max_tokens": 96,
}, timeout = 180, retries = 0)
}, timeout = 180)
print(f"[tools] PASS web_search stream ({len(content)} chars)")
except Exception as exc:
print(f"[tools] WARN web_search probe failed (non-blocking): {exc}")
# ── 4. Thinking on / off ─────────────────────────────────────
# Unsloth strips think blocks from message.content for tools-mode
# Studio strips think blocks from message.content for tools-mode
# responses, so we toggle plain chat (no enable_tools) and look
# at the surfaced reasoning_content / message.thinking field.
def thinking_call(enable):
@ -679,7 +622,7 @@ jobs:
}, timeout = 180)
assert status == 200
msg = data["choices"][0]["message"]
# Unsloth surfaces thinking via reasoning_content (OpenAI
# Studio surfaces thinking via reasoning_content (OpenAI
# extension). Fall back to inline <think> markers for
# robustness across template versions.
raw = (msg.get("content") or "") + (msg.get("reasoning_content") or "")
@ -705,7 +648,7 @@ jobs:
print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)")
PY
- name: Stop Unsloth
- name: Stop Studio
if: always()
run: |
kill "${STUDIO_PID}" 2>/dev/null || true
@ -811,7 +754,7 @@ jobs:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-${{ env.MMPROJ_FILE }}-v2
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -827,12 +770,12 @@ jobs:
- name: Install OpenAI + Anthropic Python SDKs
run: pip install 'openai>=1.50' 'anthropic>=0.40'
- name: Reset auth + boot Unsloth (API-only)
- name: Reset auth + boot Studio (API-only)
# See Job 2's comment: API-only mode keeps tool_policy=None so
# response_format requests aren't routed through the agentic
# tool loop.
run: |
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -882,8 +825,6 @@ jobs:
import base64
import json
import os
import time
import urllib.error
import urllib.request
from openai import OpenAI
from anthropic import Anthropic
@ -907,36 +848,20 @@ jobs:
"Content-Type": "application/json",
},
)
# Shared CI runners stall sporadically, so retry transport-level
# failures only; HTTP status errors surface immediately. Bounded
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
with urllib.request.urlopen(req, timeout = timeout) as resp:
return resp.status, json.loads(resp.read().decode())
# ── 1. response_format = json_object (JSON mode) ─────────────
# llama.cpp's HTTP server supports OpenAI-compatible JSON
# mode: `response_format: {"type": "json_object"}` constrains
# the model to emit syntactically-valid JSON. We use raw HTTP
# rather than the OpenAI SDK so that the field shape Unsloth
# rather than the OpenAI SDK so that the field shape Studio
# forwards to llama-server is unambiguous (the SDK rewrites
# response_format depending on which variant it recognises).
# We deliberately do NOT pass a strict JSON schema -- on
# small Gemma-4 quants the GBNF-from-schema path occasionally
# produces empty output, and JSON mode is the surface we care
# about exposing through Unsloth.
# about exposing through Studio.
status, data = post("/v1/chat/completions", {
"model": "default",
"messages": [
@ -1008,7 +933,7 @@ jobs:
)
# ── 2. OpenAI image_url (data URI base64) ───────────────────
# 64x64 solid-red PNG. stb_image (used by Unsloth's image
# 64x64 solid-red PNG. stb_image (used by Studio's image
# normaliser at routes/inference.py:3410) rejects 4x4 or
# smaller PNGs as truncated, so we go up to 64x64 -- still
# tiny in token cost. The assertion is loose: any non-empty
@ -1024,11 +949,11 @@ jobs:
# The Mac prebuilt llama.cpp server has a known crash when
# processing image inputs alongside the gemma-4-E2B mmproj
# (server disconnects mid-completion). This is upstream
# llama.cpp behaviour, not Unsloth. Wrap both SDK calls in
# llama.cpp behaviour, not Studio. Wrap both SDK calls in
# try/except so an upstream crash registers as a WARN rather
# than failing the whole job. Unsloth's contract (OpenAI/
# than failing the whole job. Studio's contract (OpenAI/
# Anthropic image fields are accepted and forwarded) is
# validated by the request body Unsloth constructs, not by
# validated by the request body Studio constructs, not by
# whether llama.cpp can decode it on Mac Metal.
client = OpenAI(base_url = f"{BASE}/v1", api_key = KEY)
try:
@ -1054,14 +979,14 @@ jobs:
except Exception as exc:
print(
f"[image/openai] WARN image_url SDK call raised: {type(exc).__name__}: "
f"{exc}. Likely upstream llama.cpp Mac+vision crash, NOT an Unsloth "
f"regression. Unsloth successfully forwarded the request."
f"{exc}. Likely upstream llama.cpp Mac+vision crash, NOT a Studio "
f"regression. Studio successfully forwarded the request."
)
# ── 3. Anthropic source/base64 image ────────────────────────
# Two SDK quirks vs. Unsloth: base_url must NOT include /v1
# Two SDK quirks vs. Studio: base_url must NOT include /v1
# (the SDK appends it itself; otherwise /v1/v1/messages -> 405),
# and Unsloth's auth is HTTPBearer-only so the SDK's default
# and Studio's auth is HTTPBearer-only so the SDK's default
# x-api-key header is ignored -- send Authorization: Bearer
# via default_headers.
anthropic = Anthropic(
@ -1100,11 +1025,11 @@ jobs:
print(
f"[image/anthropic] WARN anthropic image SDK call raised: "
f"{type(exc).__name__}: {exc}. Likely upstream llama.cpp Mac+vision "
f"crash, NOT an Unsloth regression."
f"crash, NOT a Studio regression."
)
PY
- name: Stop Unsloth
- name: Stop Studio
if: always()
run: |
kill "${STUDIO_PID}" 2>/dev/null || true

View file

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Proves Unsloth's llama.cpp install loads on every supported macOS. The heavy
# Proves Studio's llama.cpp install loads on every supported macOS. The heavy
# app smokes stay single-OS; this matrix covers the OS-version dimension cheaply
# (install.sh + binary-load assert). Regression guard for the macOS-version
# selection in studio/install_llama_prebuilt.py.
@ -60,7 +60,7 @@ jobs:
with:
python-version: '3.12'
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.

View file

@ -19,7 +19,6 @@ on:
- 'install.sh'
- 'pyproject.toml'
- 'tests/studio/**'
- '.github/scripts/run-studio-permission-browser.sh'
- '.github/workflows/studio-mac-ui-smoke.yml'
push:
branches: [main, pip]
@ -84,7 +83,7 @@ jobs:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -97,7 +96,7 @@ jobs:
- name: Assert llama.cpp loads on this macOS
run: bash .github/scripts/assert-llama-loads.sh
- name: Install Playwright browsers
- name: Install Playwright + Chromium
# No --with-deps on Mac: that flag installs Linux apt packages.
# GitHub-hosted macos-14 ships the system frameworks Chromium
# needs already.
@ -113,7 +112,7 @@ jobs:
# in-script retry recover from any residual flakes.
run: |
pip install 'playwright>=1.55,<1.58'
python -m playwright install chromium webkit
python -m playwright install chromium
- name: Patch Playwright pipeTransport.js to tolerate malformed JSON
# In Playwright 1.55-1.58, pipeTransport.js does
@ -144,10 +143,9 @@ jobs:
print(f"pipeTransport.js: patched JSON.parse calls in {path}")
PY
- name: Reset auth + boot Unsloth
- name: Reset auth + boot Studio
run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -190,8 +188,8 @@ jobs:
# dies mid-test, (2) Chromium net::ERR_NO_BUFFER_SPACE when the
# runner's kernel briefly runs out of socket buffers, and (3) a
# goto 'interrupted by another navigation' when the SPA auth
# guard redirects mid-navigation. The retry FULLY resets Unsloth
# (kill, wipe auth, reboot, wait /api/health, re-export
# guard redirects mid-navigation. The retry FULLY resets Studio
# (kill, reset-password, reboot, wait /api/health, re-export
# bootstrap pw) before re-running the script. A real test failure
# (assertion / timeout) does NOT match any pattern so it bypasses
# retry and surfaces immediately.
@ -211,10 +209,10 @@ jobs:
|| grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_attempt_${attempt}.log \
|| grep -q "interrupted by another navigation" logs/playwright_attempt_${attempt}.log; } \
&& [ "$attempt" -lt "$max_attempts" ]; then
echo "::warning::Playwright flake on attempt ${attempt}; resetting Unsloth and retrying..."
echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..."
kill "${STUDIO_PID}" 2>/dev/null || true
sleep 2
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> "logs/studio_retry_${attempt}.log" 2>&1 &
STUDIO_PID=$!
@ -240,19 +238,15 @@ jobs:
exit "$rc"
done
- name: Stop Unsloth (chat-ui ends with Shutdown click; this is belt-and-suspenders)
- name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders)
if: always()
run: |
kill "${STUDIO_PID}" 2>/dev/null || true
sleep 2
- name: Cross-browser permission controls
- name: Reset auth + boot Studio for extra UI tests (port 18897)
run: |
bash .github/scripts/run-studio-permission-browser.sh 18895 webkit
- name: Reset auth + boot Unsloth for extra UI tests (port 18897)
run: |
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
> logs/studio_extra.log 2>&1 &
@ -277,7 +271,7 @@ jobs:
echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV"
echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV"
- name: Drive Compare/Recipes/Export/Unsloth/Settings with Playwright
- name: Drive Compare/Recipes/Export/Studio/Settings with Playwright
env:
BASE_URL: http://127.0.0.1:18897
STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }}
@ -306,10 +300,10 @@ jobs:
|| grep -q "ERR_NO_BUFFER_SPACE" logs/playwright_extra_attempt_${attempt}.log \
|| grep -q "interrupted by another navigation" logs/playwright_extra_attempt_${attempt}.log; } \
&& [ "$attempt" -lt "$max_attempts" ]; then
echo "::warning::Playwright flake on attempt ${attempt}; resetting Unsloth and retrying..."
echo "::warning::Playwright flake on attempt ${attempt}; resetting Studio and retrying..."
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
sleep 2
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
> "logs/studio_extra_retry_${attempt}.log" 2>&1 &
STUDIO_EXTRA_PID=$!
@ -333,7 +327,7 @@ jobs:
exit "$rc"
done
- name: Stop second Unsloth
- name: Stop second Studio
if: always()
run: |
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
@ -349,7 +343,5 @@ jobs:
logs/studio_extra.log
logs/install.log
logs/playwright
logs/playwright-permissions-*
logs/playwright_extra
logs/studio-permissions-*.log
retention-days: 7

View file

@ -4,15 +4,15 @@
# Mac counterpart to studio-update-smoke.yml. Verifies that on a real
# Apple Silicon (macos-14, M1) runner:
#
# 1. install.sh --local --no-torch installs Unsloth AND auto-fetches
# 1. install.sh --local --no-torch installs Studio AND auto-fetches
# the prebuilt llama.cpp Mac binary (llama-bNNNN-bin-macos-arm64
# from ggml-org/llama.cpp). Hitting the source-build fallback is
# treated as an Unsloth bug -- Unsloth must always pick the
# treated as an Unsloth bug -- Studio must always pick the
# prebuilt on Mac.
# 2. unsloth studio update --local is idempotent. Two consecutive
# runs both report "prebuilt up to date and validated", no
# source-build fallback.
# 3. The installed Unsloth still boots and /api/health returns
# 3. The installed Studio still boots and /api/health returns
# healthy after the update path.
name: Mac Studio Update CI
@ -42,7 +42,7 @@ permissions:
jobs:
update-idempotency:
name: Unsloth Updating Tests
name: Studio Updating Tests
runs-on: macos-14
timeout-minutes: 30
steps:
@ -59,7 +59,7 @@ jobs:
python-version: '3.12'
cache: 'pip'
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -106,7 +106,7 @@ jobs:
grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log
echo "second update was clean"
- name: Boot Unsloth briefly to confirm the install is still usable
- name: Boot Studio briefly to confirm the install is still usable
run: |
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \
@ -123,13 +123,13 @@ jobs:
sleep 1
done
if [ -z "$HEALTHY" ]; then
echo "Unsloth failed to come up after \`update\`"
echo "Studio failed to come up after \`update\`"
tail -200 logs/studio.log
kill "$PID" 2>/dev/null || true
exit 1
fi
kill "$PID" 2>/dev/null || true
echo "post-update Unsloth /api/health OK"
echo "post-update Studio /api/health OK"
- name: Uninstall and verify clean
# Round-trip through scripts/uninstall.sh on real macOS. As a side

View file

@ -12,7 +12,7 @@
# stay in release-desktop.yml (manual `workflow_dispatch`) because they need
# code-signing secrets and ~30 min of runner time each.
name: Unsloth Tauri CI
name: Studio Tauri CI
on:
pull_request:
@ -91,16 +91,6 @@ jobs:
npm run build
test -f dist/index.html
# The crate carries ~100 unit tests (native_file_dialogs, preflight,
# install, desktop_auth, ...) that nothing ran until now: this workflow
# only ever built. Run them here, where the toolchain and the WebKit dev
# packages are already installed, so a broken assertion fails the PR
# instead of sitting unnoticed. `--no-fail-fast` reports every failing
# test in one run rather than stopping at the first.
- name: Rust unit tests (studio/src-tauri)
working-directory: studio/src-tauri
run: cargo test --no-fail-fast
- name: Tauri debug build (Linux, no bundle, no codesign)
# `--debug` + `--no-bundle` keeps this lean: compiles the Rust crate,
# confirms the frontend dist is wired into Tauri, but skips the AppImage

View file

@ -1,8 +1,8 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# End-to-end Unsloth chat UI smoke via Playwright + Chromium against a
# headless Linux runner. Boots Unsloth with the smallest GGUF
# End-to-end Studio chat UI smoke via Playwright + Chromium against a
# headless Linux runner. Boots Studio with the smallest GGUF
# (gemma-3-270m-it UD-Q4_K_XL, ~254 MiB), drives the actual frontend
# bundle, and asserts the full bootstrap-password / change-password /
# send-message / persist-on-reload journey works end to end.
@ -14,7 +14,7 @@
# frontend-only CI happily pass while the actual user-visible UI is
# broken (cf. the 2026.5.1 chat-history release).
name: Unsloth UI CI
name: Studio UI CI
on:
pull_request:
@ -27,7 +27,6 @@ on:
# The Playwright test files themselves -- a PR that ONLY edits
# the test must still trigger UI CI.
- 'tests/studio/**'
- '.github/scripts/run-studio-permission-browser.sh'
- '.github/workflows/studio-ui-smoke.yml'
push:
branches: [main, pip]
@ -98,7 +97,7 @@ jobs:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v2
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Withheld on PR: this step runs checked-out PR code; public GGUF still downloads.
@ -108,15 +107,17 @@ jobs:
set -o pipefail
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
- name: Install Playwright browsers
- name: Install Playwright + Chromium
run: |
pip install 'playwright>=1.45'
python -m playwright install --with-deps chromium firefox webkit
# --with-deps installs the OS-level runtime libs Chromium
# needs (libnss3, libxkbcommon, etc.). About 30 s on a
# warm runner.
python -m playwright install --with-deps chromium
- name: Reset auth + boot Unsloth
- name: Reset auth + boot Studio
run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -146,7 +147,7 @@ jobs:
# NEW + NEW2 are generated freshly per CI run via secrets.token_urlsafe
# rather than hardcoded. If a workflow gets compromised, the
# attacker can't replay a known-good rotated password against
# any future / parallel Unsloth install -- the rotated value
# any future / parallel Studio install -- the rotated value
# only ever exists for the lifetime of this single job, masked
# in the log via ::add-mask::.
run: |
@ -164,37 +165,31 @@ jobs:
env:
BASE_URL: http://127.0.0.1:18892
# The test file lives in the repo so it can be run locally
# against a freshly-installed Unsloth (BASE_URL=...; STUDIO_OLD_PW=
# against a freshly-installed Studio (BASE_URL=...; STUDIO_OLD_PW=
# $(cat ~/.unsloth/studio/auth/.bootstrap_password); python ...).
PW_ART_DIR: logs/playwright
# Strict mode: in CI a missing button / nav / dialog must
# FAIL the test. Locally the test still runs against partial
# Unsloth installs without STUDIO_UI_STRICT.
# Studio installs without STUDIO_UI_STRICT.
STUDIO_UI_STRICT: '1'
run: |
mkdir -p logs/playwright
python tests/studio/playwright_chat_ui.py
- name: Stop Unsloth (chat-ui ends with Shutdown click; this is belt-and-suspenders)
- name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders)
if: always()
run: |
kill "${STUDIO_PID}" 2>/dev/null || true
sleep 2
- name: Cross-browser permission controls
run: |
bash .github/scripts/run-studio-permission-browser.sh 18893 firefox
bash .github/scripts/run-studio-permission-browser.sh 18893 webkit
bash .github/scripts/run-studio-permission-browser.sh 18893 chromium chrome
# The chat UI test ends by clicking the Shutdown menuitem, which
# leaves the server dead. The extra UI test (Compare / Recipes /
# Export / Unsloth / Settings) needs a fresh Unsloth, so we boot a
# Export / Studio / Settings) needs a fresh Studio, so we boot a
# second one on a different port. Boot is fast (~3-5s on the
# warm install we already did) so this adds little wall time.
- name: Reset auth + boot Unsloth for extra UI tests (port 18894)
- name: Reset auth + boot Studio for extra UI tests (port 18894)
run: |
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18894 \
> logs/studio_extra.log 2>&1 &
@ -219,7 +214,7 @@ jobs:
echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV"
echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV"
- name: Drive Compare/Recipes/Export/Unsloth/Settings with Playwright
- name: Drive Compare/Recipes/Export/Studio/Settings with Playwright
env:
BASE_URL: http://127.0.0.1:18894
STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }}
@ -232,75 +227,18 @@ jobs:
mkdir -p logs/playwright_extra
python tests/studio/playwright_extra_ui.py
- name: UI font size scaling regression (Playwright)
env:
BASE_URL: http://127.0.0.1:18894
STUDIO_PW: ${{ env.STUDIO_EXTRA_NEW_PW }}
PW_ART_DIR: logs/playwright_fontscale
run: |
mkdir -p logs/playwright_fontscale
python tests/studio/playwright_ui_font_scale.py
- name: Stop second Unsloth
- name: Stop second Studio
if: always()
run: |
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
sleep 2
# Model-picker per-model-config regression (PR #7207 re-land of #6647).
# Fourth Unsloth on its own port; loads the tiny GGUF and drives the
# picker's run-settings surface: Context Length persists across a reload,
# Reset clears the stored override (never pins it), and the infra models
# (RAG embedder + llama.cpp probe) stay hidden from the picker.
- name: Reset auth + boot Unsloth for model-config tests (port 18898)
run: |
rm -rf ~/.unsloth/studio/auth
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18898 \
> logs/studio_modelcfg.log 2>&1 &
echo "STUDIO_MODELCFG_PID=$!" >> "$GITHUB_ENV"
- name: Wait for /api/health on 18898
run: |
for i in $(seq 1 180); do
if curl -fs "http://127.0.0.1:18898/api/health" > /tmp/health4.json; then
jq -e '.status == "healthy"' /tmp/health4.json && break
fi
sleep 1
done
jq -e '.status == "healthy"' /tmp/health4.json
- name: Pass bootstrap pw for model-config test
run: |
NEW="CIModelCfg-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
echo "::add-mask::$NEW"
echo "STUDIO_MODELCFG_NEW_PW=$NEW" >> "$GITHUB_ENV"
- name: Drive model-picker per-model-config with Playwright
env:
BASE_URL: http://127.0.0.1:18898
STUDIO_NEW_PW: ${{ env.STUDIO_MODELCFG_NEW_PW }}
PW_ART_DIR: logs/playwright_modelcfg
STUDIO_UI_STRICT: '1'
GGUF_REPO: ${{ env.GGUF_REPO }}
GGUF_VARIANT: ${{ env.GGUF_VARIANT }}
STUDIO_MODEL_HINT: gemma-3-270m
run: |
mkdir -p logs/playwright_modelcfg
python tests/studio/playwright_model_config.py
- name: Stop fourth Unsloth
if: always()
run: |
kill "${STUDIO_MODELCFG_PID}" 2>/dev/null || true
sleep 2
# IME + multilingual paste regression (issue #5318 / PR #5327).
# Third Unsloth on its own port so a hang here cannot poison the
# Third Studio on its own port so a hang here cannot poison the
# earlier UI tests. No GGUF -- the bug surface is the composer.
- name: Reset auth + boot Unsloth for IME / i18n tests (port 18896)
- name: Reset auth + boot Studio for IME / i18n tests (port 18896)
run: |
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18896 \
> logs/studio_ime.log 2>&1 &
@ -318,7 +256,7 @@ jobs:
- name: Pass bootstrap pw for IME / i18n test
# IME smoke does the change-password against the bootstrap that
# Unsloth's frontend injects into the page, so it only needs the
# Studio's frontend injects into the page, so it only needs the
# NEW password.
run: |
NEW="CIIme-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
@ -335,7 +273,7 @@ jobs:
mkdir -p logs/playwright_ime
python tests/studio/playwright_chat_ime_i18n.py
- name: Stop third Unsloth
- name: Stop third Studio
if: always()
run: |
kill "${STUDIO_IME_PID}" 2>/dev/null || true
@ -355,15 +293,10 @@ jobs:
path: |
logs/studio.log
logs/studio_extra.log
logs/studio_modelcfg.log
logs/studio_ime.log
logs/install.log
logs/server-logs/
logs/playwright
logs/playwright-permissions-*
logs/playwright_extra
logs/playwright_fontscale
logs/playwright_modelcfg
logs/playwright_ime
logs/studio-permissions-*.log
retention-days: 7

View file

@ -9,7 +9,7 @@
# This catches regressions in setup.sh's update path that the existing
# GGUF / wheel jobs would miss because they only invoke install.sh once.
name: Unsloth Update CI
name: Studio Update CI
on:
pull_request:
@ -36,7 +36,7 @@ permissions:
jobs:
update-idempotency:
name: Unsloth Updating Tests
name: Studio Updating Tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@ -63,7 +63,7 @@ jobs:
# post-step then fatal-errors with "Cache folder path is
# retrieved for pip but doesn't exist on disk".
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
# Pass the workflow token so the llama.cpp prebuilt installer's
# GitHub-API call to list releases isn't rate-limited (60/hr
# unauthenticated). Without this, three consecutive install +
@ -122,7 +122,7 @@ jobs:
grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log
echo "second update was clean"
- name: Boot Unsloth briefly to confirm the install is still usable
- name: Boot Studio briefly to confirm the install is still usable
# If `update --local` accidentally broke the venv or wiped the
# llama-server binary, the server would fail to start here.
run: |
@ -138,53 +138,13 @@ jobs:
sleep 1
done
if ! jq -e '.status == "healthy"' /tmp/health.json 2>/dev/null; then
echo "Unsloth failed to come up after `update`"
echo "Studio failed to come up after `update`"
tail -200 logs/studio.log
kill "$PID" 2>/dev/null || true
exit 1
fi
kill "$PID" 2>/dev/null || true
echo "post-update Unsloth /api/health OK"
- name: A complete install reports itself complete
run: |
set -o pipefail
unsloth studio verify-install
unsloth studio desktop-capabilities --json | tee /tmp/caps.json
jq -e '.studio_install_ok == true' /tmp/caps.json
jq -e '.desktop_manageability_version >= 2' /tmp/caps.json
- name: An incomplete install must not report itself ready
# An installer killed part-way leaves a working CLI but no studio.txt
# deps, which the old preflight called ManagedReady. The manifest is
# written last, so removing it reproduces that state.
run: |
set -o pipefail
# install.sh's default root, resolved explicitly: `python` on PATH
# here is setup-python's, not the managed venv.
MANIFEST="$HOME/.unsloth/studio/unsloth_studio/unsloth_install_manifest.json"
test -f "$MANIFEST" || { echo "::error::installer never wrote $MANIFEST"; exit 1; }
rm -f "$MANIFEST"
unsloth studio desktop-capabilities --json | tee /tmp/caps_bad.json
jq -e '.studio_install_ok == false' /tmp/caps_bad.json
if unsloth studio verify-install; then
echo "::error::verify-install passed on an install with no manifest"
exit 1
fi
echo "incomplete install correctly reported not-ready"
- name: Update repairs an incomplete install
# `--local` bypasses setup.sh's PyPI version compare, so this asserts
# the repair OUTCOME. The non-local fast path the desktop Repair button
# uses is covered by tests/studio/install/test_setup_fast_path_guard.py.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -o pipefail
unsloth studio update --local 2>&1 | tee logs/update_repair.log
unsloth studio verify-install
unsloth studio desktop-capabilities --json | jq -e '.studio_install_ok == true'
echo "update repaired the incomplete install"
echo "post-update Studio /api/health OK"
- name: Uninstall and verify clean
# Round-trip the installer through scripts/uninstall.sh: confirms the

View file

@ -9,7 +9,7 @@
# (Section 6) is Linux-only and short-circuits on non-POSIX; the rest
# is platform-portable.
name: Windows Unsloth API CI
name: Windows Studio API CI
on:
pull_request:
@ -34,7 +34,7 @@ permissions:
jobs:
api-smoke:
name: Unsloth API & Auth Tests
name: Studio API & Auth Tests
runs-on: windows-latest
timeout-minutes: 30
defaults:
@ -105,7 +105,7 @@ jobs:
# studio-windows-update-smoke.yml for the full rationale --
# creating an empty studio/frontend/dist trips setup.ps1's
# mtime-based staleness check into "frontend up to date, skip
# rebuild" and Unsloth boots with an empty dist directory.
# rebuild" and Studio boots with an empty dist directory.
# Add-MpPreference accepts paths that do not yet exist.
foreach ($p in @(
"$env:USERPROFILE\.unsloth",
@ -121,7 +121,7 @@ jobs:
}
}
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -161,7 +161,7 @@ jobs:
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
cat "$INFO"
- name: Add Unsloth shim to GITHUB_PATH
- name: Add Studio shim to GITHUB_PATH
# install.ps1's User-PATH update doesn't propagate to a
# running Git Bash session; export the shim dir so the
# next `unsloth ...` invocation finds it.
@ -177,10 +177,9 @@ jobs:
- name: Install pyjwt for the JWT-expiry forge test
run: python -m pip install 'pyjwt>=2.6'
- name: Reset auth + boot Unsloth (API-only)
- name: Reset auth + boot Studio (API-only)
run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -208,7 +207,7 @@ jobs:
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
- name: Run Unsloth API & Auth tests
- name: Run Studio API & Auth tests
# Do NOT pin STUDIO_AUTH_DIR here. The Mac/Linux mirrors
# hardcode runner-specific paths (/Users/runner/...,
# /home/runner/...), but on Windows the path is
@ -220,7 +219,7 @@ jobs:
BASE_URL: http://127.0.0.1:18895
run: python tests/studio/studio_api_smoke.py
- name: Stop Unsloth
- name: Stop Studio
if: always()
run: |
kill "${STUDIO_PID}" 2>/dev/null || true

View file

@ -1,7 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Three end-to-end smoke jobs that boot a freshly-installed Unsloth and
# Three end-to-end smoke jobs that boot a freshly-installed Studio and
# exercise the surfaces real users hit through the OpenAI / Anthropic
# SDKs and curl, on the FREE windows-latest runner. Each job picks the
# smallest model that exercises the behaviour under test, primes
@ -16,7 +16,7 @@
# Qwen3-VL-2B-Instruct UD-IQ2_XXS + mmproj-F16 (~1.4 GiB total).
# Within the 14 GB windows-latest SSD budget.
name: Windows Unsloth GGUF CI
name: Windows Studio GGUF CI
on:
pull_request:
@ -57,7 +57,7 @@ jobs:
STUDIO_PORT: '18888'
HF_HOME: ${{ github.workspace }}/hf-cache
# Force UTF-8 for stdio (Windows defaults to cp1252; hf
# download / Unsloth CLI print "✓" checkmarks and crash
# download / Studio CLI print "✓" checkmarks and crash
# otherwise).
PYTHONIOENCODING: utf-8
PYTHONUTF8: '1'
@ -160,7 +160,7 @@ jobs:
# studio-windows-update-smoke.yml for the full rationale --
# creating an empty studio/frontend/dist trips setup.ps1's
# mtime-based staleness check into "frontend up to date, skip
# rebuild" and Unsloth boots with an empty dist directory.
# rebuild" and Studio boots with an empty dist directory.
# Add-MpPreference accepts paths that do not yet exist.
foreach ($p in @(
"$env:USERPROFILE\.unsloth",
@ -176,7 +176,7 @@ jobs:
}
}
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -214,7 +214,7 @@ jobs:
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
cat "$INFO"
- name: Add Unsloth shim to GITHUB_PATH
- name: Add Studio shim to GITHUB_PATH
run: |
SHIM_DIR=~/.unsloth/studio/bin
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
@ -227,10 +227,9 @@ jobs:
- name: Install OpenAI + Anthropic Python SDKs
run: python -m pip install 'openai>=1.50' 'anthropic>=0.40'
- name: Reset auth + boot Unsloth (API-only)
- name: Reset auth + boot Studio (API-only)
run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -245,7 +244,7 @@ jobs:
fi
sleep 1
done
echo "Unsloth did not become healthy in 180s"
echo "Studio did not become healthy in 180s"
tail -200 logs/studio.log
exit 1
@ -282,7 +281,7 @@ jobs:
# Retry the load step a few times so a transient TCP RST during
# llama-server warm-up (Windows runner image churn,
# windows-latest -> windows-2025-vs2026 rollout) doesn't fail
# the whole job. The Unsloth backend's _wait_for_health now
# the whole job. The Studio backend's _wait_for_health now
# catches httpx.ReadError too; this retry layer covers the
# cases the backend can't recover from on its own.
LOAD_OK=0
@ -383,15 +382,15 @@ jobs:
print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded")
PY
- name: Stop Unsloth
- name: Stop Studio
if: always()
# Run as cmd so we are not running through the Git Bash shell;
# Git Bash on windows-latest has been observed to exit 143
# (SIGTERM) from any inline kill/sleep block, masking a green
# test run. The runner reclaims the Unsloth child process at
# test run. The runner reclaims the Studio child process at
# job end either way, so just emit a marker and exit 0.
shell: cmd
run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
- name: Collect llama-server logs
if: always()
@ -399,10 +398,10 @@ jobs:
# copy must not fail an otherwise-green job.
continue-on-error: true
shell: bash
# Copy llama-server's own stdout/stderr (teed by Unsloth under
# Copy llama-server's own stdout/stderr (teed by Studio under
# ~/.unsloth/studio/logs/llama-server/) into the workspace so
# upload-artifact can pick it up. Crucial for diagnosing a
# subprocess crash where Unsloth's traceback only shows the
# subprocess crash where Studio's traceback only shows the
# symptom (httpx ReadError) but not the cause.
run: |
mkdir -p logs/llama-server
@ -440,14 +439,14 @@ jobs:
# (211 s on first run; subsequent runs hit the cache, but the
# one-time cost recurs every time the cache key bumps). Use
# main's `--local-dir gguf-cache` pattern: cache the flat .gguf
# only, pass an absolute path to Unsloth's /api/inference/load.
# only, pass an absolute path to Studio's /api/inference/load.
# The OpenAI/Anth and JSON+images jobs still cover the
# gguf_variant resolution path.
GGUF_REPO: unsloth/Qwen3.5-2B-GGUF
GGUF_FILE: Qwen3.5-2B-UD-Q4_K_XL.gguf
STUDIO_PORT: '18898'
# Force UTF-8 for stdio (Windows defaults to cp1252; hf
# download / Unsloth CLI print "✓" checkmarks and crash
# download / Studio CLI print "✓" checkmarks and crash
# otherwise).
PYTHONIOENCODING: utf-8
PYTHONUTF8: '1'
@ -508,7 +507,7 @@ jobs:
# studio-windows-update-smoke.yml for the full rationale --
# creating an empty studio/frontend/dist trips setup.ps1's
# mtime-based staleness check into "frontend up to date, skip
# rebuild" and Unsloth boots with an empty dist directory.
# rebuild" and Studio boots with an empty dist directory.
# Add-MpPreference accepts paths that do not yet exist.
foreach ($p in @(
"$env:USERPROFILE\.unsloth",
@ -524,7 +523,7 @@ jobs:
}
}
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -562,7 +561,7 @@ jobs:
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
cat "$INFO"
- name: Add Unsloth shim to GITHUB_PATH
- name: Add Studio shim to GITHUB_PATH
run: |
SHIM_DIR=~/.unsloth/studio/bin
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
@ -572,9 +571,9 @@ jobs:
fi
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
- name: Reset auth + boot Unsloth (API-only, default tool policy)
- name: Reset auth + boot Studio (API-only, default tool policy)
run: |
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -608,7 +607,7 @@ jobs:
# raw string, but we cannot embed `\a` etc. in JSON without
# JSON-string-escaping every backslash. Replace `\` with `/`
# via bash parameter expansion -- pathlib.Path on Windows
# accepts forward slashes natively, so Unsloth's loader sees
# accepts forward slashes natively, so Studio's loader sees
# a normal path.
GGUF_PATH="${GITHUB_WORKSPACE//\\//}/gguf-cache/${GGUF_FILE}"
ls -lh "$GGUF_PATH"
@ -635,8 +634,6 @@ jobs:
python - <<'PY'
import json
import os
import time
import urllib.error
import urllib.request
BASE = os.environ["BASE_URL"]
@ -659,41 +656,10 @@ jobs:
"Content-Type": "application/json",
},
)
# Shared CI runners stall sporadically, so retry transport-level
# failures only; HTTP status errors surface immediately. Bounded
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
with urllib.request.urlopen(req, timeout = timeout) as resp:
return resp.status, json.loads(resp.read().decode())
def post_sse(path, body, *, timeout = 600, retries = 1, soft = False):
# The server-side agentic loop always answers over SSE. A
# shared CI runner can stall the stream transport (the
# connection opening, or a mid-stream read) even when Unsloth
# is healthy, so harden the read three ways:
# * retry a transport stall once with a fresh request,
# capped at 300s (a healthy server answers a retry
# quickly, a wedged one never does);
# * return any text already streamed before a stall, so a
# stall on the trailing tokens -- after the answer
# arrived -- still counts;
# * when every attempt yields nothing, a hard call
# re-raises while a soft call (the best-effort
# server-side tool probes) returns None so the caller
# can WARN instead of sinking the whole job.
# HTTP status errors always surface immediately.
def post_sse(path, body, *, timeout = 600):
body = {**body, "stream": True}
data = json.dumps(body).encode()
req = urllib.request.Request(
@ -705,43 +671,24 @@ jobs:
"Content-Type": "application/json",
},
)
for attempt in range(retries + 1):
parts = []
t = timeout if attempt == 0 else min(timeout, 300)
try:
with urllib.request.urlopen(req, timeout = t) as resp:
for raw in resp:
line = raw.decode().strip()
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
for choice in chunk.get("choices", []):
delta = choice.get("delta", {}) or {}
if delta.get("content"):
parts.append(delta["content"])
return "".join(parts)
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
# Text already streamed is a valid signal -- keep it
# rather than re-running a heavy generation.
if parts:
joined = "".join(parts)
print(f"[retry-sse] {path}: {exc!r}; keeping {len(joined)} partial chars", flush = True)
return joined
if attempt == retries:
if soft:
print(f"[tools] WARN {path}: SSE transport stalled with no data ({exc!r}) -- non-blocking", flush = True)
return None
raise
print(f"[retry-sse] {path}: {exc!r}", flush = True)
time.sleep(15)
parts = []
with urllib.request.urlopen(req, timeout = timeout) as resp:
for raw in resp:
line = raw.decode().strip()
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
for choice in chunk.get("choices", []):
delta = choice.get("delta", {}) or {}
if delta.get("content"):
parts.append(delta["content"])
return "".join(parts)
# ── 1. Standard OpenAI function calling ──────────────────────
weather_tool = {
@ -784,24 +731,16 @@ jobs:
)
# ── 2. Server-side python tool ───────────────────────────────
# Bound each soft probe to a single 180s attempt (timeout=180,
# retries=0): this job runs two of them back-to-back under a
# 30-minute cap, so the default 600+15+300s per stall could hit
# the workflow timeout before the thinking checks run. A soft
# probe only WARNs anyway, so a retry buys nothing.
content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}],
"enable_tools": True,
"permission_mode": "full",
"enabled_tools": ["python"],
"session_id": "ci-tool-calling-py",
"temperature": TEMP,
"seed": SEED,
"max_tokens": 600,
}, timeout = 180, retries = 0, soft = True)
if content is None:
print("[tools] WARN python tool: SSE transport stalled after retries -- non-blocking")
elif "56088" in content or "56,088" in content:
})
if "56088" in content or "56,088" in content:
print(f"[tools] PASS python tool ({len(content)} chars, found 56088)")
else:
assert content, "python tool: SSE stream empty"
@ -818,16 +757,13 @@ jobs:
content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Use the terminal tool to run `echo hello-bash-tool` and tell me the exact output."}],
"enable_tools": True,
"permission_mode": "full",
"enabled_tools": ["terminal"],
"session_id": "ci-tool-calling-bash",
"temperature": TEMP,
"seed": SEED,
"max_tokens": 600,
}, timeout = 180, retries = 0, soft = True)
if content is None:
print("[tools] WARN terminal tool: SSE transport stalled after retries -- non-blocking")
elif "hello-bash-tool" in content:
})
if "hello-bash-tool" in content:
print(f"[tools] PASS terminal tool ({len(content)} chars)")
else:
assert content, "terminal tool: SSE stream empty"
@ -843,13 +779,12 @@ jobs:
content = post_sse("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
"enable_tools": True,
"permission_mode": "full",
"enabled_tools": ["web_search"],
"session_id": "ci-tool-calling-web",
"temperature": TEMP,
"seed": SEED,
"max_tokens": 400,
}, timeout = 180, retries = 0)
})
print(f"[tools] PASS web_search stream ({len(content)} chars)")
except Exception as exc:
print(f"[tools] WARN web_search probe failed (non-blocking): {exc}")
@ -883,15 +818,15 @@ jobs:
print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)")
PY
- name: Stop Unsloth
- name: Stop Studio
if: always()
# Run as cmd so we are not running through the Git Bash shell;
# Git Bash on windows-latest has been observed to exit 143
# (SIGTERM) from any inline kill/sleep block, masking a green
# test run. The runner reclaims the Unsloth child process at
# test run. The runner reclaims the Studio child process at
# job end either way, so just emit a marker and exit 0.
shell: cmd
run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
- name: Collect llama-server logs
if: always()
@ -899,10 +834,10 @@ jobs:
# copy must not fail an otherwise-green job.
continue-on-error: true
shell: bash
# Copy llama-server's own stdout/stderr (teed by Unsloth under
# Copy llama-server's own stdout/stderr (teed by Studio under
# ~/.unsloth/studio/logs/llama-server/) into the workspace so
# upload-artifact can pick it up. Crucial for diagnosing a
# subprocess crash where Unsloth's traceback only shows the
# subprocess crash where Studio's traceback only shows the
# symptom (httpx ReadError) but not the cause.
run: |
mkdir -p logs/llama-server
@ -940,7 +875,7 @@ jobs:
STUDIO_PORT: '18899'
HF_HOME: ${{ github.workspace }}/hf-cache
# Force UTF-8 for stdio (Windows defaults to cp1252; hf
# download / Unsloth CLI print "✓" checkmarks and crash
# download / Studio CLI print "✓" checkmarks and crash
# otherwise).
PYTHONIOENCODING: utf-8
PYTHONUTF8: '1'
@ -1006,7 +941,7 @@ jobs:
# studio-windows-update-smoke.yml for the full rationale --
# creating an empty studio/frontend/dist trips setup.ps1's
# mtime-based staleness check into "frontend up to date, skip
# rebuild" and Unsloth boots with an empty dist directory.
# rebuild" and Studio boots with an empty dist directory.
# Add-MpPreference accepts paths that do not yet exist.
foreach ($p in @(
"$env:USERPROFILE\.unsloth",
@ -1022,7 +957,7 @@ jobs:
}
}
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -1060,7 +995,7 @@ jobs:
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
cat "$INFO"
- name: Add Unsloth shim to GITHUB_PATH
- name: Add Studio shim to GITHUB_PATH
run: |
SHIM_DIR=~/.unsloth/studio/bin
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
@ -1073,9 +1008,9 @@ jobs:
- name: Install OpenAI + Anthropic Python SDKs
run: python -m pip install 'openai>=1.50' 'anthropic>=0.40'
- name: Reset auth + boot Unsloth (API-only)
- name: Reset auth + boot Studio (API-only)
run: |
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -1128,8 +1063,6 @@ jobs:
import base64
import json
import os
import time
import urllib.error
import urllib.request
from openai import OpenAI
from anthropic import Anthropic
@ -1149,24 +1082,8 @@ jobs:
"Content-Type": "application/json",
},
)
# Shared CI runners stall sporadically, so retry transport-level
# failures only; HTTP status errors surface immediately. Bounded
# to fit the job's timeout-minutes: short probes get 3 full
# attempts, long probes one retry capped at 300s (a healthy
# server answers a retry quickly; a stalled one never does).
attempts = 3 if timeout <= 300 else 2
for attempt in range(attempts):
try:
t = timeout if attempt == 0 else min(timeout, 300)
with urllib.request.urlopen(req, timeout = t) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError:
raise
except (TimeoutError, ConnectionError, urllib.error.URLError) as exc:
if attempt == attempts - 1:
raise
print(f"[retry] {path}: {exc!r}", flush = True)
time.sleep(15)
with urllib.request.urlopen(req, timeout = timeout) as resp:
return resp.status, json.loads(resp.read().decode())
# ── 1. response_format = json_object (JSON mode) ─────────────
status, data = post("/v1/chat/completions", {
@ -1263,7 +1180,7 @@ jobs:
except Exception as exc:
print(
f"[image/openai] WARN image_url SDK call raised: {type(exc).__name__}: "
f"{exc}. Unsloth successfully forwarded the request; failure here is "
f"{exc}. Studio successfully forwarded the request; failure here is "
f"upstream llama.cpp vision behaviour."
)
@ -1304,19 +1221,19 @@ jobs:
print(
f"[image/anthropic] WARN anthropic image SDK call raised: "
f"{type(exc).__name__}: {exc}. Likely upstream llama.cpp vision "
f"behaviour, NOT an Unsloth regression."
f"behaviour, NOT a Studio regression."
)
PY
- name: Stop Unsloth
- name: Stop Studio
if: always()
# Run as cmd so we are not running through the Git Bash shell;
# Git Bash on windows-latest has been observed to exit 143
# (SIGTERM) from any inline kill/sleep block, masking a green
# test run. The runner reclaims the Unsloth child process at
# test run. The runner reclaims the Studio child process at
# job end either way, so just emit a marker and exit 0.
shell: cmd
run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
- name: Collect llama-server logs
if: always()
@ -1324,10 +1241,10 @@ jobs:
# copy must not fail an otherwise-green job.
continue-on-error: true
shell: bash
# Copy llama-server's own stdout/stderr (teed by Unsloth under
# Copy llama-server's own stdout/stderr (teed by Studio under
# ~/.unsloth/studio/logs/llama-server/) into the workspace so
# upload-artifact can pick it up. Crucial for diagnosing a
# subprocess crash where Unsloth's traceback only shows the
# subprocess crash where Studio's traceback only shows the
# symptom (httpx ReadError) but not the cause.
run: |
mkdir -p logs/llama-server
@ -1349,7 +1266,7 @@ jobs:
# ── folded from studio-windows-no-vs-smoke.yml: install + run with no Visual Studio ──
no-vs-cpu:
name: Unsloth install + inference without Visual Studio
name: Studio install + inference without Visual Studio
runs-on: windows-latest
timeout-minutes: 35
defaults:
@ -1503,7 +1420,7 @@ jobs:
python -m pip install torch --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple
python -c "import torch; print('torch', torch.__version__, 'cuda?', torch.cuda.is_available())"
- name: Install Unsloth (--local, --no-torch) with no build tools present
- name: Install Studio (--local, --no-torch) with no build tools present
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -1539,15 +1456,15 @@ jobs:
echo "Prebuilt installed with no build tools:"
cat "$INFO"
- name: Add Unsloth shim to GITHUB_PATH
- name: Add Studio shim to GITHUB_PATH
run: |
SHIM_DIR=~/.unsloth/studio/bin
[ -f "$SHIM_DIR/unsloth.exe" ] || { echo "::error::unsloth.exe shim not found"; ls -la ~/.unsloth/studio/ || true; exit 1; }
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
- name: Reset auth + boot Unsloth (API-only)
- name: Reset auth + boot Studio (API-only)
run: |
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -1614,10 +1531,10 @@ jobs:
}
Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
- name: Stop Unsloth
- name: Stop Studio
if: always()
shell: cmd
run: echo Stop Unsloth (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
run: echo Stop Studio (no-op; runner reclaims STUDIO_PID=%STUDIO_PID% at job end)
- name: Collect llama-server logs
if: always()
@ -1889,11 +1806,8 @@ jobs:
# (step/substep -> Write-StudioStdoutMirror / Get-StudioAnsi).
$script:StudioVtOk = $false
$script:UnslothVerbose = $false
# Get-HostMachineArch is reached only on the absent path, where
# Test-VCRedistInstalled consults it before trusting the System32 DLL, so
# part A passes without it and only the clean-box part fails.
foreach ($fn in @('Get-StudioAnsi', 'Write-StudioStdoutMirror', 'step', 'substep',
'Invoke-SetupCommand', 'Refresh-Environment', 'Get-HostMachineArch',
'Invoke-SetupCommand', 'Refresh-Environment',
'Test-VCRedistInstalled', 'Ensure-VCRedist')) {
$src = Get-FunctionSource -Path $setup -Name $fn
if (-not $src) { throw "Function '$fn' not found in setup.ps1" }

View file

@ -4,11 +4,11 @@
# Windows counterpart to studio-ui-smoke.yml / studio-mac-ui-smoke.yml.
# Same Playwright + Chromium end-to-end chat UI flow + extra UI flow,
# but on the FREE windows-latest runner so we catch Windows-specific
# regressions in the install path (install.ps1), the Unsloth CLI's
# regressions in the install path (install.ps1), the Studio CLI's
# Windows process-management branches, and the llama.cpp prebuilt's
# Windows HTTP layer.
name: Windows Unsloth UI CI
name: Windows Studio UI CI
on:
pull_request:
@ -19,7 +19,6 @@ on:
- 'install.ps1'
- 'pyproject.toml'
- 'tests/studio/**'
- '.github/scripts/run-studio-permission-browser.sh'
- '.github/workflows/studio-windows-ui-smoke.yml'
push:
branches: [main, pip]
@ -50,7 +49,7 @@ jobs:
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
STUDIO_PORT: '18896'
HF_HOME: ${{ github.workspace }}/hf-cache
# Force UTF-8 for stdio so Python tools (hf download, Unsloth
# Force UTF-8 for stdio so Python tools (hf download, Studio
# CLI, etc.) can print Unicode characters like the success
# checkmark "✓". Windows defaults to cp1252 / charmap and
# any tool that prints "OK ✓" hits a UnicodeEncodeError.
@ -122,7 +121,7 @@ jobs:
# studio-windows-update-smoke.yml for the full rationale --
# creating an empty studio/frontend/dist trips setup.ps1's
# mtime-based staleness check into "frontend up to date, skip
# rebuild" and Unsloth boots with an empty dist directory.
# rebuild" and Studio boots with an empty dist directory.
# Add-MpPreference accepts paths that do not yet exist.
foreach ($p in @(
"$env:USERPROFILE\.unsloth",
@ -149,7 +148,7 @@ jobs:
Set-Content -LiteralPath (Join-Path $appDir 'launch-studio.vbs') -Value 'WScript.Echo "legacy"' -Encoding Unicode
Write-Host "seeded legacy launch-studio.vbs at $appDir"
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
# install.ps1 is the supported Windows installer. install.sh
# has no Windows branch (apt-get / brew calls). The PS1
# script's `Install-UnslothStudio @args` line at the bottom
@ -206,7 +205,7 @@ jobs:
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
cat "$INFO"
- name: Assert Unsloth launcher chain (no VBS, hidden PowerShell shortcut)
- name: Assert Studio launcher chain (no VBS, hidden PowerShell shortcut)
# The shortcut launch path is otherwise untested here (the steps below
# boot `unsloth studio` directly). Guard against re-introducing the VBS
# that tripped Kaspersky HEUR:Trojan.VBS.Agent.gen and against the .lnk
@ -235,7 +234,7 @@ jobs:
}
Write-Host "launcher chain OK (no VBS; hidden powershell over launch-studio.ps1)"
- name: Launch Unsloth via the shortcut and assert health
- name: Launch Studio via the shortcut and assert health
# Run the exact command the .lnk stores (hidden PowerShell over
# launch-studio.ps1) and confirm it brings the backend up. This is the
# only step that proves the shortcut launch is not silently broken.
@ -266,10 +265,10 @@ jobs:
$owner = (Get-NetTCPConnection -LocalPort $foundPort -State Listen -ErrorAction Stop | Select-Object -First 1).OwningProcess
if ($owner) { taskkill /PID $owner /T /F 2>$null | Out-Null }
} catch {}
if (-not $foundPort) { throw "Unsloth did not become healthy when launched via the shortcut" }
Write-Host "Unsloth healthy on port $foundPort (launched via the shortcut)"
if (-not $foundPort) { throw "Studio did not become healthy when launched via the shortcut" }
Write-Host "Studio healthy on port $foundPort (launched via the shortcut)"
- name: Add Unsloth shim to GITHUB_PATH
- name: Add Studio shim to GITHUB_PATH
# install.ps1 puts unsloth.exe at $StudioHome\bin\unsloth.exe
# and adds that dir to the User PATH via the Windows registry.
# Registry-level PATH updates don't propagate to a running
@ -285,7 +284,7 @@ jobs:
fi
# GITHUB_PATH wants Windows-style paths; convert via cygpath.
cygpath -w "$SHIM_DIR" >> "$GITHUB_PATH"
echo "Added Unsloth shim dir to PATH: $(cygpath -w "$SHIM_DIR")"
echo "Added Studio shim dir to PATH: $(cygpath -w "$SHIM_DIR")"
- name: Install Playwright + Chromium
# No --with-deps on Windows: that flag installs Linux apt
@ -295,10 +294,9 @@ jobs:
python -m pip install 'playwright>=1.45'
python -m playwright install chromium
- name: Reset auth + boot Unsloth
- name: Reset auth + boot Studio
run: |
# Wipe (not reset-password): the boot below must re-seed a fresh .bootstrap_password.
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
@ -341,19 +339,15 @@ jobs:
mkdir -p logs/playwright
python tests/studio/playwright_chat_ui.py
- name: Stop Unsloth (chat-ui ends with Shutdown click; this is belt-and-suspenders)
- name: Stop Studio (chat-ui ends with Shutdown click; this is belt-and-suspenders)
if: always()
run: |
kill "${STUDIO_PID}" 2>/dev/null || true
sleep 2
- name: Edge permission controls
- name: Reset auth + boot Studio for extra UI tests (port 18897)
run: |
bash .github/scripts/run-studio-permission-browser.sh 18895 chromium msedge
- name: Reset auth + boot Unsloth for extra UI tests (port 18897)
run: |
rm -rf ~/.unsloth/studio/auth
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18897 \
> logs/studio_extra.log 2>&1 &
@ -378,7 +372,7 @@ jobs:
echo "STUDIO_EXTRA_OLD_PW=$OLD" >> "$GITHUB_ENV"
echo "STUDIO_EXTRA_NEW_PW=$NEW" >> "$GITHUB_ENV"
- name: Drive Compare/Recipes/Export/Unsloth/Settings with Playwright
- name: Drive Compare/Recipes/Export/Studio/Settings with Playwright
env:
BASE_URL: http://127.0.0.1:18897
STUDIO_OLD_PW: ${{ env.STUDIO_EXTRA_OLD_PW }}
@ -392,7 +386,7 @@ jobs:
mkdir -p logs/playwright_extra
python tests/studio/playwright_extra_ui.py
- name: Stop second Unsloth
- name: Stop second Studio
if: always()
run: |
kill "${STUDIO_EXTRA_PID}" 2>/dev/null || true
@ -408,7 +402,5 @@ jobs:
logs/studio_extra.log
logs/install.log
logs/playwright
logs/playwright-permissions-*
logs/playwright_extra
logs/studio-permissions-*.log
retention-days: 7

View file

@ -5,19 +5,19 @@
# studio-mac-update-smoke.yml. Verifies that on the FREE
# windows-latest runner:
#
# 1. install.ps1 --local --no-torch installs Unsloth AND auto-fetches
# 1. install.ps1 --local --no-torch installs Studio AND auto-fetches
# the prebuilt llama.cpp Windows binary (app-<tag>-windows-x64-cpu
# from unslothai/llama.cpp). Hitting the source-build fallback is
# treated as an Unsloth bug -- Unsloth must always pick the
# treated as an Unsloth bug -- Studio must always pick the
# prebuilt on Windows.
# 2. unsloth studio update --local is idempotent. Two consecutive
# runs both report "prebuilt up to date and validated", no
# source-build fallback. The CLI's _find_setup_script picks
# setup.ps1 on Windows automatically.
# 3. The installed Unsloth still boots and /api/health returns
# 3. The installed Studio still boots and /api/health returns
# healthy after the update path.
name: Windows Unsloth Update CI
name: Windows Studio Update CI
on:
pull_request:
@ -45,7 +45,7 @@ permissions:
jobs:
update-idempotency:
name: Unsloth Updating Tests
name: Studio Updating Tests
runs-on: windows-latest
timeout-minutes: 30
defaults:
@ -53,7 +53,7 @@ jobs:
shell: bash
env:
# Force UTF-8 for stdio (Windows defaults to cp1252; hf
# download / Unsloth CLI print "✓" checkmarks and crash
# download / Studio CLI print "✓" checkmarks and crash
# otherwise).
PYTHONIOENCODING: utf-8
PYTHONUTF8: '1'
@ -90,7 +90,7 @@ jobs:
# reuses the existing Node with no download.
#
# (2) Defender. windows-latest's real-time scan opens / hashes
# every file Unsloth writes during install (Vite output =
# every file Studio writes during install (Vite output =
# thousands of small chunks, uv pip = wheel-extraction =
# thousands of small files). The latency dominates the
# 200 s frontend build and the 90 s deps install. Adding
@ -109,7 +109,7 @@ jobs:
# setup.ps1 line 1281-1296's mtime-based "is the frontend
# stale?" check into "up to date, skip rebuild", because the
# newly-created dist's mtime is younger than every source
# file. Unsloth then boots with an empty dist and 500s on
# file. Studio then boots with an empty dist and 500s on
# GET / with FileNotFoundError: dist\index.html. See run
# 25546676715 / job 74984469728.
# Add-MpPreference accepts paths that do not yet exist; the
@ -129,7 +129,7 @@ jobs:
}
}
- name: Install Unsloth (--local, --no-torch)
- name: Install Studio (--local, --no-torch)
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -168,7 +168,7 @@ jobs:
echo "install.ps1 installed the Windows prebuilt llama.cpp:"
cat "$INFO"
- name: Add Unsloth shim to GITHUB_PATH
- name: Add Studio shim to GITHUB_PATH
run: |
SHIM_DIR=~/.unsloth/studio/bin
if [ ! -f "$SHIM_DIR/unsloth.exe" ]; then
@ -198,31 +198,6 @@ jobs:
fi
echo "update path took the prebuilt fast path"
- name: Update must keep the --no-torch install GGUF-only
run: |
# `unsloth studio update` exports no UNSLOTH_NO_TORCH, so setup.ps1 has
# to recover the mode from the install manifest. Without that it reads
# the missing torch as a stale venv and tries to delete the venv it is
# running out of, and the shared dependency pass pulls torch back in.
# The skip line only prints when the dependency pass actually runs, so
# don't demand it if the fast path short-circuited that pass.
if grep -q "running ordered dependency installation" logs/update.log \
&& ! grep -q "skipping direct PyTorch and Triton installation (no-torch mode)" logs/update.log; then
echo "::error::studio update left no-torch mode; it would reinstall PyTorch."
grep -iE "no-torch|stale venv|PyTorch" logs/update.log | tail -40
exit 1
fi
PY="$HOME/.unsloth/studio/unsloth_studio/Scripts/python.exe"
if [ ! -f "$PY" ]; then
echo "::error::studio venv interpreter missing at $PY"
exit 1
fi
if "$PY" -c "import torch" 2>/dev/null; then
echo "::error::torch was reinstalled into the --no-torch venv."
exit 1
fi
echo "update preserved no-torch mode"
- name: Second update must also be a no-op
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -237,7 +212,7 @@ jobs:
grep -qE "prebuilt up to date and validated|prebuilt installed and validated" logs/update2.log
echo "second update was clean"
- name: Boot Unsloth briefly to confirm the install is still usable
- name: Boot Studio briefly to confirm the install is still usable
run: |
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p 18891 \
@ -264,13 +239,13 @@ jobs:
sleep 1
done
if [ -z "$HEALTHY" ]; then
echo "Unsloth failed to come up after \`update\`"
echo "Studio failed to come up after \`update\`"
tail -200 logs/studio.log
kill "$PID" 2>/dev/null || true
exit 1
fi
kill "$PID" 2>/dev/null || true
echo "post-update Unsloth /api/health OK"
echo "post-update Studio /api/health OK"
- name: Uninstall and verify clean
# Round-trip through scripts/uninstall.ps1 against the default

View file

@ -3,7 +3,7 @@
# Builds the PyPI wheel from the PR branch, then verifies the built wheel
# actually contains what we expect to ship and does NOT contain the broken
# Unsloth bundle that 2026.5.1 published. This is the single workflow that
# Studio bundle that 2026.5.1 published. This is the single workflow that
# would have blocked the 2026.5.1 release before twine upload.
#
# Verified locally end-to-end against this branch:
@ -12,7 +12,7 @@
# lockfile shipped, frontend dist shipped,
# no node_modules in wheel, no bun.lock in wheel,
# main bundle has unstable_Provider hits=1 (assistant-ui internals only).
# - Unsloth backend imports cleanly from the installed wheel with the
# - Studio backend imports cleanly from the installed wheel with the
# lightweight dep set below.
name: Wheel CI
@ -101,7 +101,7 @@ jobs:
hits = data.count("unstable_Provider:")
print(f"main bundle: {js[0]}")
print(f"unstable_Provider hits: {hits} (>=4 indicates 2026.5.1 regression)")
checks["bundle has no Unsloth unstable_Provider call site"] = (hits < 4)
checks["bundle has no Studio unstable_Provider call site"] = (hits < 4)
print()
for k, v in checks.items():
@ -109,7 +109,7 @@ jobs:
sys.exit(0 if all(checks.values()) else 1)
PY
- name: Unsloth backend import smoke
- name: Studio backend import smoke
# Imports `studio.backend.main:app` from the freshly-installed wheel in
# a clean venv. This catches the class of bug that 2026.5.1 shipped with:
# frontend dist missing, package-lock.json missing, or the wheel's Python
@ -125,32 +125,7 @@ jobs:
/tmp/v/bin/pip install --no-deps dist/unsloth-*.whl
# Run from /tmp so Python imports the installed package, not the source tree.
cd /tmp
/tmp/v/bin/python -c "from studio.backend.main import app; print('Unsloth backend OK:', app.title)"
- name: CLI without the Studio stack guides instead of tracebacking
# The smoke above installs studio.txt first, so it cannot catch a wheel
# that ships studio/ without declaring what it imports (#4701, #5260,
# #7147). Drop only structlog to reuse that venv without a re-download.
run: |
set -eu
/tmp/v/bin/pip uninstall -y structlog >/dev/null
cd /tmp
status=0
for args in "export ./nope ./out" "list-checkpoints"; do
echo "--- unsloth $args"
out=$(/tmp/v/bin/unsloth $args 2>&1 || true)
printf '%s\n' "$out"
case "$out" in
*Traceback*)
echo "FAIL: raw traceback instead of guidance"; status=1 ;;
esac
case "$out" in
*'unsloth studio update'*) ;;
*) echo "FAIL: no remediation in the message"; status=1 ;;
esac
done
/tmp/v/bin/pip install -q structlog >/dev/null
exit "$status"
/tmp/v/bin/python -c "from studio.backend.main import app; print('Studio backend OK:', app.title)"
- name: Upload wheel on failure
if: failure()

6
.gitignore vendored
View file

@ -208,9 +208,6 @@ tmp/
**/node_modules/
auth.db
# Packaging snapshot of the root CHANGELOG.md (written by build.sh)
studio/CHANGELOG.md
# Tauri local build/generated output
studio/src-tauri/target/
studio/src-tauri/gen/
@ -241,5 +238,4 @@ package-lock.json
!studio/package-lock.json
llama.cpp/
# Stray "~" dir some tools create from a literal ~ TMPDIR; never part of the repo.
~/
/temp/
/~/

View file

@ -1,88 +0,0 @@
# Changelog
Release notes for Unsloth and Unsloth Studio.
Unsloth Studio reads this file to show release notes inside the "New Unsloth
version" update popup. Edit it here and the popup picks the change up on the
next update check, with no release or rebuild required.
## Format
Every release is a level-2 heading whose first token is the version, optionally
followed by a date:
```md
## 2026.7.6 - 2026-07-22
```
`## [2026.7.6] - 2026-07-22` and `## v2026.7.6` also work. Everything under a
heading, up to the next level-2 heading, is that release's notes and renders as
Markdown in the popup.
Notes are matched to one exact version. When Studio offers an update to
`2026.7.6` it renders the `2026.7.6` section and nothing else. If that section
is missing, the popup links out to the online changelog rather than showing
notes from an unrelated release, so a new version needs its own section here
before its notes can appear.
Keep the newest release at the top. Lead each bullet with the change itself:
the collapsed popup highlights the first sentence and dims the rest.
`## Unreleased` is ignored by the popup, so it is safe to stage notes there and
rename the heading at release time.
<!-- Add new releases directly below this line. -->
## Unreleased
## 2026.7.5
### What's Changed
- AMD support is here. Train, run RL, chat with and deploy 500+ models on
Radeon, Instinct, Ryzen and data center GPUs across Windows, WSL and Linux,
up to 2x faster with 70% less VRAM and no accuracy loss.
- Intel XPU support lands in Studio, so Arc and Data Center GPUs run chat and
training alongside the NVIDIA, AMD and Apple paths.
- Local speech to text dictation runs fully offline, with slim Whisper bundles
and a picker for custom models.
- DoRA training is available in Studio, selectable next to LoRA and full
fine-tuning in the training tab.
- The update popup previews release notes inline, pulled from this file and
matched to the exact version being offered.
### AMD, 23 July update
Our AMD collaboration, custom Triton kernels and math algorithms bring local
training and inference to AMD hardware. The 23 July update builds on the
[AMD release](https://github.com/unslothai/unsloth/releases/tag/v0.1.501-beta):
- RDNA2 and Gorgon Halo are supported, and the installer no longer fails to
detect GPUs on Strix Halo and other AMD cards.
- RDNA4 handling is better, and HIP and ROCm failures are caught and fixed
automatically instead of stopping the install.
- Unified memory safetensors loading is 2x faster, with much faster gradient
checkpointing on unified memory devices.
- Voice dictation through whisper.cpp has preliminary support.
- Rollback environments left by installs no longer eat 5GB of disk. They are
cleaned up automatically.
Optimized ROCm builds cover GGUF and safetensors inference, and ROCm
compatibility is improved for MI300X and MI325X. Full guide:
[unsloth.ai/docs/basics/amd](https://unsloth.ai/docs/basics/amd).
### Running larger models
- Automatic GPU placement, or pick exactly which GPUs and layers to use.
- Move MoE expert layers into system memory so larger models fit.
- Split a model across several GPUs, or use tensor parallelism.
- Hardware settings are saved per model and quant.
### Also in this release
- Remote access with `unsloth studio --secure` over free HTTPS via Cloudflare.
- Web search reads PDF papers and manuals, and parallel tool calls, reasoning
output and tool retries are more reliable.
- The model download location is configurable, so weights can live on a second
drive instead of the default cache.
- Stalled Hugging Face XET downloads retry over standard HTTP, and existing
GGUF files are reused instead of downloaded again.

View file

@ -1,2 +0,0 @@
include _changelog_build.py
include CHANGELOG.md

122
README.md
View file

@ -11,7 +11,6 @@ Unsloth Studio lets you run and train models locally.
<p align="center">
<a href="#-features">Features</a> •
<a href="#-unsloth-news">News</a> •
<a href="#-install">Quickstart</a> •
<a href="#-free-notebooks">Notebooks</a> •
<a href="https://unsloth.ai/docs">Documentation</a>
@ -48,51 +47,15 @@ Unsloth Studio (Beta) lets you run and train text, [audio](https://unsloth.ai/do
* [Auto set inference settings](https://unsloth.ai/docs/new/studio/chat#auto-parameter-tuning) and customize chat templates.
* We work directly with teams behind [gpt-oss](https://docs.unsloth.ai/new/gpt-oss-how-to-run-and-fine-tune#unsloth-fixes-for-gpt-oss), [Qwen3](https://www.reddit.com/r/LocalLLaMA/comments/1kaodxu/qwen3_unsloth_dynamic_ggufs_128k_context_bug_fixes/), [Llama 4](https://github.com/ggml-org/llama.cpp/pull/12889), [Mistral](https://huggingface.co/mistralai/Mistral-Medium-3.5-128B/discussions/18), [Gemma 1-3](https://news.ycombinator.com/item?id=39671146), and [Phi-4](https://unsloth.ai/blog/phi4), where weve fixed bugs that improve model accuracy.
* Chat with images, audio, PDFs, code, DOCX and more. [Connect API providers](https://unsloth.ai/docs/integrations/connections) (OpenAI, Anthropic) or servers (vLLM, Ollama).
* [**Compare any two models**](https://unsloth.ai/docs/new/studio/chat#model-arena) side by side with the same prompt.
* **OpenAI/Anthropic-compatible APIs**: Serve local models through `/v1/chat/completions`, `/v1/responses` and `/v1/messages`.
* **Connect local models to agents**: Use `unsloth start` with Claude Code, Codex, Hermes and more.
* **Web/PDF search** can read PDF papers, manuals and other PDF results.
* **GGUF hardware controls**: Choose GPUs/layers, offload MoE experts, use multi-GPU or Tensor Parallelism.
* The opt-in **MCP control endpoint** lets AI clients manage models, training, recipes and exports.
### Training
* Train and RL **500+ models** up to **2x faster** with **70% less VRAM**; MoE up to **12x faster**.
* Train and run RL on [AMD GPUs](https://unsloth.ai/docs/basics/amd) across Windows, WSL and Linux.
* Train and RL **500+ models** up to **2x faster** with up to **70% less VRAM**, with no accuracy loss.
* Custom Triton and mathematical **kernels**. See some collabs we did with [PyTorch](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) and [Hugging Face](https://unsloth.ai/docs/new/faster-moe).
* **Data Recipes**: [Auto-create datasets](https://unsloth.ai/docs/new/studio/data-recipe) from **PDF, CSV, DOCX** etc. Edit data in a visual-node workflow.
* **[Reinforcement Learning](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide)** uses **80% less VRAM** for GRPO, FP8 and vision RL, with 7x longer contexts.
* [**Long-context training**](https://unsloth.ai/docs/new/3x-faster-training-packing): **3x faster**, 30% less VRAM and 500K+ context.
* Supports LoRA/QLoRA, full fine-tuning, RL, pretraining, 4-bit, 16-bit and FP8.
* Custom Triton and mathematical **kernels** built with PyTorch and Hugging Face.
* **[Reinforcement Learning](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide)** (RL): The most efficient [RL](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide) library, using **80% less VRAM** for GRPO, [FP8](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) etc.
* Supports full fine-tuning, RL, pretraining, 4-bit, 16-bit and, FP8 training.
* **Observability**: Monitor training live, track loss and GPU usage and customize graphs.
* [Multi-GPU](https://unsloth.ai/docs/basics/multi-gpu-training-with-unsloth) training is supported, with major improvements coming soon.
## 🚀 Unsloth Start
[Unsloth Start](https://unsloth.ai/docs/integrations/unsloth-start) connects [Claude Code](https://unsloth.ai/docs/basics/claude-code), [Codex](https://unsloth.ai/docs/basics/codex) and other agents to local models with one command.
Start Unsloth, load a model, open your project folder, then run:
```bash
unsloth start claude
```
Replace `claude` with any supported agent:
| Agent | Command |
| --- | --- |
| Claude Code | `unsloth start claude` |
| OpenAI Codex | `unsloth start codex` |
| Hermes Agent | `unsloth start hermes` |
| OpenClaw | `unsloth start openclaw` |
| OpenCode | `unsloth start opencode` |
| Pi Coding Agent | `unsloth start pi` |
Claude Code, Codex, OpenCode and Pi can keep their current model and use Unsloth as a local
subagent:
```bash
unsloth start claude --as-subagent --model unsloth/model-GGUF:quant
```
## 📥 Install
Unsloth can be used in two ways: through **[Unsloth Studio](https://unsloth.ai/docs/new/studio/)**, the web UI, or through **Unsloth Core**, the code-based version. Each has different requirements.
@ -102,8 +65,7 @@ Unsloth Studio (Beta) works on **Windows, Linux, WSL** and **macOS**.
* **CPU:** Supported for Chat and Data Recipes currently
* **NVIDIA:** Training works on RTX 30/40/50, Blackwell, DGX Spark, Station and more
* **macOS:** Training, MLX and GGUF inference are ALL supported.
* **AMD:** Training, RL, chat and deployment work on Windows, WSL and Linux. [Read the AMD guide](https://unsloth.ai/docs/basics/amd).
* **Vulkan:** GGUF inference is supported on [compatible GPUs, including Intel GPUs](https://github.com/unslothai/unsloth/pull/5819). Vulkan accelerates GGUF inference only; training still requires a supported PyTorch or MLX backend.
* **AMD:** Chat + Data works. Train with [Unsloth Core](#unsloth-core-code-based). Studio support is out soon.
* **Multi-GPU:** Available now, with a major upgrade on the way
#### macOS, Linux, WSL:
@ -112,35 +74,19 @@ curl -fsSL https://unsloth.ai/install.sh | sh
```
Use the same command to update.
To force the Vulkan llama.cpp backend, set `UNSLOTH_FORCE_VULKAN=1` **before installing or updating**. The setting selects the llama.cpp binary bundle, so setting it only when launching Studio cannot replace an existing CPU bundle:
```bash
export UNSLOTH_FORCE_VULKAN=1
curl -fsSL https://unsloth.ai/install.sh | sh
```
#### Windows:
```powershell
irm https://unsloth.ai/install.ps1 | iex
```
Use the same command to update.
To force the Vulkan llama.cpp backend, set the environment variable before running the installer or updater:
```powershell
$env:UNSLOTH_FORCE_VULKAN=1
irm https://unsloth.ai/install.ps1 | iex
```
Re-running the current installer replaces a previously selected CPU bundle when the backend differs. A separate Vulkan SDK is not required; the GPU driver must provide a working Vulkan runtime.
#### Launch
```bash
unsloth studio -p 8888
```
For LAN or cloud access, add `-H 0.0.0.0` (raw port only; add `--cloudflare` for a public URL). By default, Unsloth is accessible only locally.
For cloud or global access, add `-H 0.0.0.0`. By default, Unsloth is accessible only locally.
To reach Unsloth over HTTPS, use `unsloth studio --secure`. Unsloth stays bound to localhost and is reached only through a free Cloudflare tunnel, which publishes it at a public `https://*.trycloudflare.com` URL (it fails closed if the tunnel can't start, so the raw port is never exposed). This makes Unsloth reachable from the internet, so anyone with the link and API key can use it and run code: keep your API key private (see Remote access below).
To reach Studio over HTTPS, use `unsloth studio --secure`. Studio stays bound to localhost and is reached only through a free Cloudflare tunnel, which publishes it at a public `https://*.trycloudflare.com` URL (it fails closed if the tunnel can't start, so the raw port is never exposed). This makes Studio reachable from the internet, so anyone with the link and API key can use it and run code: keep your API key private (see Remote access below).
#### Docker
Use our [Docker image](https://hub.docker.com/r/unsloth/unsloth) ```unsloth/unsloth``` container. Run:
@ -176,7 +122,7 @@ You can use the same Docker image as Unsloth Studio.
#### AMD, Intel:
For RTX 50x, B200, 6000 GPUs: `uv pip install unsloth --torch-backend=auto`. Read our guides for: [Blackwell](https://unsloth.ai/docs/blog/fine-tuning-llms-with-blackwell-rtx-50-series-and-unsloth) and [DGX Spark](https://unsloth.ai/docs/blog/fine-tuning-llms-with-nvidia-dgx-spark-and-unsloth). <br>
To install Unsloth on **AMD** and **Intel** GPUs, follow our [AMD Guide](https://unsloth.ai/docs/basics/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel).
To install Unsloth on **AMD** and **Intel** GPUs, follow our [AMD Guide](https://unsloth.ai/docs/get-started/install/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel).
## 📒 Free Notebooks
@ -202,20 +148,13 @@ Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Ad
- See detailed documentation for Unsloth [here](https://unsloth.ai/docs)
## 🦥 Unsloth News
- **AMD training**: Train, run RL, chat and deploy on AMD GPUs across Windows, WSL and Linux. [Guide](https://unsloth.ai/docs/basics/amd)
- **GGUF hardware controls**: Choose GPU/layer placement, offload MoE experts and use multi-GPU or Tensor Parallelism. [#6414](https://github.com/unslothai/unsloth/pull/6414)
- **Local models for any agent**: Use `unsloth start` with Claude Code, Codex, Hermes, OpenCode, OpenClaw, Pi and more through Unsloth's OpenAI- and Anthropic-compatible APIs. [Guide](https://unsloth.ai/docs/basics/api)
- **MCP control endpoint**: Let compatible clients manage models, training, recipes, checkpoints and exports. [#7191](https://github.com/unslothai/unsloth/pull/7191)
- **Local inference reliability**: Resume long chats faster, recover stalled downloads and reuse existing GGUF files. [#7204](https://github.com/unslothai/unsloth/pull/7204) • [#6858](https://github.com/unslothai/unsloth/pull/6858) • [#7209](https://github.com/unslothai/unsloth/pull/7209)
- **New models**: [Qwen-AgentWorld](https://huggingface.co/unsloth/Qwen-AgentWorld-35B-A3B-GGUF), [Ornith](https://huggingface.co/unsloth/models?search=ornith), [Kimi K2.7 Code](https://unsloth.ai/docs/models/kimi-k2.7-code) and [MiniMax M3](https://unsloth.ai/docs/models/minimax-m3)
- **GLM-5.2**: Run Z.ai's 744B-parameter, 1M-context open model locally with Unsloth Dynamic GGUFs. [Guide](https://unsloth.ai/docs/models/glm-5.2)
- **DeepSeek-V4**: Run DeepSeek-V4-Flash locally with corrected multi-turn and tool-calling behavior. [Guide](https://unsloth.ai/docs/models/deepseek-v4)
- **DiffusionGemma**: Run and fine-tune Google's diffusion language model with 1.8x faster inference in Unsloth Studio. [Guide](https://unsloth.ai/docs/models/diffusiongemma)
- **Qwen3.6**: Run and train Qwen3.6 with MTP for 1.4-2.2x faster inference and NVFP4 quants for supported GPUs. [Guide](https://unsloth.ai/docs/models/qwen3.6)
- **Gemma 4**: Run and train Gemma 4 text, image and audio models with QAT, MTP, GGUF and MLX support. [Guide](https://unsloth.ai/docs/models/gemma-4)
- **MCP servers**: Connect local models to files, apps, databases and external tools through Model Context Protocol. [Guide](https://unsloth.ai/docs/basics/mcp)
- **Connections**: Mix local models with API providers (OpenAI, Anthropic) or servers (vLLM, Ollama) in the same interface. [Guide](https://unsloth.ai/docs/integrations/connections)
- **Connections**: Connect any API provider (OpenAI, Anthropic) or server (vLLM, Ollama). [Guide](https://unsloth.ai/docs/integrations/connections)
- **MTP**: Run Qwen3.6 MTP in Unsloth. MTP settings are autoset specific to your hardware. [Guide](https://unsloth.ai/docs/models/qwen3.6#mtp-guide)
- **API inference endpoint**: Deploy and run local LLMs in Claude Code, Codex tools. [Guide](https://unsloth.ai/docs/basics/api)
- **Qwen3.6**: Qwen3.6-35B-A3B can now be trained and run in Unsloth Studio. [Blog](https://unsloth.ai/docs/models/qwen3.6)
- **Gemma 4**: Run and train Googles new models directly in Unsloth. [Blog](https://unsloth.ai/docs/models/gemma-4)
- **Introducing Unsloth Studio**: our new web UI for running and training LLMs. [Blog](https://unsloth.ai/docs/new/studio)
- **Qwen3.5** - 0.8B, 2B, 4B, 9B, 27B, 35-A3B, 112B-A10B are now supported. [Guide + notebooks](https://unsloth.ai/docs/models/qwen3.5/fine-tune)
- Train **MoE LLMs 12x faster** with 35% less VRAM - DeepSeek, GLM, Qwen and gpt-oss. [Blog](https://unsloth.ai/docs/new/faster-moe)
- **Embedding models**: Unsloth now supports ~1.8-3.3x faster embedding fine-tuning. [Blog](https://unsloth.ai/docs/new/embedding-finetuning) • [Notebooks](https://unsloth.ai/docs/get-started/unsloth-notebooks#embedding-models)
- New **7x longer context RL** vs. all other setups, via our new batching algorithms. [Blog](https://unsloth.ai/docs/new/grpo-long-context)
@ -269,31 +208,16 @@ unsloth studio -p 8888
#### Remote access: `--secure` (HTTPS tunnel) vs raw port
By default `unsloth studio` binds to `127.0.0.1` (this machine only). To reach it from another device, pick one of:
- `--secure` (recommended): serve **only** through a free Cloudflare HTTPS link. Unsloth stays bound to localhost and the tunnel provides the public URL; it fails closed (does not start) if the tunnel can't come up, so the raw port is never exposed.
- `--secure` (recommended): serve **only** through a free Cloudflare HTTPS link. Studio stays bound to localhost and the tunnel provides the public URL; it fails closed (does not start) if the tunnel can't come up, so the raw port is never exposed.
```bash
unsloth studio --secure -p 8888
```
- `-H 0.0.0.0`: bind the raw port on all network interfaces, reachable from anywhere on the network (subject to your firewall). It does not create a public internet URL; add `--cloudflare` to also publish an internet-reachable `https://*.trycloudflare.com` link even behind a firewall. Only use this on a network you trust.
- `-H 0.0.0.0`: bind the raw port on all network interfaces, reachable from anywhere on the network. Only use this on a trusted network.
```bash
unsloth studio -H 0.0.0.0 -p 8888
```
The Cloudflare tunnel is **off by default**: `-H 0.0.0.0` exposes the raw port only, not a public internet URL. Pair the wildcard bind with `--cloudflare` (`unsloth studio -H 0.0.0.0 --cloudflare`) to also publish a public `https://*.trycloudflare.com` link, or prefer `--secure` (above), which keeps the raw port private. `--cloudflare` has no effect on a loopback bind.
On a wildcard bind Unsloth works out the address to share by asking `ifconfig.me` for the public IP, then asks `check-host.net` whether that port is reachable so it can tell you if a firewall is in the way. Both contact a third party. Set `UNSLOTH_STUDIO_DISABLE_PUBLIC_CHECK=1` to skip them; the banner then shows the LAN address and no reachability line.
The first time Unsloth is published on a public URL (`--secure` or `--cloudflare`) with the auto-generated admin password still in place, it asks for a new admin password in the terminal (masked input with confirmation) before the public link goes up. Without an attached terminal it warns instead and keeps the bootstrap deadline: Unsloth shuts down after `UNSLOTH_STUDIO_BOOTSTRAP_TIMEOUT` (default 1 hour) unless the password is changed in the web UI.
For headless setups that cannot answer that prompt, set the initial admin password non-interactively with `--password` (only takes effect when no password is set yet; if one already exists it is a hard error, so rotate later with `unsloth studio reset-password`):
```bash
unsloth studio --secure --password 'your-strong-password' # visible in `ps`/history
UNSLOTH_STUDIO_PASSWORD='your-strong-password' unsloth studio --secure # via env var
printf '%s\n' 'your-strong-password' | unsloth studio --secure --password - # via stdin
```
A literal `--password VALUE` is visible in the process list and shell history, so prefer the `UNSLOTH_STUDIO_PASSWORD` env var or `--password -` (stdin) for automation. This applies to any launch (public or a headless `-H 0.0.0.0` bind), and the password is set in the parent before the server binds, so it never reaches a re-executed child process.
Server-side tools (web search, Python and terminal code execution) run as your user and are on by default. Anyone who can reach the server with the API key can run code on this machine, so keep your API key private and pass `--disable-tools` when exposing Unsloth.
Server-side tools (web search, Python and terminal code execution) run as your user and are on by default. Anyone who can reach the server with the API key can run code on this machine, so keep your API key private and pass `--disable-tools` when exposing Studio.
#### Advanced launch options
Installer options can be passed as environment variables. On macOS, Linux and WSL place the variable after the pipe so the shell passes it to `sh`; on Windows set it with `$env:` before piping to `iex`.
@ -306,14 +230,6 @@ curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh
$env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex
```
Skip the post-install prompt that starts Unsloth (useful for automated installs):
```bash
curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_SKIP_AUTOSTART=1 sh
```
```powershell
$env:UNSLOTH_SKIP_AUTOSTART=1; irm https://unsloth.ai/install.ps1 | iex
```
Pin the Python version:
```bash
curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh
@ -342,9 +258,9 @@ UNSLOTH_NPM_REGISTRY=https://artifactory.example.com/api/npm/npm/ ./install.sh -
```powershell
$env:UNSLOTH_NPM_REGISTRY='https://artifactory.example.com/api/npm/npm/'; .\install.ps1 --local
```
It is threaded as `--registry` into the Unsloth frontend `npm`/`bun` installs; the supply-chain locks (7-day `min-release-age`, exact version pins) stay in force.
It is threaded as `--registry` into the Studio frontend `npm`/`bun` installs; the supply-chain locks (7-day `min-release-age`, exact version pins) stay in force.
Cap Unsloth's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`.
Cap Studio's native CPU thread pools on high-core hosts: `UNSLOTH_CPU_THREADS=8 unsloth studio -p 8888`.
#### Uninstall
The recommended way to fully remove Unsloth Studio is the matching uninstall script for your OS. It stops any running servers, removes the install dir, the launcher data dir, the desktop shortcut, and any platform-specific entries (macOS `.app` bundle + Launch Services on Mac; Start Menu, `HKCU\Software\Unsloth` registry key and user `PATH` entries on Windows):

View file

@ -1,36 +0,0 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Snapshot CHANGELOG.md into the studio package at build time.
CHANGELOG.md at the repo root stays the one file to edit. Copying it here,
rather than in build.sh, means every packaging path ships it, so release notes
still render when the popup cannot reach GitHub."""
from __future__ import annotations
import shutil
from pathlib import Path
from setuptools.command.build_py import build_py as _build_py
ROOT = Path(__file__).resolve().parent
SOURCE = ROOT / "CHANGELOG.md"
SNAPSHOT = ROOT / "studio" / "CHANGELOG.md"
class build_py(_build_py):
def run(self) -> None:
# Beside the sources only if writable (PEP 517 may build an immutable
# checkout); into the staging directory always.
if SOURCE.is_file():
try:
shutil.copyfile(SOURCE, SNAPSHOT)
except OSError:
pass
super().run()
if not SOURCE.is_file():
return
staged = Path(self.build_lib) / "studio" / "CHANGELOG.md"
staged.parent.mkdir(parents = True, exist_ok = True)
shutil.copyfile(SOURCE, staged)

View file

@ -4,9 +4,9 @@
set -euo pipefail
# PyPI/Unsloth release publishing must use `./build.sh publish` (or an
# equivalent stamp -> build -> verify-dist -> upload flow) so packaged Unsloth
# artifacts include the display-only Unsloth release version.
# PyPI/Studio release publishing must use `./build.sh publish` (or an
# equivalent stamp -> build -> verify-dist -> upload flow) so packaged Studio
# artifacts include the display-only Studio release version.
# 1. Build frontend (Vite outputs to dist/)
cd studio/frontend
@ -87,7 +87,7 @@ cd ../..
# 2. Clean old artifacts
rm -rf build dist *.egg-info
# 3. Stamp display-only Unsloth release metadata for packaged builds.
# 3. Stamp display-only Studio release metadata for packaged builds.
_STUDIO_BUILD_INFO="studio/backend/utils/_studio_release_build.py"
_STUDIO_BUILD_INFO_BACKUP="$(mktemp)"
cp "$_STUDIO_BUILD_INFO" "$_STUDIO_BUILD_INFO_BACKUP"
@ -103,13 +103,9 @@ else
STUDIO_STAMPED_VERSION="$(python scripts/stamp_studio_release.py)"
fi
# 4. Build wheel/sdist. _changelog_build.py snapshots CHANGELOG.md into the studio
# package so release notes render offline.
# 4. Build wheel/sdist
python -m build
# Drop the snapshot so a source checkout never serves a stale copy.
rm -f studio/CHANGELOG.md
if [ "${1:-}" = "publish" ]; then
python scripts/stamp_studio_release.py --verify-dist dist --expected "$STUDIO_STAMPED_VERSION"
fi

View file

@ -6,7 +6,6 @@
# irm | iex cannot forward arguments, so web installs take options as env vars set
# before the pipe (flags still work via .\install.ps1):
# $env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex # skip PyTorch (GGUF-only)
# $env:UNSLOTH_SKIP_AUTOSTART=1; irm https://unsloth.ai/install.ps1 | iex # do not prompt to launch
# $env:UNSLOTH_PYTHON='3.12'; irm https://unsloth.ai/install.ps1 | iex # pin Python version
# $env:UNSLOTH_STUDIO_HOME='C:\path'; irm https://unsloth.ai/install.ps1 | iex
# .\install.ps1 --no-torch # equivalent flag
@ -28,14 +27,6 @@ function Install-UnslothStudio {
}
}
function Clear-TauriInstallError {
param([string]$Message)
if ($TauriMode) {
Write-TauriLog "ERROR_CLEAR" $Message
[Console]::Error.WriteLine("[TAURI:ERROR_CLEAR] $Message")
}
}
function Format-TauriDiagBool {
param([bool]$Value)
if ($Value) { return "true" }
@ -57,32 +48,11 @@ function Install-UnslothStudio {
}
}
# Machine arch; Get-TauriDiagArch above reports the process. An emulated x64 shell on
# ARM64 reports AMD64, but PROCESSOR_ARCHITEW6432 is ARM64 in exactly that case.
function Get-HostMachineArch {
$osArch = ""
try { $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } catch { $osArch = "" }
$signals = @([string]$env:PROCESSOR_ARCHITEW6432, [string]$env:PROCESSOR_ARCHITECTURE, $osArch)
foreach ($s in $signals) {
if ($s.ToLowerInvariant() -eq "arm64") { return "arm64" }
}
foreach ($s in $signals) {
if ([string]::IsNullOrWhiteSpace($s)) { continue }
switch ($s.ToLowerInvariant()) {
"amd64" { return "x86_64" }
"x64" { return "x86_64" }
"x86" { return "x86" }
}
}
return "unknown"
}
function Get-TauriTorchIndexFamily {
param([string]$TorchIndexUrl)
if ($SkipTorch) { return "none" }
if ([string]::IsNullOrWhiteSpace($TorchIndexUrl)) { return "none" }
# Drop query/fragment first so a token-authenticated pin classifies by family.
$leaf = (($TorchIndexUrl -split '[?#]', 2)[0].TrimEnd('/') -split '/')[-1].ToLowerInvariant()
$leaf = ($TorchIndexUrl.TrimEnd('/') -split '/')[-1].ToLowerInvariant()
if (@("cpu", "cu118", "cu124", "cu126", "cu128", "cu130") -contains $leaf) { return $leaf }
if ($leaf -match '^rocm[0-9]+\.[0-9]+$') { return $leaf }
return "auto"
@ -91,8 +61,7 @@ function Install-UnslothStudio {
function Get-TauriGpuBranch {
param([string]$TorchIndexFamily)
if ($SkipTorch) { return "no_torch" }
# Require a digit after "cu" so /current or /custom isn't branded CUDA (parity ^cu[0-9]).
if ($TorchIndexFamily -match '^cu[0-9]') { return "cuda" }
if ($TorchIndexFamily -like "cu*") { return "cuda" }
if ($TorchIndexFamily -like "rocm*") { return "rocm" }
if ($TorchIndexFamily -eq "cpu") { return "cpu" }
return "unknown"
@ -114,14 +83,13 @@ function Install-UnslothStudio {
[int]$Code = 1
)
if ($Code -eq 0) { $Code = 1 }
Write-TauriLog "ERROR_DEFAULT" $Message
Write-TauriLog "ERROR" $Message
if (Get-Command Restore-StudioVenvRollback -CommandType Function -ErrorAction SilentlyContinue) {
Restore-StudioVenvRollback
}
if ($TauriMode) {
exit $Code
}
throw $Message
}
# ── Parse flags ──
@ -130,7 +98,6 @@ function Install-UnslothStudio {
$RepoRoot = ""
$TauriMode = $false
$SkipTorch = $false
$SkipAutostart = $false
$ShortcutsOnly = $false
$WithLlamaCppDir = ""
$argList = $args
@ -163,7 +130,6 @@ function Install-UnslothStudio {
# Env-var equivalent for web installs; an explicit flag still wins.
if ($env:UNSLOTH_NO_TORCH -in @('1', 'true', 'yes', 'on')) { $SkipTorch = $true }
if ($env:UNSLOTH_SKIP_AUTOSTART -in @('1', 'true', 'yes', 'on')) { $SkipAutostart = $true }
# Propagate to child processes so they also respect verbose mode.
# Process-scoped -- does not persist.
@ -206,7 +172,7 @@ function Install-UnslothStudio {
$envOverride = $env:STUDIO_HOME.Trim()
}
# Custom Unsloth roots are not supported with --tauri (desktop app still
# Custom Studio roots are not supported with --tauri (desktop app still
# resolves %USERPROFILE%\.unsloth\studio). Pass through if override == legacy.
if ($TauriMode -and $envOverride) {
$_tauriOverride = $envOverride
@ -497,70 +463,43 @@ function Install-UnslothStudio {
}
}
# Redact index-URL credentials (userinfo + ?query= + #fragment) from captured installer
# output before printing on failure; uv/pip errors echo the failing --index-url verbatim.
# Mirrors the other installers. Verbose mode streams uncaptured, so it isn't redacted.
function Redact-InstallOutput {
param([string]$Text)
if (-not $Text) { return $Text }
$Text = $Text -replace '(https?://)[^/@\s`]+@', '$1<redacted>@'
$Text = $Text -replace '([?&][^=\s&`]+)=[^&#\s`]+', '$1=<redacted>'
# A #token=... fragment is as sensitive as a query; URL-anchored.
return $Text -replace '(https?://[^\s`#]+)#[^\s`]+', '$1#<redacted>'
}
# Run native commands quietly by default to match install.sh behavior.
# Full command output is shown only when --verbose / UNSLOTH_VERBOSE=1.
function Invoke-InstallCommand {
param(
[Parameter(Mandatory = $true)][ScriptBlock]$Command,
[string]$Label = "install command"
[Parameter(Mandatory = $true)][ScriptBlock]$Command
)
# Installer-pinned index installs (torch) must beat an inherited uv mirror (#6898):
# for --default-index, clear the uv index env vars (restore in finally) and set
# UV_NO_CONFIG=1 so a uv.toml/pyproject index can't outrank the CLI pin (uv 0.10).
# Installer-pinned index installs (torch) must beat an inherited uv mirror
# (#6898): when the command pins an index, clear every uv index env var so
# it wins, then restore in finally. Other installs keep the user's mirror.
$savedUvIndex = $null
if ($Command.ToString() -match '--default-index') {
$savedUvIndex = @{}
foreach ($n in 'UV_DEFAULT_INDEX', 'UV_INDEX_URL', 'UV_INDEX', 'UV_EXTRA_INDEX_URL', 'UV_TORCH_BACKEND', 'UV_FIND_LINKS', 'UV_CONFIG_FILE', 'UV_NO_CONFIG') {
foreach ($n in 'UV_DEFAULT_INDEX', 'UV_INDEX_URL', 'UV_INDEX', 'UV_EXTRA_INDEX_URL') {
$savedUvIndex[$n] = [Environment]::GetEnvironmentVariable($n)
Remove-Item "Env:$n" -ErrorAction SilentlyContinue
}
$env:UV_NO_CONFIG = '1'
}
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
# Reset to avoid stale values from prior native commands.
$global:LASTEXITCODE = 0
Write-TauriLog "OUTPUT_CLEAR" $Label
if ($script:UnslothVerbose) {
# Merge stderr into stdout so progress/warning output stays visible
# without flipping $? on successful native commands (PS 5.1 treats
# stderr records as errors that set $? = $false even on exit code 0).
# Redact per record: uv echoes index URLs (credentials and all) in
# its errors, and verbose mode must not bypass the quiet path's
# redaction. ForEach-Object/Out-Host leave $LASTEXITCODE untouched.
& $Command 2>&1 | ForEach-Object { Redact-InstallOutput "$_" } | Out-Host
& $Command 2>&1 | Out-Host
} else {
$output = & $Command 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) {
Write-Host (Redact-InstallOutput $output) -ForegroundColor Red
Write-Host $output -ForegroundColor Red
}
}
$exitCode = [int]$LASTEXITCODE
if ($exitCode -eq 0) {
Clear-TauriInstallError "$Label recovered"
} else {
Write-TauriLog "ERROR_OUTPUT" "$Label failed (exit code $exitCode)"
}
return $exitCode
return [int]$LASTEXITCODE
} finally {
$ErrorActionPreference = $prevEap
if ($savedUvIndex) {
Remove-Item "Env:UV_NO_CONFIG" -ErrorAction SilentlyContinue
foreach ($n in $savedUvIndex.Keys) { if ($null -ne $savedUvIndex[$n]) { Set-Item "Env:$n" $savedUvIndex[$n] } }
}
if ($savedUvIndex) { foreach ($n in $savedUvIndex.Keys) { if ($null -ne $savedUvIndex[$n]) { Set-Item "Env:$n" $savedUvIndex[$n] } } }
}
}
@ -585,7 +524,7 @@ function Install-UnslothStudio {
}
$attempt = 1
while ($true) {
$code = Invoke-InstallCommand -Command $Command -Label $Label
$code = Invoke-InstallCommand $Command
if ($code -eq 0) { return 0 }
if ($attempt -ge $maxAttempts) { return $code }
substep ("retrying ""$Label"" after transient failure (attempt $($attempt + 1)/$maxAttempts, waiting ${delay}s)...") "Yellow"
@ -813,7 +752,7 @@ function Find-FreeLaunchPort {
return `$null
}
# If Unsloth is already healthy on any expected port, just open it and exit.
# If Studio is already healthy on any expected port, just open it and exit.
`$existingPort = Find-HealthyStudioPort
if (`$existingPort) {
Start-Process "http://localhost:`$existingPort"
@ -829,7 +768,7 @@ try {
`$haveMutex = `$true
}
if (-not `$haveMutex) {
# Another launcher is already running; wait for it to bring Unsloth up
# Another launcher is already running; wait for it to bring Studio up
`$deadline = (Get-Date).AddSeconds(`$timeoutSec)
while ((Get-Date) -lt `$deadline) {
`$port = Find-HealthyStudioPort
@ -1144,27 +1083,10 @@ exit 0
return $false
}
# The interpreter's own arch, asked of it: win-amd64|win-arm64|win32|"".
function Get-PythonPlatformTag {
param([string]$Exe)
try {
return (& $Exe -c "import sysconfig; print(sysconfig.get_platform())" 2>$null | Out-String).Trim().ToLowerInvariant()
} catch { return "" }
}
# Returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null.
# The resolved Path is passed to `uv venv --python` to prevent uv from
# re-resolving the version string back to a conda interpreter.
function Find-CompatiblePython {
# -X64Only: best installed x64 interpreter or $null, never ARM64. Last resort for
# Install-X64Python, where x64 of a lower-priority minor beats ARM64.
param([switch]$X64Only)
# Windows on ARM: prefer x64. pyarrow (via datasets) and hf-transfer ship no
# win_arm64 wheel, so a native ARM64 Python source-builds both and dies on CMake /
# Rust minutes in; x64 runs fine emulated. ARM64 is still returned when it is all
# there is, and the caller then bootstraps x64 or warns.
$preferX64 = $X64Only -or ((Get-HostMachineArch) -eq "arm64")
$candidates = @()
# Try the Python Launcher first (most reliable on Windows)
# py.exe resolves to the standard CPython install, not conda.
# Prefer the requested $PythonVersion, then newest-first fallback.
@ -1182,8 +1104,7 @@ exit 0
# Resolve the actual executable path and verify it is not conda-based
$resolvedExe = (& $pyLauncher.Source "-$minor" -c "import sys; print(sys.executable)" 2>$null | Out-String).Trim()
if ($resolvedExe -and (Test-Path $resolvedExe) -and -not (Test-IsCondaPython $resolvedExe)) {
if (-not $preferX64) { return @{ Version = $ver; Path = $resolvedExe; Arch = "" } }
$candidates += @{ Version = $ver; Path = $resolvedExe }
return @{ Version = $ver; Path = $resolvedExe }
}
}
} catch {}
@ -1204,53 +1125,11 @@ exit 0
try {
$out = & $cmd.Source --version 2>&1 | Out-String
if ($out -match "Python (3\.1[1-3])\.\d+") {
if (-not $preferX64) { return @{ Version = $Matches[1]; Path = $cmd.Source; Arch = "" } }
$candidates += @{ Version = $Matches[1]; Path = $cmd.Source }
return @{ Version = $Matches[1]; Path = $cmd.Source }
}
} catch {}
}
}
# `py -3.12` runs the launcher's preferred build, normally the native ARM64 one, so
# a same-minor x64 install that is neither preferred nor on PATH never becomes a
# candidate. `-3.12-64` cannot disambiguate (deprecated, it only means "not
# 32-bit"), so enumerate every registration with -0p and probe each path.
if ($preferX64) {
foreach ($pyLauncher in @(Get-Command py -All -CommandType Application -ErrorAction SilentlyContinue)) {
if ($pyLauncher.Source -match $script:CondaSkipPattern) { continue }
$listed = @()
try { $listed = @(& $pyLauncher.Source "-0p" 2>$null) } catch {}
foreach ($line in $listed) {
# " -V:3.12 * C:\...\python.exe": tag, optional default marker, path.
$m = [regex]::Match([string]$line, '(?i)^\s*-\S+\s+\*?\s*"?(?<p>\S.*?\.exe)"?\s*$')
if (-not $m.Success) { continue }
$exe = $m.Groups['p'].Value.Trim()
if ($candidates | Where-Object { $_.Path -eq $exe }) { continue }
if (-not (Test-Path -LiteralPath $exe)) { continue }
if (Test-IsCondaPython $exe) { continue }
try {
$out = & $exe --version 2>&1 | Out-String
if ($out -match "Python (3\.1[1-3])\.\d+") {
$candidates += @{ Version = $Matches[1]; Path = $exe }
}
} catch {}
}
}
}
# Prefer x64, but only within one minor: $minors is the caller's version preference,
# so ranking on arch alone would answer UNSLOTH_PYTHON=3.12 with an x64 3.13 and
# never bootstrap x64 3.12. Probing costs a subprocess, so non-ARM returned above.
foreach ($c in $candidates) {
$tag = Get-PythonPlatformTag $c.Path
$c.Arch = if ($tag -eq "win-amd64") { "x86_64" } elseif ($tag -eq "win-arm64") { "arm64" } else { "unknown" }
}
foreach ($minor in $minors) {
$sameMinor = @($candidates | Where-Object { $_.Version -eq $minor })
if ($sameMinor.Count -eq 0) { continue }
$x64 = $sameMinor | Where-Object { $_.Arch -eq "x86_64" } | Select-Object -First 1
if ($x64) { return $x64 }
if (-not $X64Only) { return $sameMinor[0] }
}
if (-not $X64Only -and $candidates.Count -gt 0) { return $candidates[0] }
return $null
}
@ -1261,11 +1140,8 @@ exit 0
# (no UAC), putting python.exe + the py launcher on PATH. Mirrors the uv ->
# astral.sh fallback below. Returns @{ Version; Path } or $null.
function Install-PythonFromPythonOrg {
# $Arch overrides the host arch, to pull x64 onto an ARM64 box.
param([string]$Arch = "")
# python.org ships one installer per architecture.
$targetArch = if ($Arch) { $Arch } else { Get-TauriDiagArch }
$archSuffix = switch ($targetArch) {
$archSuffix = switch (Get-TauriDiagArch) {
"x86_64" { "-amd64" }
"arm64" { "-arm64" }
"x86" { "" }
@ -1330,28 +1206,6 @@ exit 0
return (Find-CompatiblePython)
}
# ── Windows on ARM: get an x64 CPython ──
# --architecture x64 forces winget off the ARM64 build; python.org takes the same override.
function Install-X64Python {
if ($script:WingetAvailable) {
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
winget install -e --id "Python.Python.$PythonVersion" --source winget --architecture x64 --accept-package-agreements --accept-source-agreements
} catch { }
$ErrorActionPreference = $prevEAP
Refresh-SessionPath
$found = Find-CompatiblePython
if ($found -and $found.Arch -eq "x86_64") { return $found }
substep "winget could not provide an x64 Python -- trying python.org..." "Yellow"
}
$found = Install-PythonFromPythonOrg -Arch "x86_64"
if ($found -and $found.Arch -eq "x86_64") { return $found }
# Nothing installable (offline / no winget): an x64 build of another supported minor
# still runs the wheels ARM64 cannot, so take it over the native interpreter.
return (Find-CompatiblePython -X64Only)
}
# ── Install Python if no compatible version (3.11-3.13) found ──
# Find-CompatiblePython returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null.
Write-TauriLog "STEP" "Installing Python"
@ -1423,26 +1277,6 @@ exit 0
return (Exit-InstallFailure "Python installation failed")
}
}
# ── Windows on ARM: swap a native ARM64 interpreter for x64 ──
# pyarrow and hf-transfer publish no win_arm64 wheel, so an ARM64 Python source-builds
# both and fails deep into the run. Warn up front if x64 is unobtainable.
if ($DetectedPython -and (Get-HostMachineArch) -eq "arm64" -and $DetectedPython.Arch -ne "x86_64") {
substep "windows on arm: only a native ARM64 Python $($DetectedPython.Version) was found." "Yellow"
substep "pyarrow and hf-transfer publish no win_arm64 wheels, so installing x64 Python..." "Yellow"
$X64Python = Install-X64Python
if ($X64Python) {
$DetectedPython = $X64Python
step "python" "using x64 Python $($DetectedPython.Version) under emulation"
} else {
Write-Host "[WARN] Could not install an x64 Python on this ARM64 machine." -ForegroundColor Yellow
Write-Host " Continuing with ARM64 Python $($DetectedPython.Version), but the install is likely to fail:" -ForegroundColor Yellow
Write-Host " pyarrow (via datasets) and hf-transfer ship no win_arm64 wheels and will be" -ForegroundColor Yellow
Write-Host " built from source, which needs CMake plus the MSVC and Rust toolchains." -ForegroundColor Yellow
Write-Host " Fix: install x64 Python from https://www.python.org/downloads/windows/" -ForegroundColor Yellow
Write-Host " (choose 'Windows installer (64-bit)', not ARM64), then re-run this installer." -ForegroundColor Yellow
}
}
$DiagPythonVersion = $PythonVersion
if ($DetectedPython) { $DiagPythonVersion = $DetectedPython.Version }
$InitialGpuBranch = "unknown"
@ -1557,82 +1391,13 @@ exit 0
$suffix++
$candidate = Join-Path $StudioHome "unsloth_studio.rollback.$stamp.$PID.$suffix"
}
Move-Item -LiteralPath $ExistingDir -Destination $candidate -ErrorAction Stop
$script:StudioVenvRollbackDir = $candidate
$script:StudioVenvRollbackTarget = $ExistingDir
$script:StudioVenvRollbackActive = $true
# Publish the rollback state before the atomic rename so interruption
# cannot land after Move-Item but before cleanup knows where the old venv went.
try {
Move-Item -LiteralPath $ExistingDir -Destination $candidate -ErrorAction Stop
} catch {
# A collision or ordinary rename failure leaves the original in place.
# Keep state active only when the rename happened before interruption.
if (Test-Path -LiteralPath $ExistingDir) {
$script:StudioVenvRollbackActive = $false
$script:StudioVenvRollbackDir = $null
}
throw
}
substep "previous environment preserved for rollback"
}
function Remove-StudioVenvTreeWithRetry {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Label
)
$lastError = $null
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
} catch {
$lastError = $_.Exception.Message
}
if (-not (Test-Path -LiteralPath $Path)) { return $true }
if ($attempt -lt 3) { Start-Sleep -Milliseconds (250 * $attempt) }
}
Write-Host "[WARN] Could not remove $Label at $Path" -ForegroundColor Yellow
if ($lastError) { Write-Host " $lastError" -ForegroundColor Yellow }
return $false
}
function Test-StudioVenvRollbackMustBePreserved {
param([Parameter(Mandatory = $true)][System.IO.FileSystemInfo]$Rollback)
# Preserve anything outside the installer's timestamp.PID[.suffix] format.
if ($Rollback.Name -notmatch '^unsloth_studio\.rollback\.[0-9]{14}\.([0-9]+)(?:\.[0-9]+)?$') {
return $true
}
$ownerPid = 0
if (-not [int]::TryParse($Matches[1], [ref]$ownerPid)) { return $true }
if ($ownerPid -eq $PID) { return $true }
return $null -ne (Get-Process -Id $ownerPid -ErrorAction SilentlyContinue)
}
function Remove-StaleStudioVenvRollbacks {
try {
$rollbacks = @(
Get-ChildItem -LiteralPath $StudioHome -Directory -Force -ErrorAction Stop |
Where-Object { $_.Name -like 'unsloth_studio.rollback.*' }
)
} catch {
Write-Host "[WARN] Could not inspect stale environment rollbacks in $StudioHome" -ForegroundColor Yellow
Write-Host " $($_.Exception.Message)" -ForegroundColor Yellow
return
}
foreach ($rollback in $rollbacks) {
if (($rollback.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
Write-Host "[WARN] Refusing to remove rollback reparse point $($rollback.FullName)" -ForegroundColor Yellow
continue
}
# A concurrent installer may have moved its live venv aside. The PID
# in the generated name keeps this run from deleting its rescue copy.
if (Test-StudioVenvRollbackMustBePreserved -Rollback $rollback) { continue }
if (Remove-StudioVenvTreeWithRetry -Path $rollback.FullName -Label "stale environment rollback") {
substep "removed stale environment rollback $($rollback.Name)"
}
}
}
function Restore-StudioVenvRollback {
if (-not $script:StudioVenvRollbackActive) { return }
$backup = $script:StudioVenvRollbackDir
@ -1644,9 +1409,7 @@ exit 0
substep "restoring previous environment after failed install..." "Yellow"
try {
if (Test-Path -LiteralPath $target) {
if (-not (Remove-StudioVenvTreeWithRetry -Path $target -Label "incomplete environment")) {
throw "Could not remove incomplete environment at $target"
}
Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction SilentlyContinue
}
Move-Item -LiteralPath $backup -Destination $target -Force -ErrorAction Stop
substep "restored previous environment"
@ -1661,21 +1424,17 @@ exit 0
function Complete-StudioVenvRollback {
if (-not $script:StudioVenvRollbackActive) { return }
$backup = $script:StudioVenvRollbackDir
# The replacement is committed. Disable restoration before deleting the
# backup so interruption cannot restore a partially deleted environment.
if ($backup -and (Test-Path -LiteralPath $backup)) {
Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction SilentlyContinue
}
$script:StudioVenvRollbackActive = $false
$script:StudioVenvRollbackDir = $null
if ($backup -and (Test-Path -LiteralPath $backup)) {
Remove-StudioVenvTreeWithRetry -Path $backup -Label "environment rollback" | Out-Null
}
}
$studioVenvReplacementCommitted = $false
try {
if (Test-Path -LiteralPath $VenvPython) {
# why: matching guard to the .venv branch below -- in env-mode
# $StudioHome is a user-chosen workspace, so refuse to nuke an
# existing $StudioHome\unsloth_studio that lacks Unsloth sentinels.
# existing $StudioHome\unsloth_studio that lacks Studio sentinels.
# -PathType Leaf rejects a directory at the sentinel path. Accept the
# in-VENV ownership marker so partial-install retries are not blocked.
if (
@ -1686,7 +1445,7 @@ exit 0
) {
Write-Host "[ERROR] $VenvDir already exists but does not look like an Unsloth Studio install." -ForegroundColor Red
Write-Host " Move it aside or choose an empty UNSLOTH_STUDIO_HOME." -ForegroundColor Yellow
throw "Refusing to delete non-Unsloth venv at $VenvDir"
throw "Refusing to delete non-Studio venv at $VenvDir"
}
# New layout already exists -- replace only after preserving rollback copy.
substep "preserving existing environment for rollback..."
@ -1705,7 +1464,7 @@ exit 0
# workspace root (e.g. user's existing project Python venv).
$OldVenv = Join-Path $StudioHome ".venv"
$OldPy = Join-Path $OldVenv "Scripts\python.exe"
substep "found legacy Unsloth environment, validating..."
substep "found legacy Studio environment, validating..."
$prevEAP2 = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
@ -1735,7 +1494,7 @@ exit 0
# Skip in env-mode so we don't relocate the default-install venv into
# the workspace root.
$CwdVenv = Join-Path $env:USERPROFILE "unsloth_studio"
substep "found CWD-relative Unsloth environment, migrating to $VenvDir..."
substep "found CWD-relative Studio environment, migrating to $VenvDir..."
Move-Item -LiteralPath $CwdVenv -Destination $VenvDir -Force
substep "moved ~/unsloth_studio -> ~/.unsloth/studio/unsloth_studio"
$_Migrated = $true
@ -1744,7 +1503,7 @@ exit 0
if (-not (Test-Path -LiteralPath $VenvPython)) {
step "venv" "creating Python $($DetectedPython.Version) virtual environment"
substep "$VenvDir"
$venvExit = Invoke-InstallCommand -Label "create virtual environment" { uv venv $VenvDir --python "$($DetectedPython.Path)" }
$venvExit = Invoke-InstallCommand { uv venv $VenvDir --python "$($DetectedPython.Path)" }
if ($venvExit -ne 0) {
Write-Host "[ERROR] Failed to create virtual environment (exit code $venvExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to create virtual environment (exit code $venvExit)" $venvExit)
@ -1754,7 +1513,7 @@ exit 0
substep "$VenvDir"
}
# Mark the freshly-created venv as Unsloth-owned so a partial install can be
# Mark the freshly-created venv as Studio-owned so a partial install can be
# repaired by re-running install.ps1; the env-mode deletion guard above
# accepts this marker as the primary sentinel.
if (Test-Path -LiteralPath $VenvDir -PathType Container) {
@ -1763,7 +1522,7 @@ exit 0
# ── Helper: run amd-smi without triggering a UAC elevation prompt ──
# amd-smi on Windows auto-elevates to read GPU/APU memory, surfacing a confusing
# DiskPart UAC prompt mid-install (Unsloth backend amd.py hits the same).
# DiskPart UAC prompt mid-install (Studio backend amd.py hits the same).
# __COMPAT_LAYER=RunAsInvoker forces it (and helpers it spawns) to run
# un-elevated; on failure the WMI name -> gfx fallback still resolves the arch.
function Invoke-AmdSmiNoElevate {
@ -1890,7 +1649,7 @@ exit 0
function Test-HipinfoIsVenvInternal {
param([AllowNull()][string]$HipinfoPath)
if ([string]::IsNullOrWhiteSpace($HipinfoPath)) { return $false }
# Also derive the venv from the setup python + default Unsloth home, so
# Also derive the venv from the setup python + default Studio home, so
# the venv hipInfo is caught when VenvDir/VIRTUAL_ENV are unset.
$venvRoots = @()
if ($env:VIRTUAL_ENV) { $venvRoots += $env:VIRTUAL_ENV }
@ -1900,7 +1659,7 @@ exit 0
try { $venvRoots += (Split-Path -Parent (Split-Path -Parent $env:UNSLOTH_SETUP_PYTHON)) } catch {}
}
if ($env:USERPROFILE) { $venvRoots += (Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio") }
# A custom Unsloth home (UNSLOTH_STUDIO_HOME / STUDIO_HOME alias) moves the
# A custom Studio home (UNSLOTH_STUDIO_HOME / STUDIO_HOME alias) moves the
# venv off the default path; seed it too or its hipInfo escapes the filter.
$studioHomeEnv = if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) { $env:UNSLOTH_STUDIO_HOME.Trim() } elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) { $env:STUDIO_HOME.Trim() } else { $null }
if ($studioHomeEnv) {
@ -2058,14 +1817,12 @@ exit 0
# (gfx120X/110X/1151/1150/103X); unknown names fall back to CPU.
elseif ($ROCmGpuLabel) {
$nameArchTable = @(
@{ P = "9070|9080"; A = "gfx1201" } # RDNA 4 (Navi 48: RX 9070 XT / 9070 GRE / 9070 / 9080)
@{ P = "9060"; A = "gfx1200" } # RDNA 4 (Navi 44: RX 9060 XT / 9060)
@{ P = "8065S|8060S|8050S|8040S|Strix Halo|Ryzen AI Max|AI Max"; A = "gfx1151" } # RDNA 3.5 (Strix Halo + Gorgon Halo: Radeon 8065S/8060S/8050S/8040S iGPU, Ryzen AI Max / Max+)
@{ P = "890M|880M|Strix Point|HX 37[05]|AI 9 HX|AI 9 36[05]"; A = "gfx1150" } # RDNA 3.5 (Strix Point: Radeon 890M/880M, Ryzen AI 9 HX 370/375)
@{ P = "860M|840M|Krackan|AI 7 35[05]|AI 5 34[05]|AI 7 PRO 35|AI 5 33"; A = "gfx1152" } # RDNA 3.5 (Krackan Point: Radeon 860M/840M, Ryzen AI 7 350 / AI 5 340)
@{ P = "RX 7900|PRO W7900|PRO W7800"; A = "gfx1100" } # RDNA 3 desktop/workstation (Navi 31)
@{ P = "RX 7800|RX 7700(?!S)|PRO W7700|PRO V710"; A = "gfx1101" } # RDNA 3 (Navi 32)
@{ P = "RX 7600|RX 7700S|RX 7650|PRO W7600|PRO W7500"; A = "gfx1102" } # RDNA 3 (Navi 33)
@{ P = "9070 XT|9080"; A = "gfx1201" } # RDNA 4 (RX 9070 XT / 9080)
@{ P = "9070|9060"; A = "gfx1200" } # RDNA 4 (RX 9070 / 9060)
@{ P = "8060S|8050S|8040S|Strix Halo|Ryzen AI Max|AI Max"; A = "gfx1151" } # RDNA 3.5 (Strix Halo: Radeon 8060S/8050S/8040S iGPU, Ryzen AI Max+)
@{ P = "890M|880M|860M|840M|Strix Point|Krackan|HX 37[05]|AI 9 HX|AI 9 36[05]|AI 7 35[05]|AI 5 34[05]|AI 7 PRO 35|AI 5 33"; A = "gfx1150" } # RDNA 3.5 (Strix/Krackan Point: Radeon 890M/880M iGPU, Ryzen AI 9 HX 370/375)
@{ P = "RX 7900|RX 7800|RX 7700(?!S)|PRO W7900|PRO W7800|PRO W7700"; A = "gfx1100" } # RDNA 3 desktop/workstation (Navi 31)
@{ P = "RX 7600|RX 7700S|RX 7650|PRO W7600|PRO W7500|PRO V710"; A = "gfx1102" } # RDNA 3 (Navi 33)
@{ P = "780M|760M|740M|Phoenix|Hawk Point|Z1 Extreme|Z2 Extreme"; A = "gfx1103" } # RDNA 3 iGPU (Phoenix / Hawk Point)
@{ P = "RX 6900|RX 6800|RX 6750|RX 6700|PRO W6800|PRO W6900"; A = "gfx1030" } # RDNA 2 (Navi 21) -- gfx103X family
@{ P = "RX 6650|RX 6600|PRO W6600|PRO W6650"; A = "gfx1032" } # RDNA 2 (Navi 23) -- gfx103X family
@ -2181,7 +1938,7 @@ exit 0
substep " Ensure the ROCm compute driver is installed alongside the display driver:" "Yellow"
substep " https://rocm.docs.amd.com/en/latest/deploy/windows/index.html" "Yellow"
} elseif ($ROCmGfxArch) {
# Known arch: Unsloth setup installs AMD's bundled-runtime ROCm PyTorch wheels
# Known arch: Studio setup installs AMD's bundled-runtime ROCm PyTorch wheels
# (repo.amd.com), which ship their own runtime -- HIP SDK optional.
step "gpu" "AMD ROCm ($ROCmGfxArch)" "Cyan"
substep "Detected: $ROCmGpuLabel" "Cyan"
@ -2199,31 +1956,10 @@ exit 0
# On an AMD GPU (no NVIDIA), surface the optional WSL-ROCm driver hint.
if (-not $HasNvidiaSmi -and ($ROCmGfxArch -or $ROCmGpuLabel)) { Show-AmdWslDriverHint }
# Trim trailing slashes from the URL PATH only, preserving ?query / #fragment: a whole-URL
# TrimEnd corrupts a token ending in "/", a single strip leaves .../cu128// empty. Shared.
function Trim-IndexPathSlashes {
param([string]$Url)
$value = $Url.Trim()
$idx = $value.IndexOfAny([char[]]@('?', '#'))
if ($idx -lt 0) {
return $value.TrimEnd('/')
}
return $value.Substring(0, $idx).TrimEnd('/') + $value.Substring($idx)
}
# ── Choose the correct PyTorch index URL based on driver CUDA version ──
# Mirrors Get-PytorchCudaTag in setup.ps1.
function Get-TorchIndexUrl {
$baseUrl = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/') } else { "https://download.pytorch.org/whl" }
# Explicit pin -- skip ALL GPU probing (headless / CI / cross-install).
# UNSLOTH_TORCH_INDEX_URL wins (full URL, verbatim); _FAMILY is the leaf appended
# to the mirror base. Matches install.sh / install_python_stack.py.
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_URL)) {
return (Trim-IndexPathSlashes $env:UNSLOTH_TORCH_INDEX_URL)
}
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_FAMILY)) {
return "$baseUrl/$($env:UNSLOTH_TORCH_INDEX_FAMILY.Trim().Trim('/'))"
}
if (-not $NvidiaSmiExe) { return "$baseUrl/cpu" }
try {
$output = Invoke-NvidiaSmiBounded $NvidiaSmiExe
@ -2244,27 +1980,6 @@ exit 0
return "$baseUrl/cu126"
}
# Strip userinfo AND query/fragment so an authenticated pin never leaks. Shared with
# _strip_index_url_credentials (install.sh / py / setup.ps1).
function Remove-IndexUrlCredentials {
param([string]$Url)
# Ordinal, not culture-aware: on non-English locales (e.g. th-TH) linguistic
# IndexOf treats "://" as ignorable, mis-locates it, and crashes Substring (issue #7279).
$sep = $Url.IndexOf('://', [System.StringComparison]::Ordinal)
if ($sep -lt 0) { return $Url }
$scheme = $Url.Substring(0, $sep)
$rest = $Url.Substring($sep + 3)
# Drop query / fragment (may hold auth tokens).
$q = $rest.IndexOfAny([char[]]('?', '#'))
if ($q -ge 0) { $rest = $rest.Substring(0, $q) }
$slash = $rest.IndexOf('/', [System.StringComparison]::Ordinal)
$authority = if ($slash -ge 0) { $rest.Substring(0, $slash) } else { $rest }
$at = $authority.LastIndexOf('@', [System.StringComparison]::Ordinal)
$host_ = if ($at -ge 0) { $authority.Substring($at + 1) } else { $authority }
if ($slash -ge 0) { return "${scheme}://${host_}$($rest.Substring($slash))" }
return "${scheme}://${host_}"
}
# ── Torch flavor helpers (to repair a stale CPU / wrong-CUDA wheel) ──
# torch.__version__ -> flavor tag (cuXXX / rocm / cpu); untagged wheel = cpu,
# matching setup.ps1's stale-venv parse.
@ -2283,13 +1998,11 @@ exit 0
param([string]$TorchIndexUrl, [string]$ROCmIndexUrl)
if (-not [string]::IsNullOrWhiteSpace($ROCmIndexUrl)) { return 'rocm' }
if ([string]::IsNullOrWhiteSpace($TorchIndexUrl)) { return $null }
# Drop query/fragment first so .../cu128?token=x classifies as cu128 (else it reinstalls every run).
$leaf = (($TorchIndexUrl -split '[?#]', 2)[0].TrimEnd('/') -split '/')[-1].ToLowerInvariant()
$leaf = ($TorchIndexUrl.TrimEnd('/') -split '/')[-1].ToLowerInvariant()
if ($leaf -match '^cu\d+$') { return $leaf }
if ($leaf -eq 'cpu') { return 'cpu' }
if ($leaf -match '^rocm') { return 'rocm' }
# gfx must be followed by a digit (an architecture leaf); gfx-private is custom.
if ($leaf -match '^gfx[0-9]') { return 'rocm' }
if ($leaf -match '^gfx') { return 'rocm' }
return $null
}
@ -2324,10 +2037,6 @@ exit 0
} catch { return $null }
}
# An explicit pin is authoritative: the AMD ROCm reroute below must not rewrite it
# (e.g. a deliberate cpu pin on an AMD host).
$TorchIndexPinned = (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_URL)) -or `
(-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_FAMILY))
$TorchIndexUrl = Get-TorchIndexUrl
# ── GPU arch → newest compatible Windows ROCm wheel release ──
@ -2339,20 +2048,13 @@ exit 0
# Override with UNSLOTH_ROCM_WINDOWS_MIRROR for air-gapped / mirror installs.
$ROCmIndexUrl = $null
$ROCmTorchFloor = $null
$PinnedRocmVisionSpec = $null
$PinnedRocmAudioSpec = $null
if (-not $TorchIndexPinned -and ($HasROCm -or $ROCmGfxArch) -and $TorchIndexUrl -like "*/cpu" -and -not $SkipTorch) {
if (($HasROCm -or $ROCmGfxArch) -and $TorchIndexUrl -like "*/cpu" -and -not $SkipTorch) {
$amdIndexBase = if ($env:UNSLOTH_ROCM_WINDOWS_MIRROR) { $env:UNSLOTH_ROCM_WINDOWS_MIRROR.TrimEnd('/') } else { "https://repo.amd.com/rocm/whl" }
$archFamilyMap = @{
"gfx1201" = "gfx120X-all"; "gfx1200" = "gfx120X-all" # RDNA 4
"gfx1151" = "gfx1151"; "gfx1150" = "gfx1150" # RDNA 3.5 (Strix Halo/Point)
"gfx1152" = "gfx1152" # RDNA 3.5 (Krackan Point)
"gfx1103" = "gfx110X-all"; "gfx1102" = "gfx110X-all" # RDNA 3
"gfx1101" = "gfx110X-all"; "gfx1100" = "gfx110X-all"
"gfx1036" = "gfx103X-all"; "gfx1035" = "gfx103X-all" # RDNA 2 (RX 6000)
"gfx1034" = "gfx103X-all"; "gfx1033" = "gfx103X-all"
"gfx1032" = "gfx103X-all"; "gfx1031" = "gfx103X-all"
"gfx1030" = "gfx103X-all"
"gfx90a" = "gfx90a"; "gfx908" = "gfx908" # MI200/MI100
}
# gfx120X (RDNA 4) and gfx1151/gfx1150 (Strix) have a null-pointer bug in
@ -2368,7 +2070,6 @@ exit 0
$torchFloorMap = @{
"gfx1201" = "torch>=2.11.0,<2.12.0"; "gfx1200" = "torch>=2.11.0,<2.12.0"
"gfx1151" = "torch>=2.11.0,<2.12.0"; "gfx1150" = "torch>=2.11.0,<2.12.0"
"gfx1152" = "torch>=2.11.0,<2.12.0"
}
# Companion ranges track the torch ceiling so pip resolves a consistent
# trio on AMD's per-arch index (each published independently). Mirrors
@ -2376,12 +2077,10 @@ exit 0
$torchvisionFloorMap = @{
"gfx1201" = "torchvision>=0.26.0,<0.27.0"; "gfx1200" = "torchvision>=0.26.0,<0.27.0"
"gfx1151" = "torchvision>=0.26.0,<0.27.0"; "gfx1150" = "torchvision>=0.26.0,<0.27.0"
"gfx1152" = "torchvision>=0.26.0,<0.27.0"
}
$torchaudioFloorMap = @{
"gfx1201" = "torchaudio>=2.11.0,<2.12.0"; "gfx1200" = "torchaudio>=2.11.0,<2.12.0"
"gfx1151" = "torchaudio>=2.11.0,<2.12.0"; "gfx1150" = "torchaudio>=2.11.0,<2.12.0"
"gfx1152" = "torchaudio>=2.11.0,<2.12.0"
}
$archFamily = if ($ROCmGfxArch -and $archFamilyMap.ContainsKey($ROCmGfxArch)) { $archFamilyMap[$ROCmGfxArch] } else { $null }
if ($archFamily) {
@ -2399,32 +2098,6 @@ exit 0
}
}
# A gfx*/rocm pin skips the auto-reroute above, but the generic CPU/CUDA install below
# would use torch>=2.4,<2.11 and pull a known-bad wheel on the gfx115x/gfx120x/rocm>=7.2
# indexes (the _grouped_mm bug). Route a pinned ROCm index through the ROCm path.
if ($TorchIndexPinned -and -not $ROCmIndexUrl -and -not $SkipTorch) {
$_pinLeaf = (($TorchIndexUrl -split '[?#]', 2)[0].TrimEnd('/') -split '/')[-1].ToLower()
$_pinRocm211 = $false
# Anchor ($) so a suffixed custom leaf (rocm7.2-private) falls through to verbatim.
if ($_pinLeaf -match '^rocm(\d+)\.(\d+)$') {
# Only KNOWN-2.11 rocm (rocm7.2) gets the floor. Matches Test-RocmKnown211Version.
$_pinRocm211 = ([int]$Matches[1] -eq 7 -and [int]$Matches[2] -eq 2)
}
# Only the 2.11-allowlist gfx arches need the floor; others publish <2.11 and stay bare.
$_pinGfx211 = @('gfx120x-all', 'gfx1151', 'gfx1150', 'gfx1152') -contains $_pinLeaf
if ($_pinGfx211 -or $_pinRocm211) {
$ROCmIndexUrl = $TorchIndexUrl
$ROCmTorchFloor = "torch>=2.11.0,<2.12.0"
$PinnedRocmVisionSpec = "torchvision>=0.26.0,<0.27.0"
$PinnedRocmAudioSpec = "torchaudio>=2.11.0,<2.12.0"
substep "pinned ROCm index ($_pinLeaf) -- enforcing $ROCmTorchFloor" "Cyan"
} elseif ($_pinLeaf -match '^gfx[0-9]' -or $_pinLeaf -match '^rocm[0-9]+(\.[0-9]+)?$') {
# Other gfx / older rocm (<=7.1) ship torch <2.11; route via the ROCm path with
# bare specs. Only EXACT rocm<digits>/gfx* are families; a suffixed leaf is verbatim.
$ROCmIndexUrl = $TorchIndexUrl
}
}
if ($ROCmIndexUrl) {
$TorchIndexFamily = "rocm"
} else {
@ -2487,14 +2160,14 @@ exit 0
}
if ($_Migrated) {
# Migrated env: force-reinstall unsloth+unsloth-zoo for a clean state, preserving
# existing torch/CUDA unless the flavor repair below re-lands it.
# Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state
# in the new venv location, while preserving existing torch/CUDA
Write-TauriLog "STEP" "Installing unsloth"
substep "upgrading unsloth in migrated environment..."
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated no-torch)" { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" }
if ($baseInstallExit -eq 0) {
# Resolve pydantic WITH deps so pip pins pydantic-core
# to the matching version (no-torch-runtime.txt below
@ -2508,7 +2181,7 @@ exit 0
}
}
} else {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (migrated)" { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" }
}
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
@ -2516,7 +2189,7 @@ exit 0
}
if ($StudioLocalInstall) {
substep "overlaying local repo (editable)..."
$overlayExit = Invoke-InstallCommand -Label "overlay local repo" { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
if ($overlayExit -ne 0) {
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
@ -2533,24 +2206,22 @@ exit 0
substep "skipping PyTorch (--no-torch flag set)." "Yellow"
} elseif ($ROCmIndexUrl) {
Write-TauriLog "STEP" "Installing PyTorch (AMD ROCm Windows)"
substep "installing PyTorch from $(Remove-IndexUrlCredentials $ROCmIndexUrl)..."
substep "installing PyTorch from $ROCmIndexUrl..."
$torchSpec = if ($ROCmTorchFloor) { $ROCmTorchFloor } else { "torch" }
# Pin the companions to match $torchSpec; bare names can resolve an
# ABI-incompatible torchvision/torchaudio on AMD's per-arch index.
$visionSpec = if ($PinnedRocmVisionSpec) { $PinnedRocmVisionSpec } elseif ($ROCmGfxArch -and $torchvisionFloorMap -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
$audioSpec = if ($PinnedRocmAudioSpec) { $PinnedRocmAudioSpec } elseif ($ROCmGfxArch -and $torchaudioFloorMap -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
$visionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
$audioSpec = if ($ROCmGfxArch -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (AMD ROCm)" { uv pip install --python $VenvPython --force-reinstall --default-index $ROCmIndexUrl $torchSpec $visionSpec $audioSpec }
if ($torchInstallExit -ne 0) {
# Transient AMD-index failure: fall back to a CPU base (Unsloth setup retries
# ROCm). Use an explicit CPU index -- for a pinned ROCm index $TorchIndexUrl IS
# the ROCm mirror, so reusing it would just retry it.
$CpuFallbackIndexUrl = if ($env:UNSLOTH_PYTORCH_MIRROR) { "$($env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/'))/cpu" } else { "https://download.pytorch.org/whl/cpu" }
substep "ROCm PyTorch install failed (exit $torchInstallExit); using a CPU base, Unsloth setup retries ROCm." "Yellow"
# Transient AMD-index failure: fall back to a CPU base so the install
# still completes; Studio setup retries ROCm afterwards.
substep "ROCm PyTorch install failed (exit $torchInstallExit); using a CPU base, Studio setup retries ROCm." "Yellow"
# --force-reinstall: a failed ROCm install can leave an unpinned ROCm
# torch (e.g. 2.10.0+rocm on gfx110X/gfx90a) that still satisfies the CPU
# torch>= range, so without it uv would keep the ROCm build and only swap
# the companions -- a mismatched venv the flavor-repair block won't fix.
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (CPU fallback)" { uv pip install --python $VenvPython --force-reinstall "torch>=2.4,<2.11.0" "torchvision>=0.19,<0.26.0" "torchaudio>=2.4,<2.11.0" --default-index $CpuFallbackIndexUrl }
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch (CPU fallback)" { uv pip install --python $VenvPython --force-reinstall "torch>=2.4,<2.11.0" torchvision torchaudio --default-index $TorchIndexUrl }
if ($torchInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install PyTorch (ROCm and CPU base both failed, exit code $torchInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
@ -2563,27 +2234,8 @@ exit 0
}
} else {
Write-TauriLog "STEP" "Installing PyTorch"
# Windows on ARM lacks only torchaudio (whl/cpu win_arm64: torch 42,
# torchvision 60, torchaudio 0), so drop that pin instead of aborting. Ask the
# interpreter, not PROCESSOR_ARCHITECTURE; reached when no x64 Python exists.
$VenvPlatform = ""
try {
$VenvPlatform = (& $VenvPython -c "import sysconfig; print(sysconfig.get_platform())" 2>$null | Out-String).Trim().ToLowerInvariant()
} catch { $VenvPlatform = "" }
substep "installing PyTorch ($(Remove-IndexUrlCredentials $TorchIndexUrl))..."
# Bound the companions to the capped torch on EVERY index, cu<digits>
# families included: torchaudio 2.11 dropped its exact torch pin from
# the wheel metadata, so a bare companion next to torch<2.11 can
# resolve a mismatched 2.11.0 build. Mirrors install.sh.
$_pinVisionSpec = "torchvision>=0.19,<0.26.0"
$_pinAudioSpec = "torchaudio>=2.4,<2.11.0"
$_torchSpecs = @("torch>=2.4,<2.11.0", $_pinVisionSpec, $_pinAudioSpec)
if ($VenvPlatform -eq "win-arm64") {
substep "windows on arm: skipping torchaudio (upstream publishes no"
substep "win_arm64 wheel); torch and torchvision install normally."
$_torchSpecs = @("torch>=2.4,<2.11.0", $_pinVisionSpec)
}
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython @_torchSpecs --default-index $TorchIndexUrl }
substep "installing PyTorch ($TorchIndexUrl)..."
$torchInstallExit = Invoke-InstallCommandRetry -Label "install PyTorch" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --default-index $TorchIndexUrl }
if ($torchInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
@ -2595,7 +2247,7 @@ exit 0
if ($SkipTorch) {
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (no-torch)" { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" }
if ($baseInstallExit -eq 0) {
# Same pydantic-with-deps trick as the migrated branch.
$baseInstallExit = Invoke-InstallCommandRetry -Label "install pydantic" { uv pip install --python $VenvPython pydantic }
@ -2607,7 +2259,7 @@ exit 0
}
}
} elseif ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.7.5" "unsloth-zoo>=2026.7.6" }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (local)" { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.7.2" "unsloth-zoo>=2026.7.2" }
} else {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth" { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
}
@ -2618,7 +2270,7 @@ exit 0
if ($StudioLocalInstall) {
substep "overlaying local repo (editable)..."
$overlayExit = Invoke-InstallCommand -Label "overlay local repo" { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
if ($overlayExit -ne 0) {
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
@ -2635,13 +2287,13 @@ exit 0
Write-TauriLog "STEP" "Installing unsloth"
substep "installing unsloth (this may take a few minutes)..."
if ($StudioLocalInstall) {
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.7.6" "unsloth>=2026.7.5" --torch-backend=auto }
$baseInstallExit = Invoke-InstallCommandRetry -Label "install unsloth (auto torch backend)" { uv pip install --python $VenvPython "unsloth-zoo>=2026.7.2" "unsloth>=2026.7.2" --torch-backend=auto }
if ($baseInstallExit -ne 0) {
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
}
substep "overlaying local repo (editable)..."
$overlayExit = Invoke-InstallCommand -Label "overlay local repo" { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
if ($overlayExit -ne 0) {
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
@ -2661,13 +2313,6 @@ exit 0
}
}
$installedPackageVersion = (& $VenvPython -c "from importlib.metadata import version; import sys; print(version(sys.argv[1]))" $PackageName 2>$null | Out-String).Trim()
if ($LASTEXITCODE -eq 0 -and $installedPackageVersion) {
step $PackageName "$installedPackageVersion installed"
} else {
substep "[WARN] installed $PackageName version could not be determined" "Yellow"
}
# ── Enforce the installed torch flavor matches the detected GPU build ──
# PEP 440 ignores the +cpu/+cuXXX/+rocm local label in a version range, so uv
# keeps a stale torch==X+cpu against a CUDA index and setup.ps1 then loops on
@ -2686,10 +2331,10 @@ exit 0
$rocmSpec = if ($ROCmTorchFloor) { $ROCmTorchFloor } else { "torch" }
# Pin companions like the fresh ROCm path (bare names can pull an
# ABI-incompatible torchvision/torchaudio from the per-arch index).
$visionSpec = if ($PinnedRocmVisionSpec) { $PinnedRocmVisionSpec } elseif ($ROCmGfxArch -and $torchvisionFloorMap -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
$audioSpec = if ($PinnedRocmAudioSpec) { $PinnedRocmAudioSpec } elseif ($ROCmGfxArch -and $torchaudioFloorMap -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
$visionSpec = if ($ROCmGfxArch -and $torchvisionFloorMap.ContainsKey($ROCmGfxArch)) { $torchvisionFloorMap[$ROCmGfxArch] } else { "torchvision" }
$audioSpec = if ($ROCmGfxArch -and $torchaudioFloorMap.ContainsKey($ROCmGfxArch)) { $torchaudioFloorMap[$ROCmGfxArch] } else { "torchaudio" }
substep "PyTorch flavor mismatch (installed $installedTorchTag, need ROCm) -- reinstalling correct build..." "Yellow"
$torchFixExit = Invoke-InstallCommand -Label "reinstall PyTorch (ROCm)" { uv pip install --python $VenvPython --force-reinstall --default-index $ROCmIndexUrl $rocmSpec $visionSpec $audioSpec }
$torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython --force-reinstall --default-index $ROCmIndexUrl $rocmSpec $visionSpec $audioSpec }
if ($torchFixExit -ne 0) {
Write-Host "[ERROR] Failed to reinstall PyTorch with the correct ROCm build (exit code $torchFixExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to reinstall PyTorch (ROCm) (exit code $torchFixExit)" $torchFixExit)
@ -2698,7 +2343,7 @@ exit 0
} elseif ($expectedTorchTag -ne 'rocm') {
# CUDA: stale +cpu (or wrong cuXXX) against a CUDA index -> reinstall triplet.
substep "PyTorch flavor mismatch (installed $installedTorchTag, need $expectedTorchTag) -- reinstalling correct build..." "Yellow"
$torchFixExit = Invoke-InstallCommand -Label "reinstall PyTorch ($expectedTorchTag)" { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" "torchvision>=0.19,<0.26.0" "torchaudio>=2.4,<2.11.0" --default-index $TorchIndexUrl --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio }
$torchFixExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --default-index $TorchIndexUrl --reinstall-package torch --reinstall-package torchvision --reinstall-package torchaudio }
if ($torchFixExit -ne 0) {
Write-Host "[ERROR] Failed to reinstall PyTorch with the correct CUDA build (exit code $torchFixExit)" -ForegroundColor Red
return (Exit-InstallFailure "Failed to reinstall PyTorch ($expectedTorchTag) (exit code $torchFixExit)" $torchFixExit)
@ -2773,7 +2418,7 @@ exit 0
Write-TauriLog "ERROR" "unsloth CLI was not installed correctly"
Write-Host "[ERROR] unsloth CLI was not installed correctly." -ForegroundColor Red
Write-Host " Expected: $UnslothExe" -ForegroundColor Yellow
Write-Host " This usually means an older unsloth version was installed that does not include the Unsloth CLI." -ForegroundColor Yellow
Write-Host " This usually means an older unsloth version was installed that does not include the Studio CLI." -ForegroundColor Yellow
Write-Host " Try re-running the installer or see: https://github.com/unslothai/unsloth?tab=readme-ov-file#-quickstart" -ForegroundColor Yellow
return (Exit-InstallFailure "unsloth CLI was not installed correctly")
}
@ -2799,9 +2444,6 @@ exit 0
# an inherited value would put llama.cpp in the wrong place.
$previousUnslothStudioHome = $env:UNSLOTH_STUDIO_HOME
$hadPreviousUnslothStudioHome = ($null -ne $previousUnslothStudioHome)
$previousTauriMode = $env:UNSLOTH_TAURI_MODE
$hadPreviousTauriMode = ($null -ne $previousTauriMode)
$env:UNSLOTH_TAURI_MODE = if ($TauriMode) { "1" } else { "0" }
if ($StudioRedirectMode -eq 'env') {
$env:UNSLOTH_STUDIO_HOME = $StudioHome
} else {
@ -2831,22 +2473,14 @@ exit 0
} else {
Remove-Item Env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue
}
if ($hadPreviousTauriMode) {
$env:UNSLOTH_TAURI_MODE = $previousTauriMode
} else {
Remove-Item Env:UNSLOTH_TAURI_MODE -ErrorAction SilentlyContinue
}
Remove-Item Env:UNSLOTH_LOCAL_LLAMA_CPP_DIR -ErrorAction SilentlyContinue
Remove-Item Env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -ErrorAction SilentlyContinue
Remove-Item Env:UNSLOTH_SETUP_PYTHON -ErrorAction SilentlyContinue
}
if ($setupExit -ne 0) {
if (-not $TauriMode) {
Write-Host "[ERROR] unsloth studio setup failed (exit code $setupExit)" -ForegroundColor Red
}
Write-Host "[ERROR] unsloth studio setup failed (exit code $setupExit)" -ForegroundColor Red
return (Exit-InstallFailure "unsloth studio setup failed (exit code $setupExit)" $setupExit)
}
Clear-TauriInstallError "studio setup completed"
# ── Expose `unsloth` via a shim dir containing only unsloth.exe ──
# We do NOT add the venv Scripts dir to PATH (it also holds python.exe
@ -2895,7 +2529,7 @@ exit 0
Write-Host " Move or remove it manually, then re-run the installer." -ForegroundColor Yellow
throw "Cannot create unsloth launcher: $ShimExe is a directory."
}
# try/catch: if unsloth.exe is locked (Unsloth running), keep the old shim.
# try/catch: if unsloth.exe is locked (Studio running), keep the old shim.
$shimUpdated = $false
try {
if (Test-Path -LiteralPath $ShimExe) { Remove-Item -LiteralPath $ShimExe -Force -ErrorAction Stop }
@ -2913,7 +2547,7 @@ exit 0
if (Test-Path -LiteralPath $ShimExe) {
Write-Host "[WARN] Could not refresh unsloth launcher at $ShimExe." -ForegroundColor Yellow
Write-Host " This usually means a running 'unsloth studio' process still holds the file open." -ForegroundColor Yellow
Write-Host " Close Unsloth and re-run the installer to pick up the latest launcher." -ForegroundColor Yellow
Write-Host " Close Studio and re-run the installer to pick up the latest launcher." -ForegroundColor Yellow
Write-Host " Continuing with the existing launcher." -ForegroundColor Yellow
} else {
Write-Host "[WARN] Could not create unsloth launcher at $ShimExe" -ForegroundColor Yellow
@ -2934,13 +2568,6 @@ exit 0
}
Refresh-SessionPath # sync current session with registry
Complete-StudioVenvRollback
$studioVenvReplacementCommitted = $true
Remove-StaleStudioVenvRollbacks
} finally {
if (-not $studioVenvReplacementCommitted) {
Restore-StudioVenvRollback
}
}
# Env-mode session export AFTER Refresh-SessionPath; otherwise a legacy
# User PATH entry (Machine > User > current $env:Path) would win.
@ -2985,10 +2612,9 @@ exit 0
# Diagnostic only; never block install on a probe failure.
}
# In interactive terminals, ask the user before starting Unsloth unless the
# caller explicitly disabled the post-install prompt.
# In interactive terminals, ask the user before starting Studio.
# In non-interactive environments (CI, Docker) just print instructions.
$IsInteractive = (-not $SkipAutostart) -and [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)
$IsInteractive = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)
if ($IsInteractive) {
Write-Host ""
$reply = Read-Host " Start Unsloth Studio now? [Y/n]"
@ -2997,8 +2623,8 @@ exit 0
} else {
step "launch" "to start later, run:"
substep "unsloth studio -p 8888"
substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)"
substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)"
substep "(add -H 0.0.0.0 to allow network / cloud access)"
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
Write-Host ""
}
} else {
@ -3018,8 +2644,8 @@ exit 0
substep "& $_actLiteral"
substep "unsloth studio -p 8888"
}
substep "(add -H 0.0.0.0 for LAN / cloud access; exposes the raw port only, not a public URL)"
substep "(add -H 0.0.0.0 --cloudflare for a public Cloudflare HTTPS link, or --secure to keep the raw port private; anyone with the API key can run code)"
substep "(add -H 0.0.0.0 to allow network / cloud access)"
substep "(add --secure for a public Cloudflare HTTPS link; anyone with the API key can run code)"
Write-Host ""
}
}

1676
install.sh

File diff suppressed because it is too large Load diff

View file

@ -25,17 +25,11 @@ classifiers = [
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
dependencies = [
"typer>=0.12.0",
"typer",
"rich",
"pydantic",
"pyyaml",
"nest-asyncio",
# Every CLI command imports studio.backend.*, which reaches structlog at
# module level. The rest of the server stack lives in the studio extra.
"structlog>=24.1.0",
# unsloth_cli/__init__.py reaches click via commands/start.py, so every
# command needs it. typer supplied it until 0.27 dropped the dependency.
"click>=8.0",
]
[project.scripts]
@ -47,14 +41,8 @@ version = {attr = "unsloth.models._utils.__version__"}
[tool.setuptools]
include-package-data = true
[tool.setuptools.cmdclass]
# Snapshots CHANGELOG.md into studio/ so every build path ships it.
build_py = "_changelog_build.build_py"
[tool.setuptools.package-data]
unsloth_cli = ["codex_fallback_prompt.md", "pi_subagent.ts"]
studio = [
"CHANGELOG.md",
"*.sh",
"*.ps1",
"*.bat",
@ -79,40 +67,13 @@ include = ["unsloth*", "unsloth_cli*", "studio", "studio.backend*"]
exclude = ["images*", "tests*", "*.node_modules", "*.node_modules.*"]
[project.optional-dependencies]
# Studio's server stack, mirroring studio/backend/requirements/studio.txt.
# test_studio_extra_matches_requirements.py catches drift.
studio = [
"typer",
"fastapi",
"uvicorn",
"pydantic",
"packaging",
"matplotlib==3.10.9",
"pandas",
"nest_asyncio",
"datasets==4.3.0",
"pyjwt",
"huggingface-hub==0.36.2",
"structlog>=24.1.0",
"diceware",
"ddgs",
"cryptography>=42.0.0",
"boto3>=1.34.0",
"httpx>=0.27.0",
"fastmcp>=3.0.2",
"sqlite-vec==0.1.9",
"pymupdf==1.27.2.3",
"pymupdf4llm==0.3.4",
"python-docx==1.2.0",
]
triton = [
"triton>=3.0.0 ; ('linux' in sys_platform)",
"triton-windows ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
]
huggingfacenotorch = [
"unsloth_zoo>=2026.7.6",
"unsloth_zoo>=2026.7.2",
"wheel>=0.42.0",
"packaging",
"numpy",
@ -131,25 +92,9 @@ huggingfacenotorch = [
"trl>=0.18.2,!=0.19.0,<=0.24.0",
"sentence-transformers",
]
# torchcodec backend for Gemma audio / datasets>=4 (#7225).
# Pick the audio-torch* pin matching your torch minor (see TORCH_TORCHCODEC).
# torchcodec publishes no sdist and only manylinux_2_28_x86_64, macosx_*_arm64
# and win_amd64 wheels, so Linux aarch64, Windows ARM64 and Intel Mac have
# nothing to resolve and pip fails the whole install rather than skipping audio.
# Gate on the platforms that have a wheel, matching
# PLATFORM_LACKS_TORCHCODEC_WHEEL in studio/install_python_stack.py.
audio-torch210 = [
"torchcodec>=0.10.0,<0.11.0 ; python_version >= '3.10' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))",
]
audio-torch290 = [
"torchcodec>=0.8.0,<0.10.0 ; python_version >= '3.10' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))",
]
audio-torch280 = [
"torchcodec>=0.6.0,<0.8.0 ; python_version >= '3.9' and (((sys_platform == 'linux' or sys_platform == 'win32') and (platform_machine == 'x86_64' or platform_machine == 'AMD64')) or (sys_platform == 'darwin' and platform_machine == 'arm64'))",
]
huggingface = [
"unsloth[huggingfacenotorch]",
"unsloth_zoo>=2026.7.6",
"unsloth_zoo>=2026.7.2",
"torchvision",
"unsloth[triton]",
]
@ -586,19 +531,16 @@ cu126-torch2100 = [
"unsloth[huggingface]",
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
"unsloth[cu126onlytorch2100]",
"unsloth[audio-torch210]",
]
cu128-torch2100 = [
"unsloth[huggingface]",
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
"unsloth[cu128onlytorch2100]",
"unsloth[audio-torch210]",
]
cu130-torch2100 = [
"unsloth[huggingface]",
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
"unsloth[cu130onlytorch2100]",
"unsloth[audio-torch210]",
]
kaggle = [
"unsloth[huggingface]",
@ -637,7 +579,7 @@ colab-ampere-torch220 = [
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
]
colab-new = [
"unsloth_zoo>=2026.7.6",
"unsloth_zoo>=2026.7.2",
"packaging",
"tyro",
"transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0",
@ -888,19 +830,16 @@ cu126-ampere-torch2100 = [
"unsloth[huggingface]",
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
"unsloth[cu126onlytorch2100]",
"unsloth[audio-torch210]",
]
cu128-ampere-torch2100 = [
"unsloth[huggingface]",
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
"unsloth[cu128onlytorch2100]",
"unsloth[audio-torch210]",
]
cu130-ampere-torch2100 = [
"unsloth[huggingface]",
"bitsandbytes>=0.45.5,!=0.46.0,!=0.48.0",
"unsloth[cu130onlytorch2100]",
"unsloth[audio-torch210]",
]
flashattentiontorch260abiFALSEcu12x = [
"flash-attn @ https://github.com/Dao-AILab/flash-attention/releases/download/v2.7.4.post1/flash_attn-2.7.4.post1+cu12torch2.6cxx11abiFALSE-cp39-cp39-linux_x86_64.whl ; ('linux' in sys_platform) and python_version == '3.9'",
@ -1185,8 +1124,7 @@ intelgputorch210 = [
"torchvision @ https://download.pytorch.org/whl/xpu/torchvision-0.25.0%2Bxpu-cp313-cp313-win_amd64.whl#sha256=1c4b44b36a557f7381e3076fb8843366742238648441d607c8d049c6da0f8886 ; sys_platform == 'win32' and python_version == '3.13' and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
]
intel-gpu-torch210 = [
"unsloth[intelgputorch210]",
"unsloth[audio-torch210]",
"unsloth[intelgputorch210]"
]
intelgputorch2110 = [
"unsloth_zoo[intelgpu]",
@ -1267,11 +1205,8 @@ intel = [
]
amd = [
"unsloth[huggingfacenotorch]",
# 4-bit decode is unreliable on ROCm before 0.50.0, the first PyPI release
# carrying the full path: blocksize/warp decoupling (bnb #1887), fused SIMT
# GEMM on RDNA (#1979), RDNA3/4 workgroup fix (#2012).
"bitsandbytes>=0.50.0 ; ('linux' in sys_platform) and (platform_machine == 'AMD64' or platform_machine == 'x86_64' or platform_machine == 'aarch64')",
"bitsandbytes>=0.50.0 ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
"bitsandbytes>=0.49.1 ; ('linux' in sys_platform) and (platform_machine == 'AMD64' or platform_machine == 'x86_64' or platform_machine == 'aarch64')",
"bitsandbytes>=0.49.1 ; (sys_platform == 'win32') and (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
]
rocm702-torch280 = [
"unsloth[amd]",
@ -1343,7 +1278,6 @@ rocm72-torch2100 = [
"torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/torchvision-0.25.0%2Brocm7.2.0.git82df5f59-cp311-cp311-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'",
"torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/torchvision-0.25.0%2Brocm7.2.0.git82df5f59-cp312-cp312-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'",
"torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.2/torchvision-0.25.0%2Brocm7.2.0.git82df5f59-cp313-cp313-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'",
"unsloth[audio-torch210]",
]
rocm711-torch2100 = [
"unsloth[amd]",
@ -1362,7 +1296,6 @@ rocm711-torch2100 = [
"torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.1.1/torchvision-0.25.0%2Brocm7.1.1.git82df5f59-cp311-cp311-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.11' and platform_machine == 'x86_64'",
"torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.1.1/torchvision-0.25.0%2Brocm7.1.1.git82df5f59-cp312-cp312-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.12' and platform_machine == 'x86_64'",
"torchvision @ https://repo.radeon.com/rocm/manylinux/rocm-rel-7.1.1/torchvision-0.25.0%2Brocm7.1.1.git82df5f59-cp313-cp313-linux_x86_64.whl ; platform_system == 'Linux' and python_version == '3.13' and platform_machine == 'x86_64'",
"unsloth[audio-torch210]",
]
[project.urls]

View file

@ -1,71 +0,0 @@
#!/bin/sh
# Build whisper.cpp's whisper-server for Studio's GGUF dictation engine.
#
# Installs into the managed Studio home so the backend's binary discovery
# (core/inference/stt_ggml_sidecar.py::find_whisper_server_binary) picks it up:
# <UNSLOTH_STUDIO_HOME>/whisper.cpp/build/bin/whisper-server (custom home)
# ~/.unsloth/whisper.cpp/build/bin/whisper-server (default)
#
# Usage:
# ./scripts/build_whisper_cpp.sh # build the pinned tag
# WHISPER_CPP_TAG=v1.9.0 ./scripts/build_whisper_cpp.sh
#
# Requires: git, cmake, a C/C++ toolchain (the same prerequisites as a
# llama.cpp source build). GPU backends are auto-detected by whisper.cpp's
# CMake (Metal on macOS; set GGML_CUDA=1 to force a CUDA build on Linux).
set -eu
WHISPER_CPP_SOURCE="${WHISPER_CPP_SOURCE:-https://github.com/ggml-org/whisper.cpp}"
WHISPER_CPP_TAG="${WHISPER_CPP_TAG:-v1.9.1}"
STUDIO_HOME="${UNSLOTH_STUDIO_HOME:-${STUDIO_HOME:-}}"
CUSTOM_STUDIO_HOME=false
if [ -n "$STUDIO_HOME" ]; then
CUSTOM_STUDIO_HOME=true
INSTALL_DIR="$STUDIO_HOME/whisper.cpp"
else
INSTALL_DIR="$HOME/.unsloth/whisper.cpp"
fi
command -v git >/dev/null 2>&1 || { echo "ERROR: git is required" >&2; exit 1; }
command -v cmake >/dev/null 2>&1 || { echo "ERROR: cmake is required" >&2; exit 1; }
# Same policy as studio/setup.sh's _assert_studio_owned_or_absent: never delete
# a directory under a custom Studio home unless Studio itself created it (the
# marker file below). Protects a user-managed whisper.cpp/src from rm -rf.
STUDIO_OWNED_MARKER=".unsloth-studio-owned"
if [ "$CUSTOM_STUDIO_HOME" = true ] && [ -e "$INSTALL_DIR" ] && \
[ ! -f "$INSTALL_DIR/$STUDIO_OWNED_MARKER" ]; then
echo "ERROR: $INSTALL_DIR already exists and is not marked as an Unsloth-owned whisper.cpp build tree." >&2
echo " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." >&2
exit 1
fi
echo "==> Building whisper.cpp ($WHISPER_CPP_TAG) into $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
: > "$INSTALL_DIR/$STUDIO_OWNED_MARKER"
if [ ! -d "$INSTALL_DIR/src/.git" ]; then
rm -rf "$INSTALL_DIR/src"
git clone --depth 1 --branch "$WHISPER_CPP_TAG" "$WHISPER_CPP_SOURCE" "$INSTALL_DIR/src"
else
git -C "$INSTALL_DIR/src" fetch --depth 1 origin "$WHISPER_CPP_TAG"
git -C "$INSTALL_DIR/src" checkout FETCH_HEAD
fi
CMAKE_FLAGS="-DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF"
if [ "${GGML_CUDA:-0}" = "1" ]; then
CMAKE_FLAGS="$CMAKE_FLAGS -DGGML_CUDA=ON"
fi
# shellcheck disable=SC2086
cmake -S "$INSTALL_DIR/src" -B "$INSTALL_DIR/src/build" $CMAKE_FLAGS
NCPU="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)"
cmake --build "$INSTALL_DIR/src/build" --config Release --target whisper-server -j"$NCPU"
mkdir -p "$INSTALL_DIR/build/bin"
cp "$INSTALL_DIR/src/build/bin/whisper-server" "$INSTALL_DIR/build/bin/whisper-server"
echo "==> Installed $INSTALL_DIR/build/bin/whisper-server"
"$INSTALL_DIR/build/bin/whisper-server" --help >/dev/null 2>&1 && echo "==> Binary runs OK"

View file

@ -219,7 +219,7 @@ fi
echo "${ROCM_DIR}/lib" | $SUDO tee /etc/ld.so.conf.d/rocm.conf >/dev/null
$SUDO ldconfig
# ── Step 4: persist environment (system-wide so Unsloth's worker inherits it) ──
# ── Step 4: persist environment (system-wide so Studio's worker inherits it) ──
say "Persisting ROCm-on-WSL environment"
_envfile="/etc/profile.d/unsloth-rocm-wsl.sh"
$SUDO tee "$_envfile" >/dev/null <<EOF

View file

@ -52,14 +52,14 @@ def _normalise_on(on_field):
def _load_workflow(path: Path):
try:
return yaml.safe_load(path.read_text(encoding = "utf-8"))
return yaml.safe_load(path.read_text())
except Exception as exc:
print(f"ERROR: failed to parse {path}: {exc}", file = sys.stderr)
sys.exit(2)
def _extract_cache_keys(path: Path) -> list[str]:
text = path.read_text(encoding = "utf-8")
text = path.read_text()
keys: list[str] = []
for m in re.finditer(r"(?:^|\n)\s*key:\s*([^\n]+)", text):
keys.append(m.group(1).strip())
@ -104,7 +104,7 @@ def main() -> int:
for t in RESTRICTED_TRIGGERS:
if t in triggers:
text = path.read_text(encoding = "utf-8")
text = path.read_text()
if "lint:workflow_triggers-allow-workflow_run" not in text:
findings.append(
f"{path.name}: RESTRICTED trigger '{t}' requires an "

View file

@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Lockfile supply-chain audit for the Unsloth frontend and Tauri shell.
"""Lockfile supply-chain audit for the Studio frontend and Tauri shell.
Runs BEFORE `npm ci` / `cargo fetch` in CI. Refuses to proceed when a
lockfile contains patterns indicating supply-chain injection (npm
@ -294,7 +294,7 @@ CARGO_REGISTRY_SOURCE = "registry+https://github.com/rust-lang/crates.io-index"
# Cargo non-registry source allowlist: `(crate_name, exact_source_string)`.
# Both must match verbatim; bumping the pinned SHA forces a re-review.
# Unsloth's Tauri shell pulls `fix-path-env` from git because it is not
# Studio's Tauri shell pulls `fix-path-env` from git because it is not
# published to crates.io; commit c4c45d5 was reviewed when it landed.
CARGO_SOURCE_ALLOWLIST: tuple[tuple[str, str], ...] = (
(

View file

@ -95,8 +95,8 @@ COLAB_ORACLE_BASE_URL = "https://raw.githubusercontent.com/googlecolab/backend-i
# Source: pytorch/torchcodec compatibility matrix on its README.
TORCH_TORCHCODEC: dict[str, set[str]] = {
"2.10": {"0.10"},
"2.9": {"0.8", "0.9"},
"2.8": {"0.6", "0.7"},
"2.9": {"0.7", "0.8", "0.9"},
"2.8": {"0.6"},
"2.7": {"0.3", "0.4", "0.5"},
"2.6": {"0.2", "0.3"},
"2.5": {"0.1", "0.2"},

View file

@ -1,377 +0,0 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Measure where Unsloth Studio's startup time goes, per platform.
Nothing measured this before: the backend logs "lifespan startup completed in X ms"
but no test or CI job asserted a budget, and studio_test_kit discards the elapsed
time of its /healthz poll. A first local run (Linux, warm cache, fast server CPU)
found `import main` alone costs 6.6s before the server can bind, dominated by eager
module-level imports pulled in by the `routes` package:
torch 1930 ms self
unsloth_zoo 914 ms self
routes 779 ms self
transformers 524 ms self
Phases measured:
import `python -X importtime -c "import main"`, top cumulative + per-package self
spawn process start -> first byte on stdout
healthz process start -> /api/health (or /healthz) answers 200
lifespan the backend's own "lifespan startup completed in X ms" log line
Usage:
python scripts/profile_startup.py --repeats 3 --json out.json
python scripts/profile_startup.py --import-only # no server, no port needed
Exit code is 0 unless --max-healthz-seconds is given and exceeded.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import platform
import re
import shutil
import socket
import statistics
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
BACKEND = REPO_ROOT / "studio" / "backend"
_IMPORTTIME_RE = re.compile(r"import time:\s+(\d+)\s+\|\s+(\d+)\s+\|(\s*)(\S.*)")
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return int(s.getsockname()[1])
def profile_imports(python: str, top: int = 15) -> dict:
"""Cumulative and self import cost for the backend's module graph.
Run in a subprocess with -X importtime: the numbers are only meaningful for a
cold interpreter, and importing in-process would measure a warm sys.modules.
"""
proc = subprocess.run(
[python, "-X", "importtime", "-c", "import sys; sys.path.insert(0, '.'); import main"],
cwd = BACKEND,
capture_output = True,
text = True,
timeout = 900,
)
rows = []
for line in proc.stderr.splitlines():
m = _IMPORTTIME_RE.match(line)
if m:
rows.append((int(m.group(1)), int(m.group(2)), m.group(4).strip()))
if not rows:
return {"ok": False, "error": (proc.stderr or proc.stdout)[-2000:]}
if proc.returncode != 0:
# Rows survive up to the failure, so any total from a partial graph is wrong.
return {
"ok": False,
"error": (proc.stderr or proc.stdout)[-2000:],
"partial_rows": len(rows),
}
by_cum = sorted(rows, key = lambda r: -r[1])
# Total comes from the `main` row, not by_cum[0]: -X importtime also prints the
# interpreter's own startup graph (`site`), which can outrank a trivial main.
main_row = next((r for r in reversed(rows) if r[2] == "main"), None)
if main_row is None:
return {
"ok": False,
"error": "no `import main` row in -X importtime output\n"
+ (proc.stderr or proc.stdout)[-2000:],
}
self_by_pkg: dict[str, int] = {}
for self_us, _cum, name in rows:
pkg = name.split(".")[0]
self_by_pkg[pkg] = self_by_pkg.get(pkg, 0) + self_us
return {
"ok": True,
"total_seconds": round(main_row[1] / 1e6, 3),
"top_cumulative": [
{"module": n, "seconds": round(c / 1e6, 3)} for _s, c, n in by_cum[:top]
],
"self_by_package_ms": {
k: round(v / 1000) for k, v in sorted(self_by_pkg.items(), key = lambda x: -x[1])[:top]
},
}
def _terminate_tree(proc: subprocess.Popen) -> None:
"""Stop the server AND its children, which on Windows are a separate process.
CI profiles `Scripts/unsloth.exe`, a distlib launcher stub that CreateProcess's
the venv python and waits, so terminate() reaps the stub only: the real backend
keeps the inherited stdout handle, the reader thread never sees EOF, and
--repeats strands one server per iteration on the shared UNSLOTH_STUDIO_HOME.
taskkill /T walks the tree, as unsloth_cli/commands/start.py already does.
"""
if proc.poll() is not None:
return
if os.name == "nt":
try:
killed = subprocess.run(
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
capture_output = True,
timeout = 30,
check = False,
)
if killed.returncode == 0:
return
except Exception:
# taskkill missing or timed out; fall through so the stub still dies.
pass
# check=False: a nonzero taskkill does not raise, so fall through as well.
proc.terminate()
def profile_launch(
bin_path: str,
port: int,
timeout_s: int = 300,
) -> dict:
"""Spawn the backend the way the desktop app does and time it to first 200."""
log_lines: list[str] = []
first_byte: list[float] = []
t0 = time.perf_counter()
proc = subprocess.Popen(
[bin_path, "studio", "--api-only", "-H", "127.0.0.1", "-p", str(port)],
cwd = REPO_ROOT,
stdout = subprocess.PIPE,
stderr = subprocess.STDOUT,
text = True,
bufsize = 1,
)
def _drain() -> None:
# Runs alongside the health polling: the first read timestamps the spawn
# phase, and an undrained pipe blocks the backend before it binds.
for line in proc.stdout:
if not first_byte:
first_byte.append(time.perf_counter() - t0)
log_lines.append(line.rstrip("\n"))
reader = threading.Thread(target = _drain, daemon = True)
reader.start()
t_healthz = None
deadline = t0 + timeout_s
try:
while time.perf_counter() < deadline:
if proc.poll() is not None:
break
if t_healthz is None:
for url in (
f"http://127.0.0.1:{port}/api/health",
f"http://127.0.0.1:{port}/healthz",
):
try:
with urllib.request.urlopen(url, timeout = 2) as r:
if r.status == 200:
t_healthz = time.perf_counter() - t0
break
except (urllib.error.URLError, OSError, TimeoutError):
pass
if t_healthz is not None:
break
time.sleep(0.25)
finally:
_terminate_tree(proc)
try:
# Safe: the reader drains the pipe, so the child cannot block on write().
proc.wait(timeout = 30)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
reader.join(timeout = 10)
t_first_byte = first_byte[0] if first_byte else None
lifespan_ms = None
for line in log_lines:
m = re.search(r"lifespan startup completed in ([\d.]+)ms", line)
if m:
lifespan_ms = float(m.group(1))
return {
"spawn_seconds": round(t_first_byte, 3) if t_first_byte is not None else None,
"healthz_seconds": round(t_healthz, 3) if t_healthz is not None else None,
"lifespan_ms": lifespan_ms,
"reached_healthz": t_healthz is not None,
"log_tail": log_lines[-25:],
}
def python_version_of(python: str) -> str:
"""Version of the interpreter that runs the imports, not the one running us.
--python points at the installed Studio venv while this script runs under the
runner's system python, so platform.python_version() would label it wrong.
"""
if python == sys.executable:
return platform.python_version()
try:
proc = subprocess.run(
[python, "-c", "import platform; print(platform.python_version())"],
capture_output = True,
text = True,
timeout = 60,
)
if proc.returncode == 0 and proc.stdout.strip():
return proc.stdout.strip()
except (OSError, subprocess.SubprocessError):
pass
return "unknown"
def find_bin() -> str | None:
home = os.environ.get("UNSLOTH_STUDIO_HOME") or str(Path.home() / ".unsloth" / "studio")
names = ["unsloth.exe", "unsloth"] if platform.system() == "Windows" else ["unsloth"]
subdirs = ["unsloth_studio/Scripts", "unsloth_studio/bin", "bin", "Scripts"]
for sd in subdirs:
for n in names:
p = Path(home) / sd / n
if p.exists():
return str(p)
return shutil.which("unsloth")
def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser(
description = __doc__, formatter_class = argparse.RawDescriptionHelpFormatter
)
ap.add_argument(
"--repeats",
type = int,
default = 1,
help = "launch repeats; the median is reported (imports are measured once)",
)
ap.add_argument(
"--python",
default = sys.executable,
help = "interpreter used for the import profile (default: this one)",
)
ap.add_argument("--bin", help = "path to the unsloth CLI (default: autodetect)")
ap.add_argument(
"--import-only",
action = "store_true",
help = "skip the server phases (no install needed beyond the deps)",
)
ap.add_argument(
"--max-healthz-seconds",
type = float,
help = "fail if the median time to a healthy port exceeds this",
)
ap.add_argument("--json", help = "write the full report here")
a = ap.parse_args(argv)
# range(0) launches nothing, leaving the budget check with nothing to fail on.
if a.repeats < 1:
ap.error("--repeats must be at least 1")
# Same reason: --import-only never launches anything.
if a.import_only and a.max_healthz_seconds is not None:
ap.error("--max-healthz-seconds cannot be combined with --import-only")
# nan and inf parse fine as floats but `med > budget` is then always False,
# so the gate would report success without ever bounding anything.
if a.max_healthz_seconds is not None and not math.isfinite(a.max_healthz_seconds):
ap.error("--max-healthz-seconds must be a finite number")
report: dict = {
"platform": platform.system().lower(),
"machine": platform.machine(),
"python": python_version_of(a.python),
"cpu_count": os.cpu_count(),
}
print("== import graph ==")
report["imports"] = profile_imports(a.python)
imp = report["imports"]
if imp.get("ok"):
print(f" import main: {imp['total_seconds']}s")
for row in imp["top_cumulative"][:8]:
print(f" {row['seconds']:7.3f}s {row['module']}")
print(" self time by package (ms):")
for k, v in list(imp["self_by_package_ms"].items())[:8]:
print(f" {v:8} ms {k}")
else:
print(f" FAILED: {imp.get('error', '')[:400]}")
if not a.import_only:
bin_path = a.bin or find_bin()
if not bin_path:
print(
"== launch == skipped: no unsloth CLI found "
"(set UNSLOTH_STUDIO_HOME or pass --bin)"
)
report["launch"] = {"skipped": "no unsloth CLI found"}
else:
print(f"== launch == {bin_path}")
runs = []
for i in range(a.repeats):
r = profile_launch(bin_path, _free_port())
runs.append(r)
print(
f" run {i + 1}: healthz={r['healthz_seconds']}s "
f"lifespan={r['lifespan_ms']}ms reached={r['reached_healthz']}"
)
got = [r["healthz_seconds"] for r in runs if r["healthz_seconds"] is not None]
report["launch"] = {
"runs": runs,
"failed_runs": sum(1 for r in runs if not r["reached_healthz"]),
"healthz_median_seconds": round(statistics.median(got), 3) if got else None,
"healthz_max_seconds": round(max(got), 3) if got else None,
}
if got:
print(
f" median time to healthy port: {report['launch']['healthz_median_seconds']}s"
)
if a.json:
Path(a.json).write_text(json.dumps(report, indent = 2), encoding = "utf-8")
print(f"\nwrote {a.json}")
if a.max_healthz_seconds is not None:
launch = report.get("launch") or {}
med = launch.get("healthz_median_seconds")
failed = launch.get("failed_runs") or 0
if failed:
# Failed launches fail the budget; dropping them would keep only the fast ones.
print(
f"::error::startup regression: {failed} of {len(launch.get('runs') or [])} "
f"launches never became healthy within the timeout"
)
return 1
if med is None:
# Nothing measured: exiting 0 would pass a requested budget without a
# single health request, so fail closed.
print(
"::error::startup regression: no healthz measurement, so the "
f"{a.max_healthz_seconds}s budget was never checked "
f"({launch.get('skipped') or 'launch phase produced no runs'})"
)
return 1
elif med > a.max_healthz_seconds:
print(
f"::error::startup regression: {med}s median to a healthy port "
f"exceeds the {a.max_healthz_seconds}s budget"
)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -62,7 +62,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
# Hard caps (deliberately conservative; npm tarballs in this repo are
# all well under these limits, so a packaging spike is noticeable).
# ─────────────────────────────────────────────────────────────────────
# Caps calibrated against the real Unsloth frontend transitive closure:
# Caps calibrated against the real Studio frontend transitive closure:
# - typescript.js is 9.1 MB (TS compiler bundled into one file)
# - mermaid 11.x dist/mermaid.js.map is ~12 MB (sourcemap)
# - lightningcss-linux-x64-{gnu,musl}.node is 10 MB

View file

@ -1,5 +1,5 @@
{
"_comment": "scan_packages.py allowlist (reviewed). Each entry is a CRITICAL/HIGH finding manually judged benign. Matched on (package, package-relative file, check, evidence_hash); evidence_hash is over the matched code with L<NN>: markers stripped, so version bumps and line shifts do not reopen an entry but changed code does. severity and evidence are for review only. Regenerate with --write-baseline AFTER reviewing every line.",
"_comment": "scan_packages.py allowlist. Each entry is a CRITICAL/HIGH finding manually judged benign. Matched on (package, package-relative file, check, evidence_hash); evidence_hash is over the matched code with L<NN>: markers stripped, so version bumps and line shifts do not reopen an entry but changed code does. severity and evidence are for review only. Regenerate with --write-baseline AFTER reviewing every line.",
"version": 1,
"entries": [
{
@ -95,16 +95,8 @@
"file": "fastapi/routing.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L587: while True: sha256:06c2c7f15d73bf192e5e3272c5ff5fcaeff7f6774fef5f4eca6ef473ae50e2b3",
"evidence_hash": "57acd497f404c203e4450d0580ad85aa8a33406e8d64ad06fbac6cf47d97b24d"
},
{
"package": "fastapi",
"file": "fastapi/routing.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L592: while True: sha256:84283c09277ded3296998b2a6a838744457b606829cf5ab5d0da6f222ff020a0",
"evidence_hash": "a7295004315e26a8f3c64fb837521e9fdd7268219bb43e000fb0236ab0259223"
"evidence": "L586: while True: sha256:251135b5ebfdd1248916449f32262575e003ef64382501c65b7e4061d67bda45",
"evidence_hash": "365aef4449c8089753d9398417cd76ab762cef547d75db70d87bca9c0b550ab5"
},
{
"package": "fastmcp-slim",
@ -167,8 +159,8 @@
"file": "huggingface_hub/hf_api.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L4677: while True: sha256:04afb38843e4125d1476f3f04bdad0edf1f63f8d75ad49a713b13e4bc68612fb",
"evidence_hash": "18877a2502c862b46a5d7e33fa7c39ab4ef32da7e1b07f596fd455f4376770c6"
"evidence": "L4613: while True: sha256:f764b6ca3118b23c7c0e670e77178c022a6905f825d7df6e528545fa10aae8f6",
"evidence_hash": "9c85d50c227285fa8dc69512999cbb082258cda4b299c7d0e0f69f5aff7accd4"
},
{
"package": "huggingface-hub",
@ -311,8 +303,8 @@
"file": "openai/_base_client.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L274: while True: sha256:90a38e5c1e26893c7c273354143612640e9a9c0f079d3e2b60612d79f24e80a6",
"evidence_hash": "1022e8e8649436ec64a98a9d9141d085452c49549fd2157b0278fc369a83ac66"
"evidence": "L264: while True: sha256:95ca67e46d42354ae650abbdc5b0d97df8b0ed43187800bf40f5690c3901b94b",
"evidence_hash": "a57d8d15fed0bf04f9967dcc18a18b80bb19f4095675bccbb78ac0450d7fce14"
},
{
"package": "openai",
@ -327,8 +319,8 @@
"file": "openai/auth/_workload.py",
"check": "Accesses cloud metadata/IMDS AND makes network calls",
"severity": "CRITICAL",
"evidence": "IMDS: L97: url = \"http://169.254.169.254/metadata/identity/oauth2/token\" | L150: url = \"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity\"\nNetwork: L78: http_client: httpx.Client | None = None, | L109: with httpx.Client() as client: | L134: http_client: httpx.Client | None = None, | L156: with httpx.Client() as client: | L251: exchange_client = DefaultHttpx2Client(follow_redirects=False) if self._use_httpx2 else httpx.Client()",
"evidence_hash": "9717e51cb961dc14c458955d91a1e48e3753997346ecea0106bded3a8d64bfe0"
"evidence": "IMDS: L96: url = \"http://169.254.169.254/metadata/identity/oauth2/token\" | L149: url = \"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity\"\nNetwork: L77: http_client: httpx.Client | None = None, | L108: with httpx.Client() as client: | L133: http_client: httpx.Client | None = None, | L155: with httpx.Client() as client: | L248: with httpx.Client() as client:",
"evidence_hash": "1581d9f4a23393e9af23fbe5ef9f66807b22c5b5a3f1fe167254c9ebee108567"
},
{
"package": "openai",
@ -346,37 +338,29 @@
"evidence": "Env: L105: token = os.environ.get(\"AWS_BEARER_TOKEN_BEDROCK\") | L150: environment_token = os.environ.get(\"AWS_BEARER_TOKEN_BEDROCK\")\nNetwork: L415: http_client: httpx.Client | None = None, | L531: http_client: httpx.Client | None = None, | L649: http_client: httpx.AsyncClient | None = None, | L767: http_client: httpx.AsyncClient | None = None,",
"evidence_hash": "92dbec8ccd79c1e0bc41e93cdd0bdbb091220616c6a1352873196e9dda6bd85c"
},
{
"package": "openai",
"file": "openai/resources/beta/responses/responses.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L4000: while True: sha256:f8ab538118daba9ec06e27399dbdc90a4521c3390e6a47a6348a1f180a83effd",
"evidence_hash": "31481ea83c687acc27144d72d3832d4fb98dd1c79fb5e0ddd85080de95997b9f"
},
{
"package": "openai",
"file": "openai/resources/beta/threads/runs/runs.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L1053: while True: sha256:973bb1aeca2e17e022872dc343a1bf5d8fe33bfa59fe01e2f8fe875522db5bce",
"evidence_hash": "24626e4aa53047a515ead563b42c07c43f73a2c5b82978fa59f58ffc2859e19b"
"evidence": "L1074: while True: sha256:ef6d59a4a10b73a5af491f10af2885b7a309fda9468eb0f9572d19558d3ceb9f",
"evidence_hash": "43c03b55fedcbc980e5e6649c3c4493729128d280cc868349ab9590908ea5f99"
},
{
"package": "openai",
"file": "openai/resources/realtime/realtime.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L311: while True: sha256:5b63313072aae9ca28677e03426513ccf12221e4f4e0ea6c31efbe09790633b5",
"evidence_hash": "05e1af469d651b51673763a7c4cdf759af9472fb627b7b470adc28cc237bd650"
"evidence": "L310: while True: sha256:458198ff3d3f05870bf98c9564cbfd68c739e57b9bbe4120ed81e3eb6af74a05",
"evidence_hash": "a3165d21e46b3ce553795daeae53e8f80e8e89c5cb228e68e6dcaff54bca5a89"
},
{
"package": "openai",
"file": "openai/resources/responses/responses.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L3951: while True: sha256:d68ef896bf0743ca430cfacb9a3353da1f3b9c51c3a21b6450a07a32b55aa2ac",
"evidence_hash": "160eecdd79b521bffbe8476f782b69a0724c35d1b19376a7600807165fd54f9f"
"evidence": "L3803: while True: sha256:1ce0b5a388c747945cdfda1a71b77afdfd03ae840d7aa9fa62f02eb00aa5e29f",
"evidence_hash": "6de300ebb5e6e17cb51c89cbcdf08515a44655182f0776f0908a9d1043ebbcd7"
},
{
"package": "openai",
@ -642,14 +626,6 @@
"evidence": "L1221: os.dup2(self.ostream.fileno(), self.orig_stream_fileno) | L1226: os.dup2(self.orig_stream_dup, self.orig_stream_fileno)",
"evidence_hash": "bba233b67f8ea4f0723b2fecaabf56528531bccd77ace836165bf38b47246bcc"
},
{
"package": "sentencepiece",
"file": "sentencepiece/__init__.py",
"check": "Reverse shell / bind shell pattern",
"severity": "CRITICAL",
"evidence": "L772: os.dup2(self.ostream.fileno(), self.orig_stream_fileno) | L777: os.dup2(self.orig_stream_dup, self.orig_stream_fileno)",
"evidence_hash": "65b5a11cce128fe09b3f238c01bed7c883d1740d7d46d659118f67940f6c17dc"
},
{
"package": "setuptools",
"file": "distutils-precedence.pth",
@ -1553,78 +1529,6 @@
"severity": "HIGH",
"evidence": "Obfusc: L836: code = compile(module, \"<werkzeug routing>\", \"exec\")\nExec: L736: exec(code, globs, locs)",
"evidence_hash": "5c0992c90f05c772abd94d00784f157de337e1f8567f8b3aee1b15e46c96cd5d"
},
{
"package": "unsloth-zoo",
"file": "tests/test_mlx_save_export_regressions.py",
"check": "Writes to /tmp and executes (staged dropper)",
"severity": "CRITICAL",
"evidence": "L165: temporary_location=\"/tmp/ignored\", sha256:ab5c587f9ec31a0cc10ee55698ab133a417148d9d3f371bbc81b1e13fa119c13",
"evidence_hash": "93a11159147aad94f353ec4d2e0b8486b256abef88cd96d741813222cd32b138"
},
{
"package": "unsloth-zoo",
"file": "tests/test_vision_collator_audio.py",
"check": "Writes to /tmp and executes (staged dropper)",
"severity": "CRITICAL",
"evidence": "L111: out = extract_audio_info(msgs({\"type\": \"audio\", key: \"/tmp/a.wav\"})) sha256:2efe23ffbe2b91b8403aec9b700736919b59e5ca770f8e1f5501651b44b7d398",
"evidence_hash": "d416b79dd17b24214f3f7653ac01354507d7bf0fc464dee30a4a4b8998f063ba"
},
{
"package": "openai",
"file": "openai/_base_client.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L274: while True: sha256:90a38e5c1e26893c7c273354143612640e9a9c0f079d3e2b60612d79f24e80a6",
"evidence_hash": "1022e8e8649436ec64a98a9d9141d085452c49549fd2157b0278fc369a83ac66"
},
{
"package": "openai",
"file": "openai/auth/_workload.py",
"check": "Accesses cloud metadata/IMDS AND makes network calls",
"severity": "CRITICAL",
"evidence": "IMDS: L97: url = \"http://169.254.169.254/metadata/identity/oauth2/token\" | L150: url = \"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity\"\nNetwork: L78: http_client: httpx.Client | None = None, | L109: with httpx.Client() as client: | L134: http_client: httpx.Client | None = None, | L156: with httpx.Client() as client: | L251: exchange_client = DefaultHttpx2Client(follow_redirects=False) if self._use_httpx2 else httpx.Client()",
"evidence_hash": "9717e51cb961dc14c458955d91a1e48e3753997346ecea0106bded3a8d64bfe0"
},
{
"package": "openai",
"file": "openai/resources/beta/responses/responses.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L4000: while True: sha256:f8ab538118daba9ec06e27399dbdc90a4521c3390e6a47a6348a1f180a83effd",
"evidence_hash": "31481ea83c687acc27144d72d3832d4fb98dd1c79fb5e0ddd85080de95997b9f"
},
{
"package": "openai",
"file": "openai/resources/realtime/realtime.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L311: while True: sha256:5b63313072aae9ca28677e03426513ccf12221e4f4e0ea6c31efbe09790633b5",
"evidence_hash": "05e1af469d651b51673763a7c4cdf759af9472fb627b7b470adc28cc237bd650"
},
{
"package": "openai",
"file": "openai/resources/responses/responses.py",
"check": "C2 polling/beaconing loop detected",
"severity": "CRITICAL",
"evidence": "L3951: while True: sha256:d68ef896bf0743ca430cfacb9a3353da1f3b9c51c3a21b6450a07a32b55aa2ac",
"evidence_hash": "160eecdd79b521bffbe8476f782b69a0724c35d1b19376a7600807165fd54f9f"
},
{
"package": "unsloth-zoo",
"file": "tests/test_gemma4_forced_float32_ple_dtype.py",
"check": "Advanced obfuscation (marshal/compile/zlib) + exec/eval",
"severity": "HIGH",
"evidence": "Obfusc: L277: compile(rewritten + _GEMMA4_PLE_CAST_HELPER, \"<gemma4-ple-generated>\", \"exec\") | L440: compile(on, \"<gemma4-ple-append>\", \"exec\") | L468: compile(generated, \"<gemma4-ple-crosspath>\", \"exec\")\nExec: L19: exec(_GEMMA4_PLE_CAST_HELPER, namespace)",
"evidence_hash": "a85e24d8e7c431563cbd83b70f91a3b971abde0f37083d68e70984147960cc70"
},
{
"package": "unsloth-zoo",
"file": "tests/test_vision_collator_audio.py",
"check": "Writes to /tmp and executes (staged dropper)",
"severity": "CRITICAL",
"evidence": "L111: out = extract_audio_info(msgs({\"type\": \"audio\", key: \"/tmp/a.wav\"})) sha256:022f81dd21acfc6a35a058de96132834c218404a9e37b3d09a7768a8c8f6c728",
"evidence_hash": "2d1e75446af120d9133a42aa8af426a839d3434d9dc109cc1d6c1b22ca1ddb75"
}
]
}

View file

@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Stamp and verify display-only Unsloth release metadata for builds."""
"""Stamp and verify display-only Studio release metadata for builds."""
from __future__ import annotations
@ -50,7 +50,7 @@ MAX_VERSION_LENGTH = 64
PLACEHOLDER = """# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
\"\"\"Build-stamped Unsloth release metadata.
\"\"\"Build-stamped Studio release metadata.
Release builds may rewrite this module in the build workspace before creating
Python artifacts. Keep the committed value neutral so source checkouts do not
@ -145,7 +145,7 @@ def build_info_source(version: str | None) -> str:
return f'''# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Build-stamped Unsloth release metadata."""
"""Build-stamped Studio release metadata."""
STUDIO_RELEASE_VERSION = {literal}
'''
@ -168,7 +168,7 @@ def stamp(require_release: bool) -> int:
version, source = resolve_version()
if version is not None and not is_valid_version(version):
print(
f"Invalid Unsloth release version from {source}: {version!r}",
f"Invalid Studio release version from {source}: {version!r}",
file = sys.stderr,
)
return 2
@ -196,9 +196,9 @@ def stamp(require_release: bool) -> int:
if version is None:
if require_release:
print(
"No Unsloth release version available. Set "
"No Studio release version available. Set "
"UNSLOTH_STUDIO_RELEASE_VERSION, build from a GitHub tag, "
"or run from an exact local Unsloth release tag.",
"or run from an exact local Studio release tag.",
file = sys.stderr,
)
return 2
@ -207,7 +207,7 @@ def stamp(require_release: bool) -> int:
return 0
_atomic_write_text(BUILD_INFO_PATH, build_info_source(version), encoding = "utf-8")
print(f"Stamping Unsloth release version {version} from {source}", file = sys.stderr)
print(f"Stamping Studio release version {version} from {source}", file = sys.stderr)
print(version)
return 0
@ -233,7 +233,7 @@ def _read_sdist_member(path: Path) -> str | None:
def verify_dist(expected: str, dist_dir: Path) -> int:
if not is_valid_version(expected):
print(f"Invalid expected Unsloth release version: {expected!r}", file = sys.stderr)
print(f"Invalid expected Studio release version: {expected!r}", file = sys.stderr)
return 2
artifacts = list(dist_dir.glob("*.whl")) + list(dist_dir.glob("*.tar.gz"))
@ -251,14 +251,14 @@ def verify_dist(expected: str, dist_dir: Path) -> int:
if content is None:
failures.append(f"{artifact.name}: missing {BUILD_INFO_SUFFIX}")
elif expected_line not in content:
failures.append(f"{artifact.name}: Unsloth release version mismatch")
failures.append(f"{artifact.name}: Studio release version mismatch")
if failures:
for failure in failures:
print(failure, file = sys.stderr)
return 2
print(f"Verified Unsloth release version {expected} in {len(artifacts)} artifact(s)")
print(f"Verified Studio release version {expected} in {len(artifacts)} artifact(s)")
return 0

View file

@ -83,7 +83,7 @@ function Uninstall-UnslothStudio {
}
}
# A path is an Unsloth-owned root iff one of install.ps1's sentinels exists:
# A path is a Studio-owned root iff one of install.ps1's sentinels exists:
# <root>\share\studio.conf, <root>\unsloth_studio\.unsloth-studio-owned,
# or <root>\bin\unsloth.exe.
function _IsStudioRoot {
@ -164,7 +164,7 @@ function Uninstall-UnslothStudio {
return $p
}
# Discover non-default Unsloth roots from env vars + studio.conf files.
# Discover non-default Studio roots from env vars + studio.conf files.
# Mirrors install.ps1's precedence: UNSLOTH_STUDIO_HOME wins, STUDIO_HOME
# is ignored when both are set, so uninstalling install A doesn't also
# delete install B if the user has a stale STUDIO_HOME pointing at B.
@ -207,7 +207,7 @@ function Uninstall-UnslothStudio {
# Return $true iff the PID's image path lives under one of $KnownRoots.
# Prevents killing an unrelated process that happens to listen on a stale
# Unsloth port.
# Studio port.
function _PidUnderKnownRoot {
param([int]$Pid_, [string[]]$KnownRoots)
if (-not $KnownRoots -or $KnownRoots.Count -eq 0) { return $false }
@ -223,8 +223,8 @@ function Uninstall-UnslothStudio {
return $false
}
# Stop an Unsloth backend whose port is recorded in <DataDir>\studio.port.
# Only kills if the listening PID's exe path is under a known Unsloth root.
# Stop a Studio backend whose port is recorded in <DataDir>\studio.port.
# Only kills if the listening PID's exe path is under a known Studio root.
function _StopByPortFile {
param([string]$PortFile, [string[]]$KnownRoots)
if (-not (Test-Path -LiteralPath $PortFile -PathType Leaf)) { return }
@ -372,7 +372,7 @@ function Uninstall-UnslothStudio {
continue
}
if (-not (_IsStudioRoot $r)) {
_Substep "refusing to remove non-Unsloth path: $r" "Yellow"
_Substep "refusing to remove non-Studio path: $r" "Yellow"
continue
}
_RemovePath $r
@ -436,7 +436,7 @@ function Uninstall-UnslothStudio {
$entries = $rawPath -split ';'
$kept = New-Object System.Collections.ArrayList
$removedAny = $false
# Only remove PATH entries that live inside an Unsloth root we
# Only remove PATH entries that live inside a Studio root we
# actually own (default or env-mode). A literal substring
# match on `unsloth_studio` would clobber unrelated user
# virtualenvs that happen to share the name.

View file

@ -12,7 +12,7 @@
set -e
# Stop an Unsloth server via its PID file (written by install.sh's _spawn_terminal).
# Stop a Studio server via its PID file (written by install.sh's _spawn_terminal).
_kill_pid_file() {
_pid_file="$1"
[ -f "$_pid_file" ] || return 0
@ -47,7 +47,7 @@ _pkill_studio() {
command -v pkill >/dev/null 2>&1 || return 0
# Scope fallback patterns to the install roots we are removing so a
# different Unsloth install (different UNSLOTH_STUDIO_HOME) is not touched.
# different Studio install (different UNSLOTH_STUDIO_HOME) is not touched.
_kill_roots="$HOME/.unsloth/studio"
_roots_from_conf=$(_custom_studio_roots 2>/dev/null || true)
[ -n "$_roots_from_conf" ] && _kill_roots="$_kill_roots
@ -89,7 +89,7 @@ _remove_path() {
fi
}
# Accept as Unsloth root only if Unsloth sentinels exist (matches install.sh's
# Accept as Studio root only if Studio sentinels exist (matches install.sh's
# env-mode ownership guard at install.sh:1358-1361). A bare unsloth_studio/
# directory is NOT enough -- require the install-time owner marker so a user
# directory that happens to contain a folder named "unsloth_studio" is safe.
@ -175,8 +175,8 @@ _custom_studio_roots() {
_from_conf "$HOME/.local/share/unsloth/studio.conf"
}
# Remove $HOME/.local/bin/unsloth only if it's an Unsloth-managed symlink.
# Unsloth's install.sh writes this as a symlink into the studio venv
# Remove $HOME/.local/bin/unsloth only if it's a Studio-managed symlink.
# Studio's install.sh writes this as a symlink into the studio venv
# (install.sh: `ln -sfn "$VENV_DIR/bin/unsloth" "$_shim_path"`). A
# pip-installed `unsloth` CLI is a regular file — leave it alone to avoid
# wiping an unrelated install.
@ -206,7 +206,7 @@ _custom_studio_roots | while IFS= read -r _custom_root; do
continue
fi
if ! _is_studio_root "$_custom_root"; then
echo " refusing to remove non-Unsloth path: $_custom_root" >&2
echo " refusing to remove non-Studio path: $_custom_root" >&2
continue
fi
_remove_path "$_custom_root"
@ -234,7 +234,7 @@ _remove_path "$HOME/.unsloth/rocm-smoketest"
# Drop ~/.unsloth only if now empty (rmdir refuses non-empty, so user content is kept).
rmdir "$HOME/.unsloth" 2>/dev/null || true
_remove_path "$HOME/.local/share/unsloth"
# CLI shim: only the symlink Unsloth created, never a pip-installed file.
# CLI shim: only the symlink Studio created, never a pip-installed file.
_remove_cli_shim
echo "Removing desktop shortcut and launcher lock..."

View file

@ -1,34 +0,0 @@
# Unsloth Studio MCP server
Unsloth can expose a local MCP server so an MCP client can inspect models and
GPU state, validate recipes, start or stop training, inspect recipe output, and
export a loaded model.
The server is disabled by default. Enable it for a local Unsloth process with:
```bash
UNSLOTH_STUDIO_ENABLE_MCP=1 \
UNSLOTH_STUDIO_MCP_TOKEN='use-a-local-secret' \
unsloth studio
```
The endpoint is `http://127.0.0.1:8888/mcp/` when Unsloth uses its default port
(a request to `/mcp` redirects to the canonical `/mcp/`). Use the actual Unsloth
port when it is configured differently.
The high-impact tools are:
- `studio_status` and `list_local_models` for discovery
- `get_training_status`, `start_training`, `stop_training`, and `list_training_runs`
- `validate_recipe`, `get_recipe_job_status`, and `get_recipe_job_dataset`
- `load_checkpoint` and `export_gguf`
`start_training` accepts the same fields as the Unsloth `TrainingStartRequest`.
The request is validated by the existing Pydantic model before a subprocess is
started. Export paths use the existing Unsloth validation as well.
The endpoint always requires `UNSLOTH_STUDIO_MCP_TOKEN` and checks an exact
Bearer token for both HTTP and WebSocket connections. Keep it on localhost
unless the deployment has an authenticated reverse proxy. The MCP endpoint is
intentionally opt-in because tools can consume GPU memory, write model
artifacts, and stop active work.

View file

@ -1,145 +1,134 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {
"id": "view-in-github",
"colab_type": "text"
},
"source": [
"<a href=\"https://colab.research.google.com/github/unslothai/unsloth/blob/main/studio/Unsloth_Studio_Colab.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>"
]
},
{
"cell_type": "markdown",
"metadata": {
"id": "6b87de59"
},
"source": [
"To run this, press \"*Runtime*\" and press \"*Run all*\" on a **free** Tesla T4 Google Colab instance!\n",
"<div class=\"align-center\">\n",
"<a href=\"https://unsloth.ai/\"><img src=\"https://github.com/unslothai/unsloth/raw/main/images/unsloth%20new%20logo.png\" width=\"115\"></a>\n",
"<a href=\"https://discord.gg/unsloth\"><img src=\"https://github.com/unslothai/unsloth/raw/main/images/Discord button.png\" width=\"145\"></a>\n",
"<a href=\"https://unsloth.ai/docs/\"><img src=\"https://github.com/unslothai/unsloth/blob/main/images/documentation%20green%20button.png?raw=true\" width=\"125\"></a> Join Discord if you need help + ⭐ <i>Star us on <a href=\"https://github.com/unslothai/unsloth\">Github</a> </i> ⭐\n",
"</div>\n",
"\n",
"To install Unsloth Studio on your local device, follow [our guide](https://unsloth.ai/docs/new/unsloth-studio/install). Unsloth Studio is licensed [AGPL-3.0](https://github.com/unslothai/unsloth/blob/main/studio/LICENSE.AGPL-3.0).\n",
"\n",
"### Unsloth Studio\n",
"\n",
"Train and run open models with [**Unsloth Studio**](https://unsloth.ai/docs/new/unsloth-studio/start). NEW! Installation should now only take 2 mins!\n",
"\n",
"\n",
"We are actively working on making Unsloth Studio install on Colab T4 GPUs faster.\n",
"\n",
"[Features](https://unsloth.ai/docs/new/unsloth-studio#features) • [Quickstart](https://unsloth.ai/docs/new/unsloth-studio/start) • [Data Recipes](https://unsloth.ai/docs/new/unsloth-studio/data-recipe) • [Unsloth Chat](https://unsloth.ai/docs/new/unsloth-studio/chat) • [Export](https://unsloth.ai/docs/new/unsloth-studio/export)"
],
"id": "6b87de59"
},
{
"cell_type": "markdown",
"metadata": {
"id": "e4206349"
},
"source": [
"<p align=\"left\"><img src=\"https://github.com/unslothai/unsloth/raw/main/studio/frontend/public/studio%20github%20landscape%20colab%20display.png\" width=\"600\"></p>"
],
"id": "e4206349"
},
{
"cell_type": "markdown",
"metadata": {
"id": "27da2957"
},
"source": [
"### Setup: Clone repo and run setup"
],
"id": "27da2957"
},
{
"cell_type": "code",
"metadata": {
"id": "27e68f91"
},
"source": "!git clone --depth 1 --branch main https://github.com/unslothai/unsloth.git\n%cd /content/unsloth\n!chmod +x studio/setup.sh && ./studio/setup.sh --local",
"execution_count": null,
"outputs": [],
"id": "27e68f91"
},
{
"cell_type": "markdown",
"metadata": {
"id": "3e1771a9"
},
"source": [
"### Start Unsloth Studio"
],
"id": "3e1771a9"
},
{
"cell_type": "code",
"metadata": {
"id": "277e431e"
},
"source": [
"import sys\n",
"sys.path.insert(0, \"/content/unsloth/studio/backend\")\n",
"from colab import start\n",
"\n",
"# On Colab, start() auto-opens a Cloudflare link and prints admin login credentials.\n",
"# Use the Cloudflare link above the ready card to open Studio (in-cell iframes often stay blank).\n",
"start()\n",
"\n",
"# To skip the Cloudflare tunnel and try the in-notebook proxy iframe only:\n",
"# start(cloudflare=False)"
],
"execution_count": null,
"outputs": [],
"id": "277e431e"
},
{
"cell_type": "markdown",
"metadata": {
"id": "f2b0c6a1"
},
"source": [
"And we're done! If you have any questions on Unsloth, we have a [Discord](https://discord.gg/unsloth) channel! If you find any bugs or want to keep updated with the latest LLM stuff, or need help, join projects etc, feel free to join our Discord!\n",
"\n",
"Some other resources:\n",
"1. Looking to use Unsloth locally? Read our [Installation Guide](https://unsloth.ai/docs/get-started/install) for details on installing Unsloth on Windows, Docker, AMD, Intel GPUs.\n",
"2. Learn how to do Reinforcement Learning with our [RL Guide and notebooks](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide).\n",
"3. Read our guides and notebooks for [Text-to-speech (TTS)](https://unsloth.ai/docs/basics/text-to-speech-tts-fine-tuning) and [vision](https://unsloth.ai/docs/basics/vision-fine-tuning) model support.\n",
"4. Explore our [LLM Tutorials Directory](https://unsloth.ai/docs/models/tutorials-how-to-fine-tune-and-run-llms) to find dedicated guides for each model.\n",
"5. Need help with Inference? Read our [Inference & Deployment page](https://unsloth.ai/docs/basics/inference-and-deployment) for details on using vLLM, llama.cpp, Ollama etc.\n",
"\n",
"<div class=\"align-center\">\n",
" <a href=\"https://unsloth.ai\"><img src=\"https://github.com/unslothai/unsloth/raw/main/images/unsloth%20new%20logo.png\" width=\"115\"></a>\n",
" <a href=\"https://discord.gg/unsloth\"><img src=\"https://github.com/unslothai/unsloth/raw/main/images/Discord.png\" width=\"145\"></a>\n",
" <a href=\"https://unsloth.ai/docs/\"><img src=\"https://github.com/unslothai/unsloth/blob/main/images/documentation%20green%20button.png?raw=true\" width=\"125\"></a>\n",
"\n",
" Join Discord if you need help + ⭐️ <i>Star us on <a href=\"https://github.com/unslothai/unsloth\">Github</a> </i> ⭐️\n",
"\n",
" <b>This notebook is licensed <a href=\"https://github.com/unslothai/unsloth/blob/main/studio/LICENSE.AGPL-3.0\">AGPL-3.0</a></b>\n",
"</div>"
],
"id": "f2b0c6a1"
}
],
"metadata": {
"accelerator": "GPU",
"colab": {
"gpuType": "T4",
"provenance": [],
"include_colab_link": true
},
"kernelspec": {
"display_name": "Python 3",
"name": "python3"
},
"language_info": {
"name": "python"
}
"cells": [
{
"cell_type": "markdown",
"metadata": {
"id": "view-in-github",
"colab_type": "text"
},
"source": [
"<a href=\"https://colab.research.google.com/github/unslothai/unsloth/blob/main/studio/Unsloth_Studio_Colab.ipynb\" target=\"_parent\"><img src=\"https://colab.research.google.com/assets/colab-badge.svg\" alt=\"Open In Colab\"/></a>"
]
},
"nbformat": 4,
"nbformat_minor": 5
{
"cell_type": "markdown",
"id": "6b87de59",
"metadata": {
"id": "6b87de59"
},
"source": [
"To run this, press \"*Runtime*\" and press \"*Run all*\" on a **free** Tesla T4 Google Colab instance!\n",
"<div class=\"align-center\">\n",
"<a href=\"https://unsloth.ai/\"><img src=\"https://github.com/unslothai/unsloth/raw/main/images/unsloth%20new%20logo.png\" width=\"115\"></a>\n",
"<a href=\"https://discord.gg/unsloth\"><img src=\"https://github.com/unslothai/unsloth/raw/main/images/Discord button.png\" width=\"145\"></a>\n",
"<a href=\"https://unsloth.ai/docs/\"><img src=\"https://github.com/unslothai/unsloth/blob/main/images/documentation%20green%20button.png?raw=true\" width=\"125\"></a> Join Discord if you need help + ⭐ <i>Star us on <a href=\"https://github.com/unslothai/unsloth\">Github</a> </i> ⭐\n",
"</div>\n",
"\n",
"To install Unsloth Studio on your local device, follow [our guide](https://unsloth.ai/docs/new/unsloth-studio/install). Unsloth Studio is licensed [AGPL-3.0](https://github.com/unslothai/unsloth/blob/main/studio/LICENSE.AGPL-3.0).\n",
"\n",
"### Unsloth Studio\n",
"\n",
"Train and run open models with [**Unsloth Studio**](https://unsloth.ai/docs/new/unsloth-studio/start). NEW! Installation should now only take 2 mins!\n",
"\n",
"\n",
"We are actively working on making Unsloth Studio install on Colab T4 GPUs faster.\n",
"\n",
"[Features](https://unsloth.ai/docs/new/unsloth-studio#features) • [Quickstart](https://unsloth.ai/docs/new/unsloth-studio/start) • [Data Recipes](https://unsloth.ai/docs/new/unsloth-studio/data-recipe) • [Studio Chat](https://unsloth.ai/docs/new/unsloth-studio/chat) • [Export](https://unsloth.ai/docs/new/unsloth-studio/export)"
]
},
{
"cell_type": "markdown",
"id": "e4206349",
"metadata": {
"id": "e4206349"
},
"source": [
"<p align=\"left\"><img src=\"https://github.com/unslothai/unsloth/raw/main/studio/frontend/public/studio%20github%20landscape%20colab%20display.png\" width=\"600\"></p>"
]
},
{
"cell_type": "markdown",
"id": "27da2957",
"metadata": {
"id": "27da2957"
},
"source": [
"### Setup: Clone repo and run setup"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "27e68f91",
"metadata": {
"id": "27e68f91"
},
"outputs": [],
"source": "!git clone --depth 1 --branch main https://github.com/unslothai/unsloth.git\n%cd /content/unsloth\n!chmod +x studio/setup.sh && ./studio/setup.sh --local"
},
{
"cell_type": "markdown",
"id": "3e1771a9",
"metadata": {
"id": "3e1771a9"
},
"source": [
"### Start Unsloth Studio"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "277e431e",
"metadata": {
"id": "277e431e"
},
"outputs": [],
"source": "import sys\nsys.path.insert(0, \"/content/unsloth/studio/backend\")\nfrom colab import start\n\n# Default: in-tab iframe only. start() blocks to keep the kernel alive.\nstart()\n\n# For a shareable Cloudflare link, replace start() above with:\n# start(cloudflare=True)"
},
{
"cell_type": "markdown",
"id": "f2b0c6a1",
"metadata": {
"id": "f2b0c6a1"
},
"source": [
"And we're done! If you have any questions on Unsloth, we have a [Discord](https://discord.gg/unsloth) channel! If you find any bugs or want to keep updated with the latest LLM stuff, or need help, join projects etc, feel free to join our Discord!\n",
"\n",
"Some other resources:\n",
"1. Looking to use Unsloth locally? Read our [Installation Guide](https://unsloth.ai/docs/get-started/install) for details on installing Unsloth on Windows, Docker, AMD, Intel GPUs.\n",
"2. Learn how to do Reinforcement Learning with our [RL Guide and notebooks](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide).\n",
"3. Read our guides and notebooks for [Text-to-speech (TTS)](https://unsloth.ai/docs/basics/text-to-speech-tts-fine-tuning) and [vision](https://unsloth.ai/docs/basics/vision-fine-tuning) model support.\n",
"4. Explore our [LLM Tutorials Directory](https://unsloth.ai/docs/models/tutorials-how-to-fine-tune-and-run-llms) to find dedicated guides for each model.\n",
"5. Need help with Inference? Read our [Inference & Deployment page](https://unsloth.ai/docs/basics/inference-and-deployment) for details on using vLLM, llama.cpp, Ollama etc.\n",
"\n",
"<div class=\"align-center\">\n",
" <a href=\"https://unsloth.ai\"><img src=\"https://github.com/unslothai/unsloth/raw/main/images/unsloth%20new%20logo.png\" width=\"115\"></a>\n",
" <a href=\"https://discord.gg/unsloth\"><img src=\"https://github.com/unslothai/unsloth/raw/main/images/Discord.png\" width=\"145\"></a>\n",
" <a href=\"https://unsloth.ai/docs/\"><img src=\"https://github.com/unslothai/unsloth/blob/main/images/documentation%20green%20button.png?raw=true\" width=\"125\"></a>\n",
"\n",
" Join Discord if you need help + ⭐️ <i>Star us on <a href=\"https://github.com/unslothai/unsloth\">Github</a> </i> ⭐️\n",
"\n",
" <b>This notebook is licensed <a href=\"https://github.com/unslothai/unsloth/blob/main/studio/LICENSE.AGPL-3.0\">AGPL-3.0</a></b>\n",
"</div>"
]
}
],
"metadata": {
"accelerator": "GPU",
"colab": {
"gpuType": "T4",
"provenance": [],
"include_colab_link": true
},
"kernelspec": {
"display_name": "Python 3",
"name": "python3"
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -3,7 +3,7 @@
Source: google/gemma-4-31B-it HF discussion/PR #118 (adds the preserve_thinking
flag plus null-rendering, string-arguments validation, balanced turn tags, empty
messages handling, and OpenAI image_url/input_audio aliases).
Unsloth-local changes vs PR #118:
Studio-local changes vs PR #118:
1. preserve_thinking defaults to false (see SETUP block below).
2. The empty "<|channel>thought\n<channel|>" block on enable_thinking=false is
NOT emitted. Google ships a distinct template for E2B/E4B (google/gemma-4-E2B-it,

View file

@ -3,7 +3,7 @@
Source: google/gemma-4-31B-it HF discussion/PR #118 (adds the preserve_thinking
flag plus null-rendering, string-arguments validation, balanced turn tags, empty
messages handling, and OpenAI image_url/input_audio aliases).
Unsloth-local change: preserve_thinking defaults to false (see SETUP block below).
Studio-local change: preserve_thinking defaults to false (see SETUP block below).
Applied to unsloth/gemma-4-*-GGUF models so the embedded GGUF template does not
need re-downloading. Keep in sync with upstream if PR #118 changes.
-#}

View file

@ -30,7 +30,6 @@ lora:
vision_all_linear: false
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -30,7 +30,6 @@ lora:
vision_all_linear: false
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -33,7 +33,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -34,7 +34,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -30,7 +30,6 @@ lora:
- "query"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -30,7 +30,6 @@ lora:
- "value"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -33,7 +33,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -29,7 +29,6 @@ lora:
- "Wqkv"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -34,7 +34,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -35,7 +35,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -34,7 +34,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -35,7 +35,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -35,7 +35,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -33,7 +33,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -34,7 +34,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -35,7 +35,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -29,7 +29,6 @@ lora:
- "all-linear"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -29,7 +29,6 @@ lora:
- "all-linear"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -29,7 +29,6 @@ lora:
- "all-linear"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -29,7 +29,6 @@ lora:
- "all-linear"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -29,7 +29,6 @@ lora:
- "all-linear"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -26,7 +26,6 @@ lora:
- "all-linear"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -26,7 +26,6 @@ lora:
- "all-linear"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -26,7 +26,6 @@ lora:
- "all-linear"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -26,7 +26,6 @@ lora:
- "all-linear"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -26,7 +26,6 @@ lora:
- "all-linear"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -26,7 +26,6 @@ lora:
- "all-linear"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -26,7 +26,6 @@ lora:
- "all-linear"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -26,7 +26,6 @@ lora:
- "all-linear"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -35,7 +35,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -35,7 +35,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -37,7 +37,6 @@ lora:
- "shared_mlp.output_linear"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -37,7 +37,6 @@ lora:
- "shared_mlp.output_linear"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -29,7 +29,6 @@ lora:
- "all-linear"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

View file

@ -34,7 +34,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -35,7 +35,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -35,7 +35,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -34,7 +34,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -34,7 +34,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -34,7 +34,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -34,7 +34,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -30,7 +30,6 @@ lora:
- "v_proj"
use_rslora: false
use_loftq: false
use_dora: false
logging:
enable_wandb: false

View file

@ -35,7 +35,6 @@ lora:
- "down_proj"
use_rslora: false
use_loftq: false
use_dora: false
finetune_vision_layers: true
finetune_language_layers: true
finetune_attention_modules: true

Some files were not shown because too many files have changed in this diff Show more